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

useSubscription: fix rules of React violations #11863

Merged
merged 14 commits into from
Jul 3, 2024

Conversation

phryneas
Copy link
Member

Another one for #11511 - this one is essentially a rewrite.

Copy link

changeset-bot bot commented May 21, 2024

🦋 Changeset detected

Latest commit: 33829ec

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 Minor

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
Contributor

github-actions bot commented May 21, 2024

size-limit report 📦

Path Size
dist/apollo-client.min.cjs 38.69 KB (+0.04% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" 47.48 KB (+0.01% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" (production) 45.08 KB (+0.07% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" 34.22 KB (0%)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" (production) 32.08 KB (0%)
import { ApolloProvider } from "dist/react/index.js" 1.26 KB (0%)
import { ApolloProvider } from "dist/react/index.js" (production) 1.24 KB (0%)
import { useQuery } from "dist/react/index.js" 5.32 KB (0%)
import { useQuery } from "dist/react/index.js" (production) 4.39 KB (0%)
import { useLazyQuery } from "dist/react/index.js" 5.6 KB (0%)
import { useLazyQuery } from "dist/react/index.js" (production) 4.67 KB (0%)
import { useMutation } from "dist/react/index.js" 3.59 KB (0%)
import { useMutation } from "dist/react/index.js" (production) 2.81 KB (0%)
import { useSubscription } from "dist/react/index.js" 3.63 KB (+12.24% 🔺)
import { useSubscription } from "dist/react/index.js" (production) 2.78 KB (+14.49% 🔺)
import { useSuspenseQuery } from "dist/react/index.js" 5.47 KB (0%)
import { useSuspenseQuery } from "dist/react/index.js" (production) 4.12 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" 4.98 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" (production) 3.63 KB (0%)
import { useLoadableQuery } from "dist/react/index.js" 5.04 KB (0%)
import { useLoadableQuery } from "dist/react/index.js" (production) 3.69 KB (0%)
import { useReadQuery } from "dist/react/index.js" 3.35 KB (0%)
import { useReadQuery } from "dist/react/index.js" (production) 3.3 KB (0%)
import { useFragment } from "dist/react/index.js" 2.32 KB (0%)
import { useFragment } from "dist/react/index.js" (production) 2.27 KB (0%)

Copy link

netlify bot commented May 21, 2024

Deploy Preview for apollo-client-docs ready!

Name Link
🔨 Latest commit 33829ec
🔍 Latest deploy log https://app.netlify.com/sites/apollo-client-docs/deploys/668534061444cb00082c8652
😎 Deploy Preview https://deploy-preview-11863--apollo-client-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

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

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.

Copy link
Contributor

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)

Copy link
Member Author

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)

Copy link
Member Author

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)

@phryneas phryneas marked this pull request as ready for review May 21, 2024 14:55
@phryneas phryneas requested review from jerelmiller and alessbell May 21, 2024 14:56
fetchPolicy !== observable.__.fetchPolicy ||
!equal(variables, observable.__.variables)) &&
(typeof shouldResubscribe === "function" ?
!!shouldResubscribe(options!)
Copy link
Member Author

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.

Comment on lines +164 to +172
setObservable(
(observable = createSubscription(
client,
subscription,
variables,
fetchPolicy,
context
))
);
Copy link
Member Author

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

@phryneas phryneas May 22, 2024

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.

Copy link
Member Author

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

@jerelmiller
Copy link
Member

Out of curiosity, does this fix #11165?

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 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.

@@ -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>> = {}
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
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.

Copy link
Member Author

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

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 Show resolved Hide resolved
Comment on lines 136 to 137
let { skip, fetchPolicy, variables, shouldResubscribe, context } = options;
variables = useDeepMemo(() => variables, [variables]);
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
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.

Copy link
Member Author

Choose a reason for hiding this comment

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

Perfectly fair :)

@kjhuang-db
Copy link

kjhuang-db commented Jun 14, 2024

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

@jerelmiller
Copy link
Member

@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.

@kjhuang-db
Copy link

kjhuang-db commented Jun 14, 2024

@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 useSubscription declarative api (return state data directly) is batching behavior with react 18: the previous usage of useState within useSubscription might get batched (and break consumers' update based on useEffect(, [state]) usage)

  1. do you foresee putting return in externalSyncStore will address this issue? from the change in doc, it seems still an issue in 18?

related: facebook/react#25191 (comment) (what a small world)

@jerelmiller
Copy link
Member

@kjhuang-db answered in discord :)

@phryneas phryneas modified the milestones: React Hooks refresh, 3.11.0 Jun 25, 2024
@phryneas
Copy link
Member Author

Let's address #10216 and #11165 in separate follow-up PRs. I've added both to the 3.11.0 milestone.

@jerelmiller jerelmiller changed the base branch from main to release-3.11 July 2, 2024 17:29
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 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!

.changeset/little-suits-return.md Outdated Show resolved Hide resolved
src/react/hooks/useSubscription.ts Outdated Show resolved Hide resolved
*
* 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.
Copy link
Member

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?

Copy link
Member Author

@phryneas phryneas Jul 3, 2024

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.

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'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.

src/react/hooks/useSubscription.ts Outdated Show resolved Hide resolved
error: void 0,
variables,
};
observable.__.setResult(result);
Copy link
Member

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?

Copy link
Member Author

@phryneas phryneas Jul 3, 2024

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

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

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.

phryneas and others added 3 commits July 3, 2024 12:17
@phryneas phryneas added the auto-cleanup 🤖 label Jul 3, 2024
@phryneas phryneas merged commit 98e44f7 into release-3.11 Jul 3, 2024
42 checks passed
@phryneas phryneas deleted the pr/rules-of-hooks/useSubscription branch July 3, 2024 11:27
@github-actions github-actions bot mentioned this pull request Jul 9, 2024
@github-actions github-actions bot mentioned this pull request Jul 22, 2024
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 3, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants