-
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
useSubscription: fix rules of React violations #11863
Conversation
🦋 Changeset detectedLatest commit: 33829ec 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 |
size-limit report 📦
|
✅ Deploy Preview for apollo-client-docs ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
@@ -64,7 +62,7 @@ describe("mockSubscriptionLink", () => { | |||
</ApolloProvider> | |||
); | |||
|
|||
const numRenders = IS_REACT_18 ? 2 : results.length + 1; |
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.
We are now using useSyncExternalStore
, so nothing will autobatch here in React 18. Seeing how that confused people with subscriptions, that's probably a good thing.
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.
Good call out. We have a section on the hooks page about subscription autobatching: https://www.apollographql.com/docs/react/api/react/hooks/#usesubscription we should create a ticket to update that with a version range once we know which version this change will ship in (and honestly that disclaimer should be on the subscriptions page 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.
That's a very good point - what do you think about these changes? e249bad
(#11863)
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.
Also, see this idea: #11863 (comment)
fetchPolicy !== observable.__.fetchPolicy || | ||
!equal(variables, observable.__.variables)) && | ||
(typeof shouldResubscribe === "function" ? | ||
!!shouldResubscribe(options!) |
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.
shouldResubscribe
should now only execute if something is actually different - but since we're doing this in render, it will be executed every render if they are different, not only if these values changed.
setObservable( | ||
(observable = createSubscription( | ||
client, | ||
subscription, | ||
variables, | ||
fetchPolicy, | ||
context | ||
)) | ||
); |
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.
Calling setObservable
during render will prevent an awkward in-between render where you already passed in new variables/query/client and it still shows you results for the old one.
variables, | ||
}; | ||
observable.__.setResult(result); | ||
update(); |
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.
Idea: Generally we could think about a omitData
option that would remove data
from the result and skip calling this update
(=component rerendering) if only the data changes. That way, people could subscribe and trigger updates from their own onData
handlers as they please.
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.
Uh yeah, that's #10216 ^^
context: options?.context, | ||
}) | ||
); | ||
canResetObservableRef.current = false; |
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.
The ref here was a workaround around strictMode, but didn't account for any kind of concurrency and broke rules of React.
https://github.com/apollographql/apollo-client/pull/9707/files
Out of curiosity, does this fix #11165? |
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 didn't get through the whole review before I left, but figured I'd submit what I had for now. Feel free to take or leave it.
src/react/hooks/useSubscription.ts
Outdated
@@ -102,32 +112,24 @@ export function useSubscription< | |||
TVariables extends OperationVariables = OperationVariables, | |||
>( | |||
subscription: DocumentNode | TypedDocumentNode<TData, TVariables>, | |||
options?: SubscriptionHookOptions<NoInfer<TData>, NoInfer<TVariables>> | |||
options: SubscriptionHookOptions<NoInfer<TData>, NoInfer<TVariables>> = {} |
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.
options: SubscriptionHookOptions<NoInfer<TData>, NoInfer<TVariables>> = {} | |
options: SubscriptionHookOptions<NoInfer<TData>, NoInfer<TVariables>> = Object.create(null) |
I believe we've been using this version of an empty object throughout the project.
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 enough.
@@ -11,8 +11,6 @@ import { itAsync, MockSubscriptionLink } from "../../../../testing"; | |||
import { graphql } from "../../graphql"; | |||
import { ChildProps } from "../../types"; | |||
|
|||
const IS_REACT_18 = React.version.startsWith("18"); |
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.
🎉
src/react/hooks/useSubscription.ts
Outdated
let { skip, fetchPolicy, variables, shouldResubscribe, context } = options; | ||
variables = useDeepMemo(() => variables, [variables]); |
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.
let { skip, fetchPolicy, variables, shouldResubscribe, context } = options; | |
variables = useDeepMemo(() => variables, [variables]); | |
const { skip, fetchPolicy, shouldResubscribe, context } = options; | |
const variables = useDeepMemo(() => options.variables, [options.variables]); |
Perhaps a personal preference, but could we create the variables
variable like this? I find this a bit easier to remember that variables
references the memoized value easier than having to look at where its reassigned from. (and feel free to leave the first let
if you prefer... I know 'let' vs 'const' yada yada).
This is technical more bytes, so feel free to ignore this suggestion if you prefer the way it is.
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.
Perfectly fair :)
when will this be merged? can we integrate this #10216 to it as it is critical to decouple streaming data update vs react-render in a lot of use case |
@kjhuang-db we won't release this PR until 3.11 which is scheduled for early July. With a big refactor like this, we tend to want to put this in a minor release. The work to integrate the new option should likely be done in a separate PR to ensure those changes don't get buried along with the rest of this code, but it should absolutely start with the changes from this branch to avoid conflicts. |
@jerelmiller sounds good. curious since this change seems switching the state from local to externalStore, with such big refactor why it is not a major release?
will branch out from this PR and see if I can add on it: I would prefer this in OSS apollo to avoid a local "copy" in my org's codebase, thanks also a qq for @phryneas: as discussed on Discord, one concern of current
related: facebook/react#25191 (comment) (what a small world) |
@kjhuang-db answered in discord :) |
Let's address #10216 and #11165 in separate follow-up PRs. I've added both to the 3.11.0 milestone. |
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 don't see anything super major. TBH this hook still seems a little complicated, but you're not doing anything that wasn't already there so this change makes sense to me.
Had a couple minor bits of feedback, but nothing blocking that I can see. Thanks for doing this!
src/react/hooks/useSubscription.ts
Outdated
* | ||
* If your subscription API sends multiple messages at the same time or in very fast succession (within fractions of a millisecond), it is likely that only the last message received in that narrow time frame will result in a re-render. | ||
* If you want to react to incoming data, please use the `onData` option instead of `useEffect`. State updates you make inside a `useEffect` hook might cause additional rerenders, and `useEffect` is mostly meant for side effects of rendering, not as an event handler. |
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.
State updates you make inside a
useEffect
hook might cause additional rerenders
Wouldn't the same be true of state updates in onData
?
That being said, I think the difference here is that you don't have to wait for React's render cycle and triggering the effect to make the state update. If a subscription fires in rapid succession, you give React a better chance to batch the state updates together which might result in fewer rerenders. Does that sound accurate? If so, perhaps we could tweak the description here to say something along those lines?
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 the same be true of state updates in onData?
Up to React. If they finally start batching uSES
and useState
updates together (which they committed to in a bug report issue), there will not be an additional render.
It's definitely cleaner to call a state update in onData
than one render later.
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've added the sentence
State updates made in an event handler like
onData
might - depending on the React version - be batched and cause only a single rerender.
error: void 0, | ||
variables, | ||
}; | ||
observable.__.setResult(result); |
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.
Out of curiosity, why the setResult
function instead of just using an assignment?
observable.__.result = result;
Is it a type safety thing?
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's hiding a mutable change from the compiler 😓
It cannot reliably detect that this is a deliberate non-reactive write (because we're not using a ref, but instead store values as "internal state" of the observable), so it would complain.
if (!subscriptionStopped) { | ||
observable.__.setResult({ | ||
loading: false, | ||
data: void 0, |
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.
We should possibly look into this when we address #11165. We persist data
in useQuery
when we've had a successful result but we get an error on a subsequent result.
That being said, I know this hook is meant to capture the latest value returned from the subscription, so maybe this makes the most sense? Thoughts? (definitely not something to address in this PR, but wanted to add this note as a discussion topic).
// TODO: fetchResult.data can be null but SubscriptionResult.data | ||
// expects TData | undefined only | ||
data: fetchResult.data!, | ||
error: void 0, |
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.
We should look into this with #11165. I don't believe we're accurately capturing errorPolicy: 'all'
in this hook since next
won't populate errors and error
won't populate data
. I believe next
would be called in the case of partial data with errorPolicy: 'all'
, but would be worth checking.
Not something to do in this PR since this has likely been a bug/oversight for some time, but wanted to note this so we don't miss it.
Co-authored-by: Jerel Miller <[email protected]>
…ooks/useSubscription
Another one for #11511 - this one is essentially a rewrite.