-
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
Fixed useQuery under StrictMode creating 2 observable query #11925
base: main
Are you sure you want to change the base?
Conversation
👷 Deploy request for apollo-client-docs pending review.Visit the deploys page to approve it
|
🦋 Changeset detectedLatest commit: e568c78 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 |
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 fix you presented here is a reasonable one and I'm good with that fix!
Where I'd like to see a change potentially is how we setup the test to reproduce this behavior. See my comments on the tests and let me know what you think!
src/core/QueryInfo.ts
Outdated
import { equal } from "@wry/equality"; | ||
import type { DocumentNode, GraphQLError } from "graphql"; |
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.
It looks like your editor might have applied some kind of import reordering. Would you mind disabling that and reverting the changes to the imports? We'd love to keep the git blame as clean as possible 🙂
@@ -1,53 +1,53 @@ | |||
// externals | |||
import { DocumentNode, GraphQLError } from "graphql"; |
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.
Same here. Would you mind reverting the change that reordered these imports?
}); | ||
// Recreate state where an observable query is dirty but has no observers in the query manager | ||
// @ts-expect-error -- Accessing private field for testing | ||
oq.queryInfo.dirty = true; |
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.
While this no doubt reproduces the issues, I'd still love to figure out how best the client gets into this state to begin with using user-facing APIs. User's aren't going to be touching QueryInfo
or the dirty
flag manually, so the more we can understand the situation that gets us here, the better. It will help us avoid a regression in the future knowing exactly the situation that got us here.
Since we are able to reproduce in CodeSandbox using useQuery
with React's strict mode enabled, we should consider moving this test over to the useQuery
tests. Maybe we start there to figure out exactly how this dirty
flag gets set and work backwards?
Once we have a failing test for useQuery
("failing" means assuming the change you've added to QueryInfo
is not applied), then we might figure out how best to backport the situation over to this test.
What do you think about that?
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.
My idea was to reproduce the bad state, regardless of what causes the bad state
I agree that it's better to do a fully functional test especially to identify the "real" cause of the issue, but I haven't had time to look into it this week because I was swamped and I'm going on vacations starting next week.
If you want to pick up the PR to wrap it up I don't mind.
Otherwise I'll try to look into it when I get a chance (it's still on my list 😅)
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.
@jerelmiller it was great to chat with you yesterday at the GraphQL Summit!
I rebased my fix and gave a shot at writing a test with useQuery.
I have not been able to reproduce the issue in the test however :(
Happy to get your input on my test how you think we can bring this to the finish line
187828a
to
98157d9
Compare
✅ Docs Preview ReadyNo new or changed pages found. |
…resetting the store Closes apollographql#11914
98157d9
to
823b26e
Compare
src/core/QueryManager.ts
Outdated
@@ -880,10 +880,7 @@ export class QueryManager<TStore> { | |||
options: { fetchPolicy }, | |||
} = oq; | |||
|
|||
if ( | |||
fetchPolicy === "standby" || | |||
(include === "active" && !oq.hasObservers()) |
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 part is related to this part of the issue my colleague found
This code is 3 years old, so I'm skeptical that my change makes sense.
From my perspective, if someone provided a list of DocumentNode or OperationName to refetch, they clearly meant to refetch only active
ones.
I can't think of a reason why you'd want to refetch queries without any observers
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.
Humm looks like I was wrong
apollo-client/src/__tests__/refetchQueries.ts
Lines 535 to 549 in 8047181
itAsync( | |
"includes queries named in refetchQueries even if they have no observers", | |
async (resolve, reject) => { | |
const client = makeClient(); | |
const aObs = client.watchQuery({ query: aQuery }); | |
const bObs = client.watchQuery({ query: bQuery }); | |
const abObs = client.watchQuery({ query: abQuery }); | |
// These ObservableQuery objects have no observers yet, but should | |
// nevertheless be refetched if identified explicitly in an options.include | |
// array passed to client.refetchQueries. | |
expect(aObs.hasObservers()).toBe(false); | |
expect(bObs.hasObservers()).toBe(false); | |
expect(abObs.hasObservers()).toBe(false); |
I still don't know why you'd want that, but there must be a good reason.
This comes back to why we end up with queries without observers with
useQuery
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.
Huh, I didn't realize that was a thing. Let me ask around and see if I can figure out why we'd ever want this. I agree that fetching a query with no observers is an odd behavior 🤔
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 noodling on the rest of the implementation, but I'm curious... with those other changes, do we need to modify this? If an ObservableQuery
is created but not registered, it shouldn't be included in this.queries
correct?
I'm wondering if we should leave this alone for now. To be honest, I'm not sure how much of a breaking change this is and while I think it makes sense to you and I that this should be the correct behavior, we've had fiery discussions on far smaller changes 😂. We will be starting work on v4 here very soon, so I think we could probably punt this change to there where we can announce this change more formally.
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.
Fair, I don't mind living with this part as a patch on our side and maybe we can surface up reasons to not do it XD
If we manage to completely solve the StrictMode issue so that QueryManager never has "dead" queries then this change is not as important anymore either
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.
Yep exactly!
@jerelmiller Let me know how you feel about the direction I've taken in my PR to fix the StrictMode issue before I dig in more into the current test failures |
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.
Took me a bit to come around to this solution, but I kind of dig it. I'd like to discuss this with the team in our next team meeting as well in case someone else has a suggestion that I'm not thinking of, but otherwise I think this is a positive change.
For the changes there now, I do have a few suggestions that I think could simplify a touch more without the need to pass around the queryInfo
or register
function. Let me know what you think!
/** | ||
* Create a query, but do not associate it with the QueryManager. | ||
* This allows to throw away the query if it ends up not being needed | ||
*/ |
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 fine if you want to keep this comment, but just an FYI we consider QueryManager
a private class so it will only ever be used internally. I'm not opposed to more documentation, but this won't be seen by consumers of this library 🙂
public registerObservableQuery( | ||
observable: ObservableQuery<any, any>, | ||
queryInfo: QueryInfo | ||
): void { | ||
this.queries.set(observable.queryId, queryInfo); | ||
} |
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.
public registerObservableQuery( | |
observable: ObservableQuery<any, any>, | |
queryInfo: QueryInfo | |
): void { | |
this.queries.set(observable.queryId, queryInfo); | |
} | |
public registerObservableQuery( | |
observable: ObservableQuery<any, any> | |
): void { | |
this.queries.set(observable.queryId, observable['queryInfo']); | |
} |
When we instantiate ObservableQuery
, it gets queryInfo
in its constructor and sets it as a private property. I'd say we can shorten this to just take the observable
and access that queryInfo
from there using the bracket notation which lets us get access to private properties.
@@ -756,6 +760,25 @@ export class QueryManager<TStore> { | |||
variables: observable.variables, | |||
}); | |||
|
|||
return { observable, queryInfo }; |
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.
With the change suggested for registerObservableQuery
, we shouldn't need to return queryInfo
here since its set as a property on observable
. I'd shorten this to just return the observable
directly which should simplify some things.
return { observable, queryInfo }; | |
return observable; |
!this.listeners.size || | ||
// It's possible that the query is no longer being watched, but the | ||
// ObservableQuery is still active/pending cleanup. In this case, we should not notify. | ||
!this.observableQuery?.hasObservers() |
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 think we probably want to call this change out in the changelog in case we have any users that use the core watchQuery
API directly. I'd say something along the lines of:
Cache writes to
ObservableQuery
with no observers will no longer receive cache updates or trigger refetches as a result of cache changes. This was usually caused when usinguseQuery
with React's strict mode which could cause the creation of multipleObservableQuery
instances on mount. This change should only affect you if you createObservableQuery
instances viawatchQuery
and do not subscribe to them but expect cache updates and refetches to occur.
Obviously feel free to workshop this and make it your own, really as long as we have something here is what I care about.
Feel free to add this as a separate changeset since I'd view it as a separate change from the strict mode issue.
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 talking with the team, I think we might want to move this change to 4.0 and make it a breaking change so that we can call it out prominently. Its difficult to tell who might rely on this behavior if you're using the core API directly. I think the fix for strict mode might be enough here to address what you're seeing specifically in your app. Moving this to a major allows us to ensure we add this behavior everywhere as well.
As an FYI, we will begin work on 4.0 once 3.12 ships (a few weeks from now), so shouldn't be long before we can address this 🙂
return observable; | ||
} | ||
|
||
public createQuery< |
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'd make this a private
method. We treat public methods as part of the public API which means we can't make breaking changes to this after this is released and I'd prefer we keep this out of users hands if we can. By making it private, that should allow us to tweak it/rename it/etc. without that fear.
Since you use this from useQuery
, TypeScript will obviously complain when you make this change, so to get around that, use bracket notation:
// useQuery.ts
client['createQuery'](options);
return { | ||
observable, | ||
register: () => { | ||
this.queryManager.registerObservableQuery(observable, queryInfo); | ||
}, | ||
}; |
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.
With the suggested change to registeerObservableQuery
, I'd actually suggest that we don't need to return this register
function here and can just return the observable
. We can then use queryManager.reigsterObservableQuery
directly in place of this instead.
return { | |
observable, | |
register: () => { | |
this.queryManager.registerObservableQuery(observable, queryInfo); | |
}, | |
}; | |
return observable; |
}); | ||
// Recreate state where an observable query is dirty but has no observers in the query manager | ||
// @ts-expect-error -- Accessing private field for testing | ||
oq.queryInfo.dirty = true; |
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 curious, now that we've sorta pinned this down on both strict mode and the creation of multiple ObservableQuery
instances without observers, do we have a need for this condition anymore?
I asked the team about this and my colleague pointed out something that we do in apollo-client/src/react/hooks/useSubscription.ts Lines 354 to 363 in 6496b25
Here we wrap the subscription with its own Thoughts? |
So I was digging into this, I haven't able to easily replicate that kind of behavior for useQuery. So instead I considered another angle, since we want to register the query in the QueryManager when the ObservableQuery subscribes, why not let it take care of subscribing. So now I'm looking at a solution to have this behavior only in useQuery. Boy oh boy, I thought the solution worked because I only tested on React19, but it seems on React18 and 17 the behavior of Now I really feel like we might need to go down the path of only creating the |
Gah. I had another conference to attend then was on vacation. Apologies for my slow time to respond! I will take a look at your changes and respond to your latest comment next week. Thanks so much for your patience! |
Haha no worries, glad it's still on your radar. The current state of my PR is not really great hahaha. |
Closes #11914