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

Fixed useQuery under StrictMode creating 2 observable query #11925

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

Cellule
Copy link

@Cellule Cellule commented Jul 4, 2024

Closes #11914

Copy link

netlify bot commented Jul 4, 2024

👷 Deploy request for apollo-client-docs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit e568c78

Copy link

changeset-bot bot commented Jul 4, 2024

🦋 Changeset detected

Latest commit: e568c78

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 self-requested a review July 5, 2024 21:38
Copy link
Member

@jerelmiller jerelmiller left a 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!

import { equal } from "@wry/equality";
import type { DocumentNode, GraphQLError } from "graphql";
Copy link
Member

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";
Copy link
Member

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;
Copy link
Member

@jerelmiller jerelmiller Jul 8, 2024

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?

Copy link
Author

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 😅)

Copy link
Author

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

@svc-apollo-docs
Copy link

svc-apollo-docs commented Oct 9, 2024

✅ Docs Preview Ready

No new or changed pages found.

@@ -880,10 +880,7 @@ export class QueryManager<TStore> {
options: { fetchPolicy },
} = oq;

if (
fetchPolicy === "standby" ||
(include === "active" && !oq.hasObservers())
Copy link
Author

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

#11914 (comment)

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

Copy link
Author

@Cellule Cellule Oct 14, 2024

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

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

Copy link
Member

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 🤔

Copy link
Member

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.

Copy link
Author

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

Copy link
Member

Choose a reason for hiding this comment

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

Yep exactly!

@Cellule Cellule changed the title Fixed issue causing inactive queries to be executed when clearing or resetting the store Fixed useQuery under StrictMode creating 2 observable query Oct 17, 2024
@Cellule
Copy link
Author

Cellule commented Oct 17, 2024

@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

Copy link
Member

@jerelmiller jerelmiller left a 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!

Comment on lines +723 to +726
/**
* 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
*/
Copy link
Member

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 🙂

Comment on lines +769 to +774
public registerObservableQuery(
observable: ObservableQuery<any, any>,
queryInfo: QueryInfo
): void {
this.queries.set(observable.queryId, queryInfo);
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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 };
Copy link
Member

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.

Suggested change
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()
Copy link
Member

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 using useQuery with React's strict mode which could cause the creation of multiple ObservableQuery instances on mount. This change should only affect you if you create ObservableQuery instances via watchQuery 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.

Copy link
Member

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<
Copy link
Member

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);

Comment on lines +446 to +451
return {
observable,
register: () => {
this.queryManager.registerObservableQuery(observable, queryInfo);
},
};
Copy link
Member

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.

Suggested change
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;
Copy link
Member

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?

@jerelmiller
Copy link
Member

I asked the team about this and my colleague pointed out something that we do in useSubscription that we may or may not be able to do here. Check out this bit of code:

return Object.assign(
new Observable<FetchResult<TData>>((observer) => {
// lazily start the subscription when the first observer subscribes
// to get around strict mode
if (!observable) {
observable = client.subscribe(options);
}
const sub = observable.subscribe(observer);
return () => sub.unsubscribe();
}),

Here we wrap the subscription with its own Observable so that we only create the ObservableQuery when its subscribed. Not sure if you want to perhaps noodle on this? This might bring its own challenges with useQuery, but if you're able to get something working like this then we can isolate the changes to useQuery itself without having to touch the core API.

Thoughts?

@Cellule
Copy link
Author

Cellule commented Oct 23, 2024

I asked the team about this and my colleague pointed out something that we do in useSubscription that we may or may not be able to do here. Check out this bit of code:

return Object.assign(
new Observable<FetchResult<TData>>((observer) => {
// lazily start the subscription when the first observer subscribes
// to get around strict mode
if (!observable) {
observable = client.subscribe(options);
}
const sub = observable.subscribe(observer);
return () => sub.unsubscribe();
}),

Here we wrap the subscription with its own Observable so that we only create the ObservableQuery when its subscribed. Not sure if you want to perhaps noodle on this? This might bring its own challenges with useQuery, but if you're able to get something working like this then we can isolate the changes to useQuery itself without having to touch the core API.

Thoughts?

So I was digging into this, I haven't able to easily replicate that kind of behavior for useQuery.
My biggest issue was that too much code relies on the ObservableQuery to be readily available and returned to the user through obsQueryFields
It would likely be possible to make it work, but as I started hacking at it, it got messy fast.

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.
The ObservableQuery is also in charge of removing itself through tearDownQuery so I thought it made sense.
I removed this.queries.set(observable.queryId, queryInfo); from QueryManager.watchQuery and added this.queryManager["queries"].set(this.queryId, this.queryInfo); in the subscribe function of ObservableQuery.
It worked in my scenario, but I noticed that a few other tests failed, namely in createQueryPreloader and some others. The tests looked fine, so I was suspicious.
I'm not sure why you would want to register a query that you won't subscribe to, but anyway.

So now I'm looking at a solution to have this behavior only in useQuery.
I passed a hidden option through WatchQueryOption that would only set the query in QueryManager.queries in the ObservableQuery subscribe function.
It seems to work, but look a bit hackish (maybe less than my current solution?)

Boy oh boy, I thought the solution worked because I only tested on React19, but it seems on React18 and 17 the behavior of useSyncExternalStore causes to call the getSnapshot function breaking React rules which registers the query in the ApolloCache
When another query gets result, it would broadcast to all cache watches causing the query to reobserve.
I initially hid/mitigated this part with the shouldNotify "fix"

Now I really feel like we might need to go down the path of only creating the ObservableQuery when it actually subscribes 😮‍💨

@jerelmiller
Copy link
Member

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!

@Cellule
Copy link
Author

Cellule commented Nov 9, 2024

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.
I feel I might have to pull my sleeve and do the dirty work. Let me know what you think from my other comments then I can try to take another go at it 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

resetStore and refetchQueries can cause to refetch unmounted/inactive queries in StrictMode
3 participants