-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Conversation
🦋 Changeset detectedLatest commit: 2ef46d3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
useSuspenseQuery
to work with useDeferredValue
and startTransition
useSuspenseQuery
and React's useDeferredValue
and startTransition
APIs
@@ -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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
src/react/hooks/useSuspenseQuery.ts
Outdated
return promise; | ||
}, | ||
[observable] | ||
(options) => subscription.fetchMore(options) as any, |
There was a problem hiding this comment.
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'.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
8ea6baa
to
211a8ac
Compare
onDispose?: () => void; | ||
} | ||
|
||
export class QuerySubscription<TData = any> { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
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 🎉
4f92b47
to
29581f3
Compare
/release:pr |
A new release has been made for this PR. You can install it with |
Confirmed with the original reproduction that this now works as expected. |
There was a problem hiding this 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.
variables: { | ||
variables: compact({ | ||
...(defaults && defaults.variables), | ||
...options.variables, | ||
}, | ||
}), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
src/react/cache/QuerySubscription.ts
Outdated
|
||
const concast = observable['concast']; | ||
|
||
this.promise = hasDirectives(['defer'], observable.query) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
src/react/cache/QuerySubscription.ts
Outdated
private handleNext() { | ||
const result = this.observable.getCurrentResult(); | ||
|
||
if (!equal(this.result, result)) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@@ -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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
.
config/bundlesize.ts
Outdated
const gzipBundleByteLengthLimit = bytes("34.1KB"); | ||
const gzipBundleByteLengthLimit = bytes("34.01KB"); |
There was a problem hiding this comment.
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!
aa8c9c1
to
1a65cbc
Compare
… request is finished
…getCurrentResult()
…s returned with error policy as all or ignore
…at checks for stream and caches result
…nt does not subscribe to updates in a given time
1650756
to
2ef46d3
Compare
Fixes #10534
Major refactor to
useSuspenseQuery
that allows it to work withuseDeferredValue
andstartTransition
when changing variables.There are several improvements to this re-implementation that are of note:
ObservableQuery
for each use of the hook. InsteadObservableQuery
instances are created per unique query/variables combination.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.useDeferredValue
andstartTransition
since these APIs require measuring whether the change results in the component suspending or not.networkStatus
is now set properly usingsuspensePolicy: 'initial'
when usingrefetch
,fetchMore
, or changing variables since we more reliably read the result state.NOTE: The
refetch
andfetchMore
functions returned from this hook still don't quite work right withstartTranstion
. I'm hoping to fix this in a future PR. If we can get those functions working withstartTransition
, we should be able to remove thesuspensePolicy
option from the hook sincestartTransition
gives us the UX behavior thatsuspensePolicy
was meant to replicate. I've opened #10676 to track this.Checklist: