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 the compatibility between useSuspenseQuery and React's useDeferredValue and startTransition APIs #10672

Merged
merged 166 commits into from Mar 28, 2023

Conversation

jerelmiller
Copy link
Member

@jerelmiller jerelmiller commented Mar 23, 2023

Fixes #10534

Major refactor to useSuspenseQuery that allows it to work with useDeferredValue and startTransition when changing variables.

There are several improvements to this re-implementation that are of note:

  • We no longer create and subscribe to a single ObservableQuery for each use of the hook. Instead ObservableQuery instances are created per unique query/variables combination.
  • These ObservableQuery instances are stored and subscribed to in an instance managed outside of React. Not having to manage the lifecycle of the query inside of the React + Suspense lifecycle meant I was able to remove some nasty hacks that plagued the previous implementation.
  • We have 1 less render when changing variables because we now suspend immediately when changing variables. This also makes it possible for the hook to be properly used with useDeferredValue and startTransition since these APIs require measuring whether the change results in the component suspending or not.
  • The networkStatus is now set properly using suspensePolicy: 'initial' when using refetch, fetchMore, or changing variables since we more reliably read the result state.

NOTE: The refetch and fetchMore functions returned from this hook still don't quite work right with startTranstion. I'm hoping to fix this in a future PR. If we can get those functions working with startTransition, we should be able to remove the suspensePolicy option from the hook since startTransition gives us the UX behavior that suspensePolicy was meant to replicate. I've opened #10676 to track this.

Checklist:

  • If this PR contains changes to the library itself (not necessary for e.g. docs updates), please include a changeset (see CONTRIBUTING.md)
  • If this PR is a new feature, please reference an issue where a consensus about the design was reached (not necessary for small changes)
  • Make sure all of the significant new logic is covered by tests

@changeset-bot
Copy link

changeset-bot bot commented Mar 23, 2023

🦋 Changeset detected

Latest commit: 2ef46d3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@apollo/client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jerelmiller jerelmiller changed the title Update useSuspenseQuery to work with useDeferredValue and startTransition Fix the compatibility between useSuspenseQuery and React's useDeferredValue and startTransition APIs Mar 23, 2023
@@ -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) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This refactor allowed me to get rid of this newly introduced option that was needed for my first implementation of useSuspenseQuery 🎉 . Always great to preserve the original functionality in core!

// 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(
Copy link
Member Author

Choose a reason for hiding this comment

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

Similarly, I no longer need to control the lifecycle of fetching and do not need to get the underlying concast object 🎉

@@ -2965,16 +3276,11 @@ describe('useSuspenseQuery', () => {
});
});

expect(renders.count).toBe(5);
expect(renders.count).toBe(4);
Copy link
Member Author

Choose a reason for hiding this comment

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

This refactor means 1 less render cycle since the hook now suspends immediately when new variables are passed in rather than setting to variables and waiting for the next render to suspend 🎉

expect(renders.suspenseCount).toBe(1);
expect(renders.frames).toMatchObject([
{
...mocks[0].result,
networkStatus: NetworkStatus.ready,
error: undefined,
},
{
...mocks[0].result,
networkStatus: NetworkStatus.refetch,
Copy link
Member Author

Choose a reason for hiding this comment

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

When suspensePolicy is set to initial, we now get a proper networkStatus which will help users detect when a fetch might be happening in the background. This is useful if you want to provide some kind of loading indication without completely replacing the UI with a loading spinner.

return promise;
},
[observable]
(options) => subscription.fetchMore(options) as any,
Copy link
Member Author

Choose a reason for hiding this comment

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

I could use some help typing this correctly. I'm getting the dreaded error:

 Type 'TData' is not assignable to type 'TFetchData'.
   'TFetchData' could be instantiated with an arbitrary type which could be unrelated to 'TData'.

Copy link
Member

Choose a reason for hiding this comment

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

I believe the commit I pushed to this branch (aa8c9c1) should improve these types.

Copy link
Member

Choose a reason for hiding this comment

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

Never mind, that caused other type errors, so I un-pushed that commit.

@jerelmiller jerelmiller force-pushed the use-deferred-value-diff-approach branch from 8ea6baa to 211a8ac Compare March 23, 2023 06:07
@jerelmiller jerelmiller linked an issue Mar 23, 2023 that may be closed by this pull request
onDispose?: () => void;
}

export class QuerySubscription<TData = any> {
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not in love with the "Subscription" name here, so if someone else has a better suggestion, I'm all ears!

This class is responsible for subscribing to the ObservableQuery and caching its result/promise for use in suspense. Outsiders can "listen in" on those changes by calling its "listen" function.

@@ -3820,7 +4261,16 @@ describe('useSuspenseQuery', () => {
]);
});

it('applies nextFetchPolicy after initial suspense', async () => {
// Jerel: I cannot figure out how to properly show the functionality of
Copy link
Member Author

Choose a reason for hiding this comment

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

I would also appreciate some feedback/ideas on this particular comment

Copy link
Member

Choose a reason for hiding this comment

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

Try triggering the reobservation with/without nextFetchPolicy by writing to the cache in a way that results in an incomplete cache broadcast for the query in question (perhaps by calling cache.evict)?

Thanks to #9504, an ordinary cache write that produces complete results will no longer trigger a full reobservation for network-only and cache-and-network queries (even in the absence of nextFetchPolicy: "cache-first"), so it's gotten a little more difficult to see the value of nextFetchPolicy, since some of the sharp edges have been removed from the network-only and cache-and-network fetch policies.

Copy link
Member

Choose a reason for hiding this comment

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

That said, I'm not opposed to removing nextFetchPolicy from the useSuspenseQuery options, though it might still creep in via clientOptions.defaultOptions.watchQuery.nextFetchPolicy.

// Related to https://github.com/apollographql/apollo-client/issues/10478
const result = resultRef.current!;

if (
Copy link
Member Author

Choose a reason for hiding this comment

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

This refactor also helped me get rid of some of the weird hacks/edge cases that plagued my initial implementation 🎉

@jerelmiller jerelmiller force-pushed the use-deferred-value-diff-approach branch from 4f92b47 to 29581f3 Compare March 23, 2023 22:25
@jerelmiller jerelmiller marked this pull request as ready for review March 23, 2023 22:38
@jerelmiller
Copy link
Member Author

/release:pr

@github-actions
Copy link
Contributor

A new release has been made for this PR. You can install it with npm i @apollo/client@0.0.0-pr-10672-20230323230524.

@jerelmiller
Copy link
Member Author

Confirmed with the original reproduction that this now works as expected.

Copy link
Member

@benjamn benjamn left a comment

Choose a reason for hiding this comment

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

This is a huge simplification @jerelmiller, and everything looks good to me, especially given all the tests you've added. Some comments inline.

Comment on lines -22 to +25
variables: {
variables: compact({
...(defaults && defaults.variables),
...options.variables,
},
}),
Copy link
Member

Choose a reason for hiding this comment

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

Small worry: this change might be removing the ability of options.variables to unset variables by explicitly passing undefined variable keys.

Copy link
Member Author

Choose a reason for hiding this comment

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

Funny enough, I added this to fix that problem 😄. I have a test that explicitly checks to see if I can unset a globally defined var from my query by passing undefined. Without this change, I found that the key was not properly unset like I expected it to. The value was properly set as undefined, but the key still existed.


const concast = observable['concast'];

this.promise = hasDirectives(['defer'], observable.query)
Copy link
Member

Choose a reason for hiding this comment

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

This check probably needs to be more general (not just @defer, maybe a helper function), and should be cached because traversing the entire query is expensive.

Copy link
Member Author

Choose a reason for hiding this comment

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

Totally agree on the caching aspect since query might be the same between different instances of a QuerySubscription. I'll refactor this into a separate method outside the class that supports caching this computation.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, and just to check, when you say "more general", what types of other cases are you thinking about?

The reason I need to do this check is because I want to resolve on the first chunk of data for deferred queries, which means creating my own promise here, rather than waiting for concast.promise to resolve, since that waits for all chunks before resolving. Are there other cases that you can think of where I'd want that same type of logic? None come to my mind at the moment.

Copy link
Member

Choose a reason for hiding this comment

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

Once we support @stream, those queries should probably have similar behavior to @defer (especially since you can mix @defer and @stream in the same query).

Copy link
Member Author

Choose a reason for hiding this comment

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

Gotcha! I figured we'd tweak this when we were ready for @stream, but I have no problem getting it ready now.

Copy link
Member

Choose a reason for hiding this comment

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

Oh I don't think we should indicate any support for @stream yet, but it would be nice to centralize this logic somewhere (maybe here, maybe a helper function) so we can easily update it whenever we add support for @stream or other features that deliver incremental chunks (and so benefit from the early-resolving promise).

Copy link
Member Author

Choose a reason for hiding this comment

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

Wrapped this helper with optimism's wrap to cache the result, and moved the function out to its own helper function (2986713). Once we need this logic elsewhere, it should be trivial to pull over to a different file.

Comment on lines 114 to 117
private handleNext() {
const result = this.observable.getCurrentResult();

if (!equal(this.result, result)) {
Copy link
Member

Choose a reason for hiding this comment

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

What's the status of potentially not calling getCurrentResult here?

These deep equality checks have a tendency pile up (redundant/costly). In this case we're ignoring the result passed to the next handler, which was already deep-equality-checked by ObservableQuery#isDifferentFromLastResult. Do we really need this one too?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll try it with the value passed to the next handler and see what tests break. I know useQuery currently has problems with that value and I expected to see the same, though I didn't try it again with this refactor (not sure why, now that I think about it). Will report back with results.

Copy link
Member Author

Choose a reason for hiding this comment

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

After trying this where I'm using the value passed to handleNext, this actually seemed to catch a few issues with incorrect networkStatus values set in the hook, so in general I think this is a mostly positive change. The one difference I see between the two now is the case where refetching with an error after a successful initial fetch won't keep the previous data value, but rather unset it completely (using errorPolicy: 'all' in this case).

I'll try and gauge what it would take to fix this in core (I assume we'd count this as a bug). For now I might try and fix in the QuerySubscription itself and come back to it in a separate PR so we can call out that change appropriately without being buried in this refactor.

Copy link
Member Author

Choose a reason for hiding this comment

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

I updated this in f5e3ba7 which actually helped me find some incorrect networkStatus values returned from the hook when an error was encountered. This also let me remove the deep equality check in f5e3ba7.

@@ -3820,7 +4261,16 @@ describe('useSuspenseQuery', () => {
]);
});

it('applies nextFetchPolicy after initial suspense', async () => {
// Jerel: I cannot figure out how to properly show the functionality of
Copy link
Member

Choose a reason for hiding this comment

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

Try triggering the reobservation with/without nextFetchPolicy by writing to the cache in a way that results in an incomplete cache broadcast for the query in question (perhaps by calling cache.evict)?

Thanks to #9504, an ordinary cache write that produces complete results will no longer trigger a full reobservation for network-only and cache-and-network queries (even in the absence of nextFetchPolicy: "cache-first"), so it's gotten a little more difficult to see the value of nextFetchPolicy, since some of the sharp edges have been removed from the network-only and cache-and-network fetch policies.

@@ -3820,7 +4261,16 @@ describe('useSuspenseQuery', () => {
]);
});

it('applies nextFetchPolicy after initial suspense', async () => {
// Jerel: I cannot figure out how to properly show the functionality of
Copy link
Member

Choose a reason for hiding this comment

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

That said, I'm not opposed to removing nextFetchPolicy from the useSuspenseQuery options, though it might still creep in via clientOptions.defaultOptions.watchQuery.nextFetchPolicy.

const gzipBundleByteLengthLimit = bytes("34.1KB");
const gzipBundleByteLengthLimit = bytes("34.01KB");
Copy link
Member

Choose a reason for hiding this comment

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

Love to see a bundlesize reduction!

jerelmiller and others added 23 commits March 28, 2023 10:59
…s returned with error policy as all or ignore
…nt does not subscribe to updates in a given time
@jerelmiller jerelmiller force-pushed the use-deferred-value-diff-approach branch from 1650756 to 2ef46d3 Compare March 28, 2023 16:59
@jerelmiller jerelmiller merged commit 932252b into release-3.8 Mar 28, 2023
4 checks passed
@jerelmiller jerelmiller deleted the use-deferred-value-diff-approach branch March 28, 2023 19:25
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Apr 28, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

useDeferredValue doesn't work with useSuspenseQuery
3 participants