Skip to content

Commit

Permalink
Incrementally re-render results after refetch or skip when using …
Browse files Browse the repository at this point in the history
…`@defer` with `useSuspenseQuery` (#11035)

When issuing a query with an `@defer` directive, calling refetch or enabling a query by disabling the skip option would only re-render when the entire result was loaded. This felt like it defeated the purpose of the `@defer` directive in these cases.

This adds the ability to incrementally re-render results returned by `@defer` queries to match the behavior of the initial fetch. This also makes a first attempt at refactoring some of `QueryReference` to remove flag-based state and replace it with a status enum.

NOTE: This attempts to add support for incrementally re-rendering `fetchMore`, but there is currently a bug in core that prevents this from happening. A test has been added that documents the existing behavior for completeness.
  • Loading branch information
jerelmiller committed Jul 7, 2023
1 parent 4555ff7 commit a3ab745
Show file tree
Hide file tree
Showing 7 changed files with 1,388 additions and 177 deletions.
5 changes: 5 additions & 0 deletions .changeset/orange-suns-check.md
@@ -0,0 +1,5 @@
---
'@apollo/client': patch
---

Incrementally re-render deferred queries after calling `refetch` or setting `skip` to `false` to match the behavior of the initial fetch. Previously, the hook would not re-render until the entire result had finished loading in these cases.
1 change: 1 addition & 0 deletions .prettierignore
Expand Up @@ -67,6 +67,7 @@ src/utilities/types/*
src/utilities/common/*
!src/utilities/common/stripTypename.ts
!src/utilities/common/omitDeep.ts
!src/utilities/common/tap.ts
!src/utilities/common/__tests__/
src/utilities/common/__tests__/*
!src/utilities/common/__tests__/omitDeep.ts
Expand Down
2 changes: 1 addition & 1 deletion .size-limit.cjs
@@ -1,7 +1,7 @@
const checks = [
{
path: "dist/apollo-client.min.cjs",
limit: "37860"
limit: "37880"
},
{
path: "dist/main.cjs",
Expand Down
150 changes: 73 additions & 77 deletions src/react/cache/QueryReference.ts
Expand Up @@ -6,9 +6,12 @@ import type {
OperationVariables,
WatchQueryOptions,
} from '../../core/index.js';
import { NetworkStatus, isNetworkRequestSettled } from '../../core/index.js';
import { isNetworkRequestSettled } from '../../core/index.js';
import type { ObservableSubscription } from '../../utilities/index.js';
import { createFulfilledPromise, createRejectedPromise } from '../../utilities/index.js';
import {
createFulfilledPromise,
createRejectedPromise,
} from '../../utilities/index.js';
import type { CacheKey } from './types.js';
import type { useBackgroundQuery, useReadQuery } from '../hooks/index.js';

Expand Down Expand Up @@ -54,8 +57,7 @@ export class InternalQueryReference<TData = unknown> {
private subscription: ObservableSubscription;
private listeners = new Set<Listener<TData>>();
private autoDisposeTimeoutId: NodeJS.Timeout;
private initialized = false;
private refetching = false;
private status: 'idle' | 'loading' = 'loading';

private resolve: ((result: ApolloQueryResult<TData>) => void) | undefined;
private reject: ((error: unknown) => void) | undefined;
Expand All @@ -67,6 +69,7 @@ export class InternalQueryReference<TData = unknown> {
this.listen = this.listen.bind(this);
this.handleNext = this.handleNext.bind(this);
this.handleError = this.handleError.bind(this);
this.initiateFetch = this.initiateFetch.bind(this);
this.dispose = this.dispose.bind(this);
this.observable = observable;
this.result = observable.getCurrentResult(false);
Expand All @@ -79,25 +82,33 @@ export class InternalQueryReference<TData = unknown> {
if (
isNetworkRequestSettled(this.result.networkStatus) ||
(this.result.data &&
(!this.result.partial || this.observable.options.returnPartialData))
(!this.result.partial || this.watchQueryOptions.returnPartialData))
) {
this.promise = createFulfilledPromise(this.result);
this.initialized = true;
this.refetching = false;
}

this.subscription = observable.subscribe({
next: this.handleNext,
error: this.handleError,
});

if (!this.promise) {
this.status = 'idle';
} else {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}

this.subscription = observable
.map((result) => {
// Maintain the last successful `data` value if the next result does not
// have one.
if (result.data === void 0) {
result.data = this.result.data;
}

return result;
})
.filter(({ data }) => !equal(data, {}))
.subscribe({
next: this.handleNext,
error: this.handleError,
});

// Start a timer that will automatically dispose of the query if the
// suspended resource does not use this queryRef in the given time. This
// helps prevent memory leaks when a component has unmounted before the
Expand All @@ -120,22 +131,26 @@ export class InternalQueryReference<TData = unknown> {
}

applyOptions(watchQueryOptions: WatchQueryOptions) {
const { fetchPolicy: currentFetchPolicy } = this.watchQueryOptions;
const {
fetchPolicy: currentFetchPolicy,
canonizeResults: currentCanonizeResults,
} = this.watchQueryOptions;

// "standby" is used when `skip` is set to `true`. Detect when we've
// enabled the query (i.e. `skip` is `false`) to execute a network request.
if (
currentFetchPolicy === 'standby' &&
currentFetchPolicy !== watchQueryOptions.fetchPolicy
) {
this.promise = this.observable.reobserve(watchQueryOptions);
this.observable.reobserve(watchQueryOptions);
this.initiateFetch();
} else {
this.observable.silentSetOptions(watchQueryOptions);

// Maintain the previous result in case the current result does not return
// a `data` property.
this.result = { ...this.result, ...this.observable.getCurrentResult() };
this.promise = createFulfilledPromise(this.result);
if (currentCanonizeResults !== watchQueryOptions.canonizeResults) {
this.result = { ...this.result, ...this.observable.getCurrentResult() };
this.promise = createFulfilledPromise(this.result);
}
}

return this.promise;
Expand All @@ -155,29 +170,17 @@ export class InternalQueryReference<TData = unknown> {
}

refetch(variables: OperationVariables | undefined) {
this.refetching = true;

const promise = this.observable.refetch(variables);

this.promise = promise;
this.initiateFetch();

return promise;
}

fetchMore(options: FetchMoreOptions<TData>) {
const promise = this.observable.fetchMore<TData>(options);

this.promise = promise;

return promise;
}

reobserve(
watchQueryOptions: Partial<WatchQueryOptions<OperationVariables, TData>>
) {
const promise = this.observable.reobserve(watchQueryOptions);

this.promise = promise;
this.initiateFetch();

return promise;
}
Expand All @@ -192,59 +195,52 @@ export class InternalQueryReference<TData = unknown> {
}

private handleNext(result: ApolloQueryResult<TData>) {
if (!this.initialized || this.refetching) {
if (!isNetworkRequestSettled(result.networkStatus)) {
return;
switch (this.status) {
case 'loading': {
this.status = 'idle';
this.result = result;
this.resolve?.(result);
break;
}

// If we encounter an error with the new result after we have successfully
// fetched a previous result, set the new result data to the last successful
// result.
if (this.result.data && result.data === void 0) {
result.data = this.result.data;
}

this.initialized = true;
this.refetching = false;
this.result = result;
if (this.resolve) {
this.resolve(result);
case 'idle': {
if (result.data === this.result.data) {
return;
}

this.result = result;
this.promise = createFulfilledPromise(result);
this.deliver(this.promise);
break;
}
return;
}

if (result.data === this.result.data) {
return;
}

this.result = result;
this.promise = createFulfilledPromise(result);
this.deliver(this.promise);
}

private handleError(error: ApolloError) {
const result = {
...this.result,
error,
networkStatus: NetworkStatus.error,
};

this.result = result;

if (!this.initialized || this.refetching) {
this.initialized = true;
this.refetching = false;
if (this.reject) {
this.reject(error);
switch (this.status) {
case 'loading': {
this.status = 'idle';
this.reject?.(error);
break;
}
case 'idle': {
this.promise = createRejectedPromise(error);
this.deliver(this.promise);
}
return;
}

this.promise = createRejectedPromise(error);
this.deliver(this.promise);
}

private deliver(promise: Promise<ApolloQueryResult<TData>>) {
this.listeners.forEach((listener) => listener(promise));
}

private initiateFetch() {
this.status = 'loading';

this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});

this.promise.catch(() => {});
}
}

0 comments on commit a3ab745

Please sign in to comment.