diff --git a/.changeset/late-rabbits-protect.md b/.changeset/late-rabbits-protect.md new file mode 100644 index 00000000000..1494b569018 --- /dev/null +++ b/.changeset/late-rabbits-protect.md @@ -0,0 +1,7 @@ +--- +'@apollo/client': minor +--- + +Remove the need to call `retain` from `useLoadableQuery` 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 f78538c39a5..d284dce0aa7 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39136, + "dist/apollo-client.min.cjs": 39129, "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32663 } diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx index 62f0827c7bf..68ef6a7e9a7 100644 --- a/src/react/hooks/__tests__/useLoadableQuery.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -47,11 +47,17 @@ import { FetchMoreFunction, RefetchFunction } from "../useSuspenseQuery"; import invariant, { InvariantError } from "ts-invariant"; import { Profiler, + SimpleCaseData, createProfiler, + setupSimpleCase, spyOnConsole, useTrackRenders, } from "../../../testing/internal"; +afterEach(() => { + jest.useRealTimers(); +}); + interface SimpleQueryData { greeting: string; } @@ -404,6 +410,262 @@ it("tears down the query on unmount", async () => { 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(() => useLoadableQuery(query, { client })); + const [loadQuery] = result.current; + + act(() => loadQuery()); + const [, queryRef] = result.current; + + expect(queryRef!).not.toBeDisposed(); + expect(client.getObservableQueries().size).toBe(1); + expect(client).toHaveSuspenseCacheEntryUsing(query); + + await act(async () => { + link.simulateResult({ result: { data: { greeting: "Hello" } } }, true); + // Ensure simulateResult will deliver the result since its wrapped with + // setTimeout + await jest.advanceTimersByTimeAsync(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(() => useLoadableQuery(query, { client })); + const [loadQuery] = result.current; + + act(() => loadQuery()); + const [, queryRef] = result.current; + + expect(queryRef!).not.toBeDisposed(); + expect(client.getObservableQueries().size).toBe(1); + expect(client).toHaveSuspenseCacheEntryUsing(query); + + await act(async () => { + link.simulateResult({ result: { data: { greeting: "Hello" } } }, true); + // Ensure simulateResult will deliver the result since its wrapped with + // setTimeout + await jest.advanceTimersByTimeAsync(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 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 } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [show, setShow] = React.useState(false); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + + }> + {show && queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + + // initial render + await Profiler.takeRender(); + await act(() => user.click(screen.getByText("Load query"))); + + 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 client = new ApolloClient({ + link: new MockLink(mocks), + cache: new InMemoryCache(), + }); + + const Profiler = createDefaultProfiler(); + const { SuspenseFallback, ReadQueryHook } = + createDefaultProfiledComponents(Profiler); + + function App() { + useTrackRenders(); + const [show, setShow] = React.useState(true); + const [loadQuery, queryRef] = useLoadableQuery(query); + + return ( + <> + + + }> + {show && queryRef && } + + + ); + } + + const { user } = renderWithClient(, { client, wrapper: Profiler }); + const toggleButton = screen.getByText("Toggle"); + + // initial render + await Profiler.takeRender(); + await act(() => user.click(screen.getByText("Load query"))); + + 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); + // Here we don't expect a suspense cache entry because we previously disposed + // of it and did not call `loadQuery` again, which would normally add it to + // the suspense cache + expect(client).not.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("changes variables on a query and resuspends when passing new variables to the loadQuery function", async () => { const { query, mocks } = useVariablesQueryCase(); diff --git a/src/react/hooks/useLoadableQuery.ts b/src/react/hooks/useLoadableQuery.ts index 3b4d9fba348..796721a92fc 100644 --- a/src/react/hooks/useLoadableQuery.ts +++ b/src/react/hooks/useLoadableQuery.ts @@ -132,8 +132,6 @@ export function useLoadableQuery< const calledDuringRender = useRenderGuard(); - React.useEffect(() => internalQueryRef?.retain(), [internalQueryRef]); - const fetchMore: FetchMoreFunction = React.useCallback( (options) => { if (!internalQueryRef) {