Skip to content

Commit

Permalink
Fix the compatibility between useSuspenseQuery and React's `useDefe…
Browse files Browse the repository at this point in the history
…rredValue` and `startTransition` APIs (#10672)

Co-authored-by: Ben Newman <[email protected]>
  • Loading branch information
jerelmiller and benjamn authored Mar 28, 2023
1 parent 3eec079 commit 932252b
Show file tree
Hide file tree
Showing 17 changed files with 1,572 additions and 614 deletions.
9 changes: 9 additions & 0 deletions .changeset/neat-rockets-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@apollo/client': patch
---

Fix the compatibility between `useSuspenseQuery` and React's `useDeferredValue` and `startTransition` APIs to allow React to show stale UI while the changes to the variable cause the component to suspend.

# Breaking change

`nextFetchPolicy` support has been removed from `useSuspenseQuery`. If you are using this option, remove it, otherwise it will be ignored.
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ src/react/*
# Allow src/react/cache
!src/react/cache/

# Allowed utilities
!src/utilities/
src/utilities/*
!src/utilities/promises/

## Allowed React Hooks
!src/react/hooks/
src/react/hooks/*
Expand Down
2 changes: 1 addition & 1 deletion config/bundlesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";
import { gzipSync } from "zlib";
import bytes from "bytes";

const gzipBundleByteLengthLimit = bytes("34.1KB");
const gzipBundleByteLengthLimit = bytes("34.14KB");
const minFile = join("dist", "apollo-client.min.cjs");
const minPath = join(__dirname, "..", minFile);
const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength;
Expand Down
25 changes: 4 additions & 21 deletions src/core/ObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ export class ObservableQuery<
options: WatchQueryOptions<TVariables, TData>;
}) {
super((observer: Observer<ApolloQueryResult<TData>>) => {
const { fetchOnFirstSubscribe = true } = options

// Zen Observable has its own error function, so in order to log correctly
// we need to provide a custom error callback.
try {
Expand All @@ -134,7 +132,7 @@ export class ObservableQuery<

// Initiate observation of this query if it hasn't been reported to
// the QueryManager yet.
if (first && fetchOnFirstSubscribe) {
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
Expand Down Expand Up @@ -784,15 +782,10 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`);
return this.last;
}

// For cases like suspense with a deferred query where we need a custom
// promise wrapped around the concast, we need access to the raw concast
// created from `reobserve`. This function provides the original `reobserve`
// functionality, but returns a concast instead of a promise. Most consumers
// should prefer `reobserve` instead of this function.
public reobserveAsConcast(
public reobserve(
newOptions?: Partial<WatchQueryOptions<TVariables, TData>>,
newNetworkStatus?: NetworkStatus
): Concast<ApolloQueryResult<TData>> {
): Promise<ApolloQueryResult<TData>> {
this.isTornDown = false;

const useDisposableConcast =
Expand Down Expand Up @@ -865,17 +858,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`);

concast.addObserver(observer);

return concast;
}

public reobserve(
newOptions?: Partial<WatchQueryOptions<TVariables, TData>>,
newNetworkStatus?: NetworkStatus,
): Promise<ApolloQueryResult<TData>> {
return this.reobserveAsConcast(
newOptions,
newNetworkStatus
).promise
return concast.promise;
}

// (Re)deliver the current result to this.observers without applying fetch
Expand Down
10 changes: 10 additions & 0 deletions src/core/networkStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,13 @@ export function isNetworkRequestInFlight(
): boolean {
return networkStatus ? networkStatus < 7 : false;
}

/**
* Returns true if the network request is in ready or error state according to a given network
* status.
*/
export function isNetworkRequestSettled(
networkStatus?: NetworkStatus,
): boolean {
return networkStatus === 7 || networkStatus === 8;
}
6 changes: 0 additions & 6 deletions src/core/watchQueryOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,6 @@ export interface WatchQueryOptions<TVariables extends OperationVariables = Opera
* behavior, for backwards compatibility with Apollo Client 3.x.
*/
refetchWritePolicy?: RefetchWritePolicy;

/**
* Determines whether the observable should execute a request when the first
* observer subscribes to it.
*/
fetchOnFirstSubscribe?: boolean
}

export interface NextFetchPolicyContext<TData, TVariables extends OperationVariables> {
Expand Down
170 changes: 170 additions & 0 deletions src/react/cache/QuerySubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {
ApolloError,
ApolloQueryResult,
DocumentNode,
NetworkStatus,
ObservableQuery,
OperationVariables,
} from '../../core';
import { isNetworkRequestSettled } from '../../core/networkStatus';
import {
Concast,
ObservableSubscription,
hasAnyDirectives,
} from '../../utilities';
import { invariant } from '../../utilities/globals';
import { wrap } from 'optimism';

type Listener<TData> = (result: ApolloQueryResult<TData>) => void;

type FetchMoreOptions<TData> = Parameters<
ObservableQuery<TData>['fetchMore']
>[0];

function wrapWithCustomPromise<TData>(
concast: Concast<ApolloQueryResult<TData>>
) {
return new Promise<ApolloQueryResult<TData>>((resolve, reject) => {
// Unlike `concast.promise`, we want to resolve the promise on the initial
// chunk of the deferred query. This allows the component to unsuspend
// when we get the initial set of data, rather than waiting until all
// chunks have been loaded.
const subscription = concast.subscribe({
next: (value) => {
resolve(value);
subscription.unsubscribe();
},
error: reject,
});
});
}

const isMultipartQuery = wrap((query: DocumentNode) => {
return hasAnyDirectives(['defer', 'stream'], query);
});

interface QuerySubscriptionOptions {
onDispose?: () => void;
autoDisposeTimeoutMs?: number;
}

export class QuerySubscription<TData = any> {
public result: ApolloQueryResult<TData>;
public promise: Promise<ApolloQueryResult<TData>>;
public readonly observable: ObservableQuery<TData>;

private subscription: ObservableSubscription;
private listeners = new Set<Listener<TData>>();
private autoDisposeTimeoutId: NodeJS.Timeout;

constructor(
observable: ObservableQuery<TData>,
options: QuerySubscriptionOptions = Object.create(null)
) {
this.listen = this.listen.bind(this);
this.handleNext = this.handleNext.bind(this);
this.handleError = this.handleError.bind(this);
this.dispose = this.dispose.bind(this);
this.observable = observable;
this.result = observable.getCurrentResult();

if (options.onDispose) {
this.onDispose = options.onDispose;
}

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

// This error should never happen since the `.subscribe` call above
// will ensure a concast is set on the observable via the `reobserve`
// call. Unless something is going horribly wrong and completely messing
// around with the internals of the observable, there should always be a
// concast after subscribing.
invariant(
observable['concast'],
'Unexpected error: A concast was not found on the observable.'
);

const concast = observable['concast'];

this.promise = isMultipartQuery(observable.query)
? wrapWithCustomPromise(concast)
: concast.promise;

// Start a timer that will automatically dispose of the query if the
// suspended resource does not use this subscription in the given time. This
// helps prevent memory leaks when a component has unmounted before the
// query has finished loading.
this.autoDisposeTimeoutId = setTimeout(
this.dispose,
options.autoDisposeTimeoutMs ?? 30_000
);
}

listen(listener: Listener<TData>) {
// As soon as the component listens for updates, we know it has finished
// suspending and is ready to receive updates, so we can remove the auto
// dispose timer.
clearTimeout(this.autoDisposeTimeoutId);

this.listeners.add(listener);

return () => {
this.listeners.delete(listener);
};
}

refetch(variables: OperationVariables | undefined) {
this.promise = this.observable.refetch(variables);

return this.promise;
}

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

return this.promise;
}

dispose() {
this.subscription.unsubscribe();
this.onDispose();
}

private onDispose() {
// noop. overridable by options
}

private handleNext(result: ApolloQueryResult<TData>) {
// If we encounter an error with the new result after we have successfully
// fetched a previous result, we should set the new result data to the last
// successful result.
if (
isNetworkRequestSettled(result.networkStatus) &&
this.result.data &&
result.data === void 0
) {
result.data = this.result.data;
}

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

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

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

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

0 comments on commit 932252b

Please sign in to comment.