Skip to content

Cancel running ObservableQuery link on unsubscribe #12633

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

Merged
merged 27 commits into from
Jun 3, 2025

Conversation

phryneas
Copy link
Member

@phryneas phryneas commented May 20, 2025

Fixes #12554

Copy link

changeset-bot bot commented May 20, 2025

🦋 Changeset detected

Latest commit: 5b828d8

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

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

Copy link

pkg-pr-new bot commented May 20, 2025

npm i https://pkg.pr.new/apollographql/apollo-client/@apollo/client@12633

commit: 5b828d8

Copy link
Contributor

github-actions bot commented May 20, 2025

size-limit report 📦

Path Size
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" (CJS) 42.23 KB (+0.48% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" (production) (CJS) 37.33 KB (+0.32% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" 32.49 KB (+0.67% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" (production) 26.98 KB (+0.67% 🔺)
import { ApolloProvider } from "@apollo/client/react" 5.56 KB (0%)
import { ApolloProvider } from "@apollo/client/react" (production) 963 B (0%)
import { useQuery } from "@apollo/client/react" 11.59 KB (+1.49% 🔺)
import { useQuery } from "@apollo/client/react" (production) 7 KB (+2.41% 🔺)
import { useLazyQuery } from "@apollo/client/react" 6.72 KB (0%)
import { useLazyQuery } from "@apollo/client/react" (production) 2.12 KB (0%)
import { useMutation } from "@apollo/client/react" 6.1 KB (0%)
import { useMutation } from "@apollo/client/react" (production) 1.47 KB (0%)
import { useSubscription } from "@apollo/client/react" 6.46 KB (0%)
import { useSubscription } from "@apollo/client/react" (production) 1.83 KB (0%)
import { useSuspenseQuery } from "@apollo/client/react" 8.17 KB (0%)
import { useSuspenseQuery } from "@apollo/client/react" (production) 3.57 KB (0%)
import { useBackgroundQuery } from "@apollo/client/react" 7.99 KB (0%)
import { useBackgroundQuery } from "@apollo/client/react" (production) 3.4 KB (0%)
import { useLoadableQuery } from "@apollo/client/react" 7.97 KB (0%)
import { useLoadableQuery } from "@apollo/client/react" (production) 3.38 KB (0%)
import { useReadQuery } from "@apollo/client/react" 6.22 KB (0%)
import { useReadQuery } from "@apollo/client/react" (production) 1.61 KB (0%)
import { useFragment } from "@apollo/client/react" 6.29 KB (0%)
import { useFragment } from "@apollo/client/react" (production) 1.68 KB (0%)

@@ -489,7 +490,8 @@ describe("ApolloClient", () => {
expect(onRequestUnsubscribe).toHaveBeenCalledTimes(1);
});

it("causes link unsubscription after multiple requests finish", async () => {
// restoring original test from https://github.com/apollographql/apollo-client/blob/326cbe8260b1cc04b339ed2b968e23c08a392cc4/src/core/__tests__/ApolloClient/general.test.ts#L366-L461
it("causes link unsubscription after reobserve", async () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

restoring original test from

it("causes link unsubscription after reobserve", async () => {
const expResult = {
data: {
allPeople: {
people: [
{
name: "Luke Skywalker",
},
],
},
},
};
const request = {
query: gql`
query people($offset: Int) {
allPeople(first: $offset) {
people {
name
}
}
}
`,
variables: undefined,
};
const mockedResponse = {
request,
result: expResult,
};
const onRequestSubscribe = jest.fn();
const onRequestUnsubscribe = jest.fn();
const mockedSingleLink = new ApolloLink(() => {
return new Observable((observer) => {
onRequestSubscribe();
// Delay (100ms) must be bigger than sum of reobserve and unsubscribe awaits (5ms each)
// to show clearly that the connection was aborted before completing
const timer = setTimeout(() => {
observer.next(mockedResponse.result);
observer.complete();
}, 100);
return () => {
onRequestUnsubscribe();
clearTimeout(timer);
};
});
});
const client = new ApolloClient({
link: mockedSingleLink,
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: "cache-and-network",
returnPartialData: false,
notifyOnNetworkStatusChange: true,
},
query: {
fetchPolicy: "network-only",
},
},
queryDeduplication: false,
});
const observableQuery = client.watchQuery<
(typeof expResult)["data"],
{ offset?: number | undefined }
>({
query: request.query,
variables: request.variables,
});
const stream = new ObservableStream(observableQuery);
expect(onRequestSubscribe).toHaveBeenCalledTimes(1);
// This is the most important part of this test
// Check that reobserve cancels the previous connection while watchQuery remains active
void observableQuery.reobserve({ variables: { offset: 20 } });
await waitFor(() => {
// Verify that previous connection was aborted by reobserve
expect(onRequestUnsubscribe).toHaveBeenCalledTimes(1);
});
stream.unsubscribe();
await wait(10);
expect(onRequestSubscribe).toHaveBeenCalledTimes(2);
expect(onRequestUnsubscribe).toHaveBeenCalledTimes(2);
});

Base automatically changed from pr/rewrite-observable-query-subscription-logic to release-4.0 May 21, 2025 16:35
@phryneas phryneas force-pushed the pr/observablequery-cancel-on-unsubscribe branch from 5dcdb67 to bda126f Compare May 22, 2025 09:43
@phryneas phryneas changed the base branch from release-4.0 to pr/prevent-standby-loadings-state May 22, 2025 10:56
@@ -413,7 +414,7 @@ describe("ApolloClient", () => {
expect(stream.unsubscribe).not.toThrow();
});

it("unsubscribes from link after initial reobserve when unsubscribing immediately", async () => {
it("causes link unsubscription if unsubscribed", async () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

@phryneas phryneas force-pushed the pr/observablequery-cancel-on-unsubscribe branch from 16b2610 to 44b8378 Compare May 23, 2025 08:50
@phryneas phryneas changed the base branch from pr/prevent-standby-loadings-state to release-4.0 May 23, 2025 08:56
@@ -80,7 +81,6 @@ interface TrackedOperation {
variables: OperationVariables;
}

const newNetworkStatusSymbol: any = Symbol();
Copy link
Member Author

Choose a reason for hiding this comment

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

The changes around newNetworkStatusSymbol actually wouldn't be necessary after all, since I didn't have to add a second internal option.
On the other hand, I believe it's an improvement to type safety and readability, so I'd suggest to keep it in anyways.

@@ -1708,11 +1743,11 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`,
// this.options.fetchPolicy is "cache-and-network" or "network-only". When
// this.options.fetchPolicy is any other policy ("cache-first", "cache-only",
// "standby", or "no-cache"), we call this.reobserve() as usual.
private reobserveCacheFirst() {
private reobserveCacheFirst(): void {
Copy link
Member Author

Choose a reason for hiding this comment

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

The return value was never used, so I changed this to void

@phryneas phryneas force-pushed the pr/observablequery-cancel-on-unsubscribe branch from ad068f6 to 47d6e84 Compare May 23, 2025 09:22
@phryneas phryneas marked this pull request as ready for review May 23, 2025 09:30
@phryneas phryneas changed the title [WIP] cancel running ObservableQuery link on unsubscribe Cancel running ObservableQuery link on unsubscribe May 23, 2025
@phryneas phryneas requested a review from jerelmiller May 23, 2025 09:30
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.

This looks really good! If you're down with changing useLazyQuery to default to the non-retain version, I'll approve once those changes are made.

// ensuring the key exists in options
variables: executeOptions?.variables,
})
.retain(/* create a persistent subscription on the query */);
Copy link
Member

Choose a reason for hiding this comment

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

I still think it makes sense to remove this by default and give control over to the user. We didn't have this type of control/mechanism before and even using AbortController in 3.x was problematic since changes to context would mean calling reobserve more often than it should have (i.e. #11835).

Now that we have a really elegant solution here, I think having consistency throughout the client is useful and its a single line of code to restore this behavior in app code. Doing so also means someone won't have to resort to AbortController to achieve this behavior which is going to be a lot more code.

One more pro is that retain makes it more obvious that the promise will be kept around rather than being buried as an implementation detail.

Copy link
Member Author

Choose a reason for hiding this comment

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

@phryneas phryneas requested a review from jerelmiller May 30, 2025 12:06
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.

The code generally looks good but I think we'll need to dig a bit deeper to change the value resolves/rejected from the promise when the in-flight query is aborted, otherwise it just resolves with data: undefined and that might be surprising.

The useLazyQuery tests fail for this reason so we'll need to revisit those. I can confirm though calling retain in those tests does work as expected so thats great news!

@phryneas
Copy link
Member Author

phryneas commented Jun 2, 2025

@jerelmiller what do you think about 60180f5 (#12633) ?

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.

Looks great! Thank you!

@phryneas phryneas merged commit 9bfb51f into release-4.0 Jun 3, 2025
42 of 43 checks passed
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.

2 participants