Skip to content

change tracking behaviour for QueryInfo #12647

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 32 commits into from
Jun 6, 2025
Merged

Conversation

phryneas
Copy link
Member

No description provided.

Copy link

changeset-bot bot commented May 27, 2025

🦋 Changeset detected

Latest commit: 6d5c6df

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 27, 2025

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

commit: 6d5c6df

Copy link
Contributor

github-actions bot commented May 27, 2025

size-limit report 📦

Path Size
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" (CJS) 42.04 KB (-0.69% 🔽)
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" (production) (CJS) 37.28 KB (-0.66% 🔽)
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" 32.35 KB (-0.92% 🔽)
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client" (production) 26.92 KB (-1.04% 🔽)
import { ApolloProvider } from "@apollo/client/react" 5.6 KB (0%)
import { ApolloProvider } from "@apollo/client/react" (production) 963 B (0%)
import { useQuery } from "@apollo/client/react" 6.84 KB (-41.46% 🔽)
import { useQuery } from "@apollo/client/react" (production) 2.19 KB (-69.12% 🔽)
import { useLazyQuery } from "@apollo/client/react" 6.77 KB (0%)
import { useLazyQuery } from "@apollo/client/react" (production) 2.13 KB (0%)
import { useMutation } from "@apollo/client/react" 6.13 KB (0%)
import { useMutation } from "@apollo/client/react" (production) 1.48 KB (0%)
import { useSubscription } from "@apollo/client/react" 6.47 KB (0%)
import { useSubscription } from "@apollo/client/react" (production) 1.8 KB (0%)
import { useSuspenseQuery } from "@apollo/client/react" 8.22 KB (-0.11% 🔽)
import { useSuspenseQuery } from "@apollo/client/react" (production) 3.59 KB (0%)
import { useBackgroundQuery } from "@apollo/client/react" 8 KB (+0.03% 🔺)
import { useBackgroundQuery } from "@apollo/client/react" (production) 3.37 KB (+0.03% 🔺)
import { useLoadableQuery } from "@apollo/client/react" 7.98 KB (-0.19% 🔽)
import { useLoadableQuery } from "@apollo/client/react" (production) 3.36 KB (0%)
import { useReadQuery } from "@apollo/client/react" 6.25 KB (0%)
import { useReadQuery } from "@apollo/client/react" (production) 1.62 KB (0%)
import { useFragment } from "@apollo/client/react" 6.31 KB (0%)
import { useFragment } from "@apollo/client/react" (production) 1.68 KB (0%)

@phryneas phryneas force-pushed the pr/adjust-query-tracking branch from ea5945f to 6960e06 Compare June 3, 2025 12:00
@phryneas phryneas changed the base branch from pr/observablequery-cancel-on-unsubscribe to release-4.0 June 3, 2025 12:51
@@ -4170,47 +4170,6 @@ describe("ApolloClient", () => {
expect(timesFired).toBe(2);
});

it("should not error on a stopped query()", 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.

After reading #4409 it seems to me that this tests to prevent something that could be achieved in a very old iteration of the Query component or by calling QueryManager functions like stopQuery from userland code - but stopQuery since has been removed and we consider QueryManager an internal API.

=> I think it's fine to remove this test.

@phryneas phryneas linked an issue Jun 4, 2025 that may be closed by this pull request
@phryneas phryneas requested a review from jerelmiller June 5, 2025 15:21
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.

Had a few suggestions but this is looking great so far! Love the purpose of QueryInfo in the new model and getting rid of the circular dependency!

"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (CJS)": 43087,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production) (CJS)": 38133,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\"": 33081,
"import { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\" (production)": 27529
Copy link
Member

Choose a reason for hiding this comment

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

All this good stuff AND a reduction in bundle size 😱. Amazing stuff!

Comment on lines 1503 to 1504
observer.add(observable.subscribe(observer));
observer.add(fetchCancelSubject.subscribe(observer));
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
observer.add(observable.subscribe(observer));
observer.add(fetchCancelSubject.subscribe(observer));
observable.subscribe(observer);
fetchCancelSubject.subscribe(observer);

I don't think you actually need the .add here because if you use the observer passed directly to subscribe, it should also handle the teardown. I found this out when implementing deduplicated subscriptions and why I wrote that withRestart function as:

source.subscribe({
  next: observer.next.bind(observer),
  complete: observer.complete.bind(observer),
  error: observer.error.bind(observer),
});

If I just passed source.subscribe(observer) I found it was running the cleanup function on my wrapper observable when complete or error was called.

Edit: I just tried this locally and unless we are missing a test somewhere, this seems to work 🙂

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 is even wilder than I thought:
observable.subscribe(observer) internally adds teardown logic and then returns observable.

So observer.add(observable.subscribe(observer)) is actually equivalent to observable.subscribe(observer); observer.add(observer);.

Good catch!

Copy link
Member Author

Choose a reason for hiding this comment

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

* @internal
* Tears down the `ObservableQuery` and stops all active operations by sending a `complete` notification.
*/
public stop() {
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 stop() {
public dispose() {

What are your thoughts on calling this dispose? I think dispose sounds a little more like "I'm done with it".


FWIW I'd also welcome a change on ApolloClient to .dispose instead of stop since the description alludes to this:

/**
 * Call this method to terminate any active client processes, making it safe
 * to dispose of this `ApolloClient` instance.
 */

I'm not opposed to leaving this as stop, but wanted to throw this out there.

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 think I prefer stop - dispose at this point for me hints to Symbol.dispose-like behaviour, and I don't think we would ever want to add a Symbol.dispose property with this functionality to either ApolloClient or ObservableQuery.

It's also less breaking to keep in on AC, and as a result more consistent here.

That said, I had accidentally marked this as @internal when I actually see this being useful in userland - removing the @internal tag.

Copy link
Member

Choose a reason for hiding this comment

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

Sounds good. Let's keep it!

*/
public stop() {
this.subject.complete();
this.initializeObservablesQueue();
Copy link
Member

Choose a reason for hiding this comment

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

This would mean that you could stop an ObservableQuery and then subscribe to it later and continue to receive results correct?

I go back and forth on whether I like this because the complete notification is sent which is generally a signal that the Observable is done emitting values. I know this class is not an Observable itself, but it is observable-like and I think we probably should treat it similar, meaning you'd need to call client.watchQuery again to reinitialize the query. That or we shouldn't call complete so we don't signal that the observable is done emitting values.

Either way, we should probably add a test somewhere to demonstrate what you can/can't do after you call stop on an ObservableQuery then call subscribe again so that we have that documented.

Copy link
Member Author

@phryneas phryneas Jun 6, 2025

Choose a reason for hiding this comment

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

The problem here is that ObservableQuery blurs the lines between an Observable and (Behavior)Subject a bit - an Observable can be subscribed again after it finishes, since each subscribe event creates and individually running subscription:

test("Observable re-subscribe behaviour after complete", async () => {
  const observable = new Observable((observer) => {
    observer.next("first");
    setTimeout(() => {
      observer.next("second");
      observer.complete();
    }, 20);
  });

  const stream = new ObservableStream(observable);
  await expect(stream).toEmitTypedValue("first");
  await expect(stream).toEmitTypedValue("second");
  await expect(stream).toComplete();

  const stream2 = new ObservableStream(observable);
  await expect(stream2).toEmitTypedValue("first");
  await expect(stream2).toEmitTypedValue("second");
  await expect(stream2).toComplete();
});

A Subject on the other hand could not, since all subscribers would get the same subscription:

test("Subject is closed once it's done", async () => {
  const subject = new Subject();

  setTimeout(() => {
    subject.next("first");
  });
  setTimeout(() => {
    subject.next("second");
    subject.complete();
  }, 50);

  const stream = new ObservableStream(subject);
  await expect(stream).toEmitTypedValue("first");
  await expect(stream).toEmitTypedValue("second");
  await expect(stream).toComplete();

  const stream2 = new ObservableStream(subject);
  await expect(stream2).toComplete();
});

So we're a mix of an Observable and BehaviorSubject here - a BehaviorObservable maybe?

Since we have Observable explicitly in the name, I think I prefer this behavior. But good call, I'll add a test.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added a test in 5367fb7

Copy link
Member

Choose a reason for hiding this comment

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

Ah ya brain fart on the first point that you can subscribe multiple times. I know this but apparently was super tired. In that case, I like keeping it in! Thanks for adding that test!

@phryneas phryneas added the auto-cleanup 🤖 label Jun 6, 2025
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.

🎉

@phryneas phryneas merged commit a70fac6 into release-4.0 Jun 6, 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
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[4.0] Register ObservableQuery only after it subscribes
2 participants