-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Allow subscriptions to be deduplicated #12663
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
Conversation
commit: |
🦋 Changeset detectedLatest commit: 61a4c51 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 |
src/utilities/internal/onAnyEvent.ts
Outdated
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.
Yay for finalize
!
size-limit report 📦
|
a6576bd
to
77c96b3
Compare
inFlightLinkObservables.peek(printedServerQuery, varJson) === | ||
entry | ||
entry | ||
) { | ||
inFlightLinkObservables.remove(printedServerQuery, varJson); |
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.
Turns out that previously this deduplicated request was not removed from inFlightObservables
when a request was unsubscribe
d before a value was emitted. Now that we are using finalize
instead of onAnyEvent
, this ensures the request is removed from inFlightObservables
when unsubscribed.
}, | ||
}; | ||
|
||
let observable: Observable<SubscribeResult<MaybeMasked<TData>>> | null = null; | ||
return Object.assign( | ||
new Observable<SubscribeResult<MaybeMasked<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(); | ||
}), |
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.
Wouldn't StrictMode
still be a concern if someone disabled deduplication?
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 seems I get the same result (executing a request through the link chain twice) whether I use the old code or new code since React 18 will run the useSyncExternalStore
callback twice in React 18 which will observable.subscribe
twice. About the only thing this does is prevent creating multiple observables returned from client.subscribe
itself rather than the two wrapper observables since the useState
callback is called twice in strict mode as well.
The connection to the link chain doesn't start until you call subscribe
on the returned observable (perhaps it eagerly started the subscription in the past?)
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.
Yeah, I think it did that eagerly on creation - isn't that the case anymore?
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.
Nope! I was thinking I should add an explicit test for this, but it is covered by this test https://github.com/apollographql/apollo-client/pull/12663/files/4d0ed15213cff36837bb4b5c58d529f3476bcfae#diff-61c0c1641f292ca56586caa27870be6570b63d3fed8d2f9eb1e3ff36d36f4c35R937-R1013, specifically this line:
// Ensure we aren't eagerly subscribing
expect(onSubscribe).not.toHaveBeenCalled();
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.
Added an explicit test in fe947b8 to make sure the request is lazily created.
src/core/QueryManager.ts
Outdated
entry.observable = execute( | ||
link, | ||
operation, | ||
executeContext | ||
) as Observable<FetchResult<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.
Wouldn't this need extra logic so non-duplicated subscriptions could be restarted, 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.
Oops thanks! Updated in 5931152
@@ -331,8 +329,12 @@ export function useSubscription< | |||
!optionsRef.current.skip, | |||
"A subscription that is skipped cannot be restarted." | |||
); | |||
setObservable(recreateRef.current()); | |||
}, [optionsRef, recreateRef]); | |||
if (observable?.__.completed) { |
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.
Wouldn't we also have to recreate if the subscription is still running, but options have changed?
Also if the subscription was not deduplicated (if that isn't handled elsewhere)?
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.
Isn't that covered by this test?
apollo-client/src/react/hooks/__tests__/useSubscription.test.tsx
Lines 2346 to 2431 in 5931152
it("will use the most recently passed in options", async () => { | |
using _disabledAct = disableActEnvironment(); | |
const { | |
link, | |
takeSnapshot, | |
getCurrentSnapshot, | |
onSubscribe, | |
onUnsubscribe, | |
rerender, | |
} = await setup({ | |
variables: { id: "1" }, | |
}); | |
{ | |
const snapshot = await takeSnapshot(); | |
expect(snapshot).toStrictEqualTyped({ | |
loading: true, | |
data: undefined, | |
error: undefined, | |
}); | |
} | |
link.simulateResult({ result: { data: { totalLikes: 1 } } }); | |
{ | |
const snapshot = await takeSnapshot(); | |
expect(snapshot).toStrictEqualTyped({ | |
loading: false, | |
data: { totalLikes: 1 }, | |
error: undefined, | |
}); | |
} | |
await expect(takeSnapshot).not.toRerender({ timeout: 20 }); | |
expect(onUnsubscribe).toHaveBeenCalledTimes(0); | |
expect(onSubscribe).toHaveBeenCalledTimes(1); | |
void rerender({ variables: { id: "2" } }); | |
await waitFor(() => expect(onUnsubscribe).toHaveBeenCalledTimes(1)); | |
expect(onSubscribe).toHaveBeenCalledTimes(2); | |
expect(link.operation?.variables).toStrictEqual({ id: "2" }); | |
{ | |
const snapshot = await takeSnapshot(); | |
expect(snapshot).toStrictEqualTyped({ | |
loading: true, | |
data: undefined, | |
error: undefined, | |
}); | |
} | |
link.simulateResult({ result: { data: { totalLikes: 1000 } } }); | |
{ | |
const snapshot = await takeSnapshot(); | |
expect(snapshot).toStrictEqualTyped({ | |
loading: false, | |
data: { totalLikes: 1000 }, | |
error: undefined, | |
}); | |
} | |
expect(onUnsubscribe).toHaveBeenCalledTimes(1); | |
expect(onSubscribe).toHaveBeenCalledTimes(2); | |
expect(link.operation?.variables).toStrictEqual({ id: "2" }); | |
getCurrentSnapshot().restart(); | |
await waitFor(() => expect(onUnsubscribe).toHaveBeenCalledTimes(2)); | |
expect(onSubscribe).toHaveBeenCalledTimes(3); | |
expect(link.operation?.variables).toStrictEqual({ id: "2" }); | |
await expect(takeSnapshot).not.toRerender(); | |
link.simulateResult({ result: { data: { totalLikes: 1005 } } }); | |
{ | |
const snapshot = await takeSnapshot(); | |
expect(snapshot).toStrictEqualTyped({ | |
loading: false, | |
data: { totalLikes: 1005 }, | |
error: undefined, | |
}); | |
} | |
}); |
The observable
gets recreated when you pass in new options by
apollo-client/src/react/hooks/useSubscription.ts
Lines 227 to 238 in 5931152
!observable || | |
((client !== observable.__.client || | |
subscription !== observable.__.query || | |
fetchPolicy !== observable.__.fetchPolicy || | |
errorPolicy !== observable.__.errorPolicy || | |
!equal(variables, observable.__.variables)) && | |
(typeof shouldResubscribe === "function" ? | |
!!shouldResubscribe(options!) | |
: shouldResubscribe) !== false) | |
) { | |
setObservable((observable = recreate())); | |
} |
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 dang you're right, in my mind for some reason useSubscription
wouldn't change options until you called restart
but of course that's not the case.
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.
Pull Request Overview
This PR adds support for deduplicating subscription operations over shared observables and exposes a restart
method on subscription observables to re-establish connections, with opt-out via queryDeduplication
.
- Introduces a
SubscriptionObservable<T>
interface (withrestart
) and updates return types in core and React hooks - Updates
QueryManager
to track deduped subscriptions, wrap link observables with restart logic, and chooseshare
vsshareReplay
based on operation type - Adjusts
useSubscription
hook and existing tests to set a completion flag and expose restart behavior
Reviewed Changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
File | Description |
---|---|
src/react/hooks/useSubscription.ts | Tracks completion and calls observable.restart() in effect |
src/core/QueryManager.ts | Adds withRestart wrapper, stores restart in inFlightLinkObservables , uses share for subscriptions |
src/core/types.ts | Defines SubscriptionObservable<T> interface |
src/core/ApolloClient.ts | Returns SubscriptionObservable and preserves restart method |
src/utilities/internal/onAnyEvent.ts | Removes deprecated utility |
src/utilities/internal/index.ts | Removes onAnyEvent export |
Tests and snapshots | Updated to assert restart presence and add restart tests |
.changeset & .api-reports | Reflect updated API surface and release notes |
Comments suppressed due to low confidence (3)
src/core/ApolloClient.ts:547
- [nitpick] Attaching
restart
only to the mapped observable means users loserestart
if they chain further RxJS operators (e.g..pipe(map(...))
). Consider providing a custom observable class or operator that preserves therestart
method across operator chains.
return Object.assign(mapped, { restart: observable.restart });
src/core/QueryManager.ts:1284
- The branch where
deduplication
is disabled (queryDeduplication: false
) does not appear to have a corresponding test. Adding a test that verifies no dedup and correct restart behavior when deduplication is off would ensure coverage for this use case.
} else {
src/react/hooks/useSubscription.ts:332
- The useEffect will call
observable.restart()
on every render when the subscription is active, potentially causing repeated reconnections. Consider introducing a user-controlled trigger or additional state to guard restarts so they only occur on explicit requests or specific dependency changes.
if (observable?.__.completed) {
function withRestart(source: Observable<FetchResult>) { | ||
return new Observable<FetchResult>((observer) => { |
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.
[nitpick] The withRestart
wrapper embeds complex subscription and restart logic inline. Extracting it into a dedicated utility or method with clear documentation would improve readability and reuse.
function withRestart(source: Observable<FetchResult>) { | |
return new Observable<FetchResult>((observer) => { | |
/** | |
* Wraps an observable with restartable subscription logic. | |
* @param source The source observable to wrap. | |
* @param entry An object containing the restart function. | |
* @returns A new observable with restartable subscription logic. | |
*/ | |
function withRestart<T>(source: Observable<T>, entry: { restart?: () => void }): Observable<T> { | |
return new Observable<T>((observer) => { |
Copilot uses AI. Check for mistakes.
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.
Appreciate the suggestion, but nah its internal
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.
Looks good to me now.
Fixes #12348
Ensures that subscriptions can be deduplicated properly. Disable deduplication with
queryDeduplication
like queries.