From 6d46ab930a5e9bd5cae153d3b75b8966784fcd4e Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Tue, 19 Dec 2023 08:56:17 -0700 Subject: [PATCH] Remove `retain` call from `useBackgroundQuery` to allow for auto disposal (#11438) --- .changeset/wise-news-grab.md | 7 + .size-limits.json | 2 +- .../__tests__/useBackgroundQuery.test.tsx | 291 ++++++++++++++++++ src/react/hooks/useBackgroundQuery.ts | 2 - 4 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 .changeset/wise-news-grab.md diff --git a/.changeset/wise-news-grab.md b/.changeset/wise-news-grab.md new file mode 100644 index 00000000000..83eafb1375f --- /dev/null +++ b/.changeset/wise-news-grab.md @@ -0,0 +1,7 @@ +--- +'@apollo/client': minor +--- + +Remove the need to call `retain` from `useBackgroundQuery` since `useReadQuery` will now retain the query. This means that a `queryRef` that is not consumed by `useReadQuery` within the given `autoDisposeTimeoutMs` will now be auto diposed for you. + +Thanks to [#11412](https://github.com/apollographql/apollo-client/pull/11412), disposed query refs will be automatically resubscribed to the query when consumed by `useReadQuery` after it has been disposed. diff --git a/.size-limits.json b/.size-limits.json index 7a4493b82cf..fa4846d0655 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39135, + "dist/apollo-client.min.cjs": 39130, "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32651 } diff --git a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx index 006cc0c876e..fbd7c3dd973 100644 --- a/src/react/hooks/__tests__/useBackgroundQuery.test.tsx +++ b/src/react/hooks/__tests__/useBackgroundQuery.test.tsx @@ -23,6 +23,7 @@ import { MockSubscriptionLink, mockSingleLink, MockedProvider, + wait, } from "../../../testing"; import { concatPagination, @@ -54,6 +55,10 @@ import { useTrackRenders, } from "../../../testing/internal"; +afterEach(() => { + jest.useRealTimers(); +}); + function createDefaultTrackedComponents< Snapshot extends { result: UseReadQueryResult | null }, TData = Snapshot["result"] extends UseReadQueryResult | null ? @@ -155,6 +160,292 @@ it("fetches a simple query with minimal config", async () => { await expect(Profiler).not.toRerender({ timeout: 50 }); }); +it("tears down the query on unmount", async () => { + const { query, mocks } = setupSimpleCase(); + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + }); + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultTrackedComponents(Profiler); + + function App() { + useTrackRenders(); + const [queryRef] = useBackgroundQuery(query); + + return ( + }> + + + ); + } + + const { unmount } = renderWithClient(, { client, wrapper: Profiler }); + + // initial suspended render + await Profiler.takeRender(); + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + unmount(); + + await wait(0); + + expect(client.getObservableQueries().size).toBe(0); + expect(client).not.toHaveSuspenseCacheEntryUsing(query); +}); + +it("auto disposes of the queryRef if not used within timeout", async () => { + jest.useFakeTimers(); + const { query } = setupSimpleCase(); + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ link, cache: new InMemoryCache() }); + + const { result } = renderHook(() => useBackgroundQuery(query, { client })); + + const [queryRef] = result.current; + + expect(queryRef).not.toBeDisposed(); + expect(client.getObservableQueries().size).toBe(1); + expect(client).toHaveSuspenseCacheEntryUsing(query); + + await act(() => { + link.simulateResult({ result: { data: { greeting: "Hello" } } }, true); + // Ensure simulateResult will deliver the result since its wrapped with + // setTimeout + jest.advanceTimersByTime(10); + }); + + jest.advanceTimersByTime(30_000); + + expect(queryRef).toBeDisposed(); + expect(client.getObservableQueries().size).toBe(0); + expect(client).not.toHaveSuspenseCacheEntryUsing(query); +}); + +it("auto disposes of the queryRef if not used within configured timeout", async () => { + jest.useFakeTimers(); + const { query } = setupSimpleCase(); + const link = new MockSubscriptionLink(); + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + defaultOptions: { + react: { + suspense: { + autoDisposeTimeoutMs: 5000, + }, + }, + }, + }); + + const { result } = renderHook(() => useBackgroundQuery(query, { client })); + + const [queryRef] = result.current; + + expect(queryRef).not.toBeDisposed(); + expect(client.getObservableQueries().size).toBe(1); + expect(client).toHaveSuspenseCacheEntryUsing(query); + + await act(() => { + link.simulateResult({ result: { data: { greeting: "Hello" } } }, true); + // Ensure simulateResult will deliver the result since its wrapped with + // setTimeout + jest.advanceTimersByTime(10); + }); + + jest.advanceTimersByTime(5000); + + expect(queryRef).toBeDisposed(); + expect(client.getObservableQueries().size).toBe(0); + expect(client).not.toHaveSuspenseCacheEntryUsing(query); +}); + +it("will resubscribe after disposed when mounting useReadQuery", async () => { + const { query, mocks } = setupSimpleCase(); + const user = userEvent.setup(); + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + defaultOptions: { + react: { + suspense: { + // Set this to something really low to avoid fake timers + autoDisposeTimeoutMs: 20, + }, + }, + }, + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultTrackedComponents(Profiler); + + function App() { + useTrackRenders(); + const [show, setShow] = React.useState(false); + const [queryRef] = useBackgroundQuery(query); + + return ( + <> + + }> + {show && } + + + ); + } + + renderWithClient(, { client, wrapper: Profiler }); + + expect(client.getObservableQueries().size).toBe(1); + expect(client).toHaveSuspenseCacheEntryUsing(query); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App]); + } + + // Wait long enough for auto dispose to kick in + await wait(50); + + expect(client.getObservableQueries().size).toBe(0); + expect(client).not.toHaveSuspenseCacheEntryUsing(query); + + await act(() => user.click(screen.getByText("Toggle"))); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + client.writeQuery({ + query, + data: { greeting: "Hello again" }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello again" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender({ timeout: 50 }); +}); + +it("auto resubscribes when mounting useReadQuery after naturally disposed by useReadQuery", async () => { + const { query, mocks } = setupSimpleCase(); + const user = userEvent.setup(); + const client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultTrackedComponents(Profiler); + + function App() { + useTrackRenders(); + const [show, setShow] = React.useState(true); + const [queryRef] = useBackgroundQuery(query); + + return ( + <> + + }> + {show && } + + + ); + } + + renderWithClient(, { client, wrapper: Profiler }); + + const toggleButton = screen.getByText("Toggle"); + + expect(client.getObservableQueries().size).toBe(1); + expect(client).toHaveSuspenseCacheEntryUsing(query); + + { + const { renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, SuspenseFallback]); + } + + { + const { snapshot } = await Profiler.takeRender(); + + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await act(() => user.click(toggleButton)); + await Profiler.takeRender(); + await wait(0); + + expect(client.getObservableQueries().size).toBe(0); + expect(client).not.toHaveSuspenseCacheEntryUsing(query); + + await act(() => user.click(toggleButton)); + + expect(client.getObservableQueries().size).toBe(1); + expect(client).toHaveSuspenseCacheEntryUsing(query); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([App, ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + client.writeQuery({ + query, + data: { greeting: "Hello again" }, + }); + + { + const { snapshot, renderedComponents } = await Profiler.takeRender(); + + expect(renderedComponents).toStrictEqual([ReadQueryHook]); + expect(snapshot.result).toEqual({ + data: { greeting: "Hello again" }, + error: undefined, + networkStatus: NetworkStatus.ready, + }); + } + + await expect(Profiler).not.toRerender({ timeout: 50 }); +}); + it("allows the client to be overridden", async () => { const { query } = setupSimpleCase(); diff --git a/src/react/hooks/useBackgroundQuery.ts b/src/react/hooks/useBackgroundQuery.ts index af5058b5cac..ab9105d4243 100644 --- a/src/react/hooks/useBackgroundQuery.ts +++ b/src/react/hooks/useBackgroundQuery.ts @@ -219,8 +219,6 @@ export function useBackgroundQuery< updateWrappedQueryRef(wrappedQueryRef, promise); } - React.useEffect(() => queryRef.retain(), [queryRef]); - const fetchMore: FetchMoreFunction = React.useCallback( (options) => { const promise = queryRef.fetchMore(options as FetchMoreQueryOptions);