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

Avoid excessive cache.watch / unsubscribe on every render in useFragment #11464

Merged
merged 17 commits into from
Jan 9, 2024

Conversation

jerelmiller
Copy link
Member

@jerelmiller jerelmiller commented Jan 5, 2024

While looking through useFragment, I happened to notice that the subscribe callback passed to useSyncExternalStore was not wrapped in a useCallback. Because of this, useFragment will unsubscribe from the previous cache.watch and resubscribe with a cache.watch on every render. While cache.watch should be fairly low overhead, there is no need to have to unsubscribe/resubscribe on every render and instead the cache.watch should last until either the cache has been changed, or the options have changed.

Checklist:

  • If this PR contains changes to the library itself (not necessary for e.g. docs updates), please include a changeset (see CONTRIBUTING.md)
  • If this PR is a new feature, please reference an issue where a consensus about the design was reached (not necessary for small changes)
  • Make sure all of the significant new logic is covered by tests

Copy link

changeset-bot bot commented Jan 5, 2024

🦋 Changeset detected

Latest commit: 524aae7

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 Patch

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 Jan 5, 2024

size-limit report 📦

Path Size
dist/apollo-client.min.cjs 37.04 KB (+0.09% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" 43.5 KB (+0.08% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/main.cjs" (production) 41.99 KB (+0.1% 🔺)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" 32.54 KB (0%)
import { ApolloClient, InMemoryCache, HttpLink } from "dist/index.js" (production) 31.23 KB (0%)
import { ApolloProvider } from "dist/react/index.js" 1.21 KB (0%)
import { ApolloProvider } from "dist/react/index.js" (production) 1.2 KB (0%)
import { useQuery } from "dist/react/index.js" 4.27 KB (0%)
import { useQuery } from "dist/react/index.js" (production) 4.08 KB (0%)
import { useLazyQuery } from "dist/react/index.js" 4.58 KB (0%)
import { useLazyQuery } from "dist/react/index.js" (production) 4.39 KB (0%)
import { useMutation } from "dist/react/index.js" 2.55 KB (0%)
import { useMutation } from "dist/react/index.js" (production) 2.53 KB (0%)
import { useSubscription } from "dist/react/index.js" 2.23 KB (0%)
import { useSubscription } from "dist/react/index.js" (production) 2.19 KB (0%)
import { useSuspenseQuery } from "dist/react/index.js" 4.6 KB (0%)
import { useSuspenseQuery } from "dist/react/index.js" (production) 4.04 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" 4.12 KB (0%)
import { useBackgroundQuery } from "dist/react/index.js" (production) 3.54 KB (0%)
import { useReadQuery } from "dist/react/index.js" 2.98 KB (+0.04% 🔺)
import { useReadQuery } from "dist/react/index.js" (production) 2.92 KB (0%)
import { useFragment } from "dist/react/index.js" 2.17 KB (+3.8% 🔺)
import { useFragment } from "dist/react/index.js" (production) 2.11 KB (+3.75% 🔺)

clearTimeout(lastTimeout);
};
},
React.useCallback(
Copy link
Member Author

Choose a reason for hiding this comment

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

Oddly, this change seems to now throw off the timing of the update between useQuery and useFragment when used in the same component.

Copy link
Member Author

Choose a reason for hiding this comment

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

Turns out we had tried to re-render a 2nd time in useFragment before the first had finished, so a lingering setTimeout had been queued before our cache.writeQuery in the test. Because of this, useFragment would re-render before useQuery and threw of the timing. I was able to fix this in 6c1372c by cancelling the timeout from the previous attempt in case it hadn't yet flushed before queuing another attempt at the render.

Copy link
Member Author

Choose a reason for hiding this comment

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

Turns out this change also makes the timing in React 17 and 18 the same. I was able to remove the additional React version check in the test.

@jerelmiller jerelmiller changed the title [WIP] Avoid excessive cache.watch / unsubscribe on every render in useFragment Avoid excessive cache.watch / unsubscribe on every render in useFragment Jan 5, 2024
@jerelmiller jerelmiller marked this pull request as ready for review January 5, 2024 20:55
const INIT = {};

export function useLazyRef<T>(getInitialValue: () => T) {
const ref = React.useRef<T>(INIT as unknown as T);
Copy link
Member Author

@jerelmiller jerelmiller Jan 5, 2024

Choose a reason for hiding this comment

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

Comparing against this INIT sentinel here instead of something like undefined makes sure that we can still set undefined as a value on subsequent renders without re-running the init function.

resultRef.current
: (resultRef.current = latestDiffToResult);
};
const getSnapshot = React.useCallback(() => resultRef.current, []);
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 prevents React from doing extra work when the getSnapshot function isn't stable across renders.


const resultRef = React.useRef<UseFragmentResult<TData>>();
let latestDiff = cache.diff<TData>(diffOptions);
const resultRef = useLazyRef<UseFragmentResult<TData>>(() =>
Copy link
Member Author

Choose a reason for hiding this comment

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

Introducing this useLazyRef avoids the need to call cache.diff on every render which should give a small perf benefit.

Copy link
Member

Choose a reason for hiding this comment

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

Did you check if we can utilize this in other places in our codebase?

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 just did a quick glance through some of our other hooks. There is only one other place right now that could potentially utilize it, which is the useInternalState hook in useQuery that initializes the InternalState instance (would still need some of that if statement though, so not sure what the bundle size diff would be)

clearTimeout(lastTimeout);
};
},
React.useCallback(
Copy link
Member Author

Choose a reason for hiding this comment

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

Turns out this change also makes the timing in React 17 and 18 the same. I was able to remove the additional React version check in the test.

Copy link
Member

@phryneas phryneas left a comment

Choose a reason for hiding this comment

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

Looks very good - thank you for digging into this! :)

src/react/hooks/useFragment.ts Outdated Show resolved Hide resolved

const resultRef = React.useRef<UseFragmentResult<TData>>();
let latestDiff = cache.diff<TData>(diffOptions);
const resultRef = useLazyRef<UseFragmentResult<TData>>(() =>
Copy link
Member

Choose a reason for hiding this comment

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

Did you check if we can utilize this in other places in our codebase?

@jerelmiller jerelmiller merged commit aac12b2 into main Jan 9, 2024
25 checks passed
@jerelmiller jerelmiller deleted the jerel/more-optimal-usefragment branch January 9, 2024 06:37
@github-actions github-actions bot mentioned this pull request Jan 9, 2024
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 9, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants