Skip to content

Commit

Permalink
Remove retain from useLoadableQuery to allow for auto disposal (#…
Browse files Browse the repository at this point in the history
…11442)

* Don't retain in useLoadableQuery and allow it to auto dispose

* Add test to show it auto disposes in configured timeout

* Add test to ensure useLoadableQuery can remount and work as expected

* Add test to ensure auto subscribe after natural dispose works

* Add changeset

* Update size limits

* trigger ci

* Clean up Prettier, Size-limit, and Api-Extractor

---------

Co-authored-by: Lenz Weber-Tronic <lorenz.weber-tronic@apollographql.com>
Co-authored-by: phryneas <phryneas@users.noreply.github.com>
  • Loading branch information
3 people committed Dec 21, 2023
1 parent de5b878 commit 4b6f2bc
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 3 deletions.
7 changes: 7 additions & 0 deletions .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.
2 changes: 1 addition & 1 deletion .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
}
262 changes: 262 additions & 0 deletions src/react/hooks/__tests__/useLoadableQuery.test.tsx
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<SimpleCaseData>();
const { SuspenseFallback, ReadQueryHook } =
createDefaultProfiledComponents(Profiler);

function App() {
useTrackRenders();
const [show, setShow] = React.useState(false);
const [loadQuery, queryRef] = useLoadableQuery(query);

return (
<>
<button onClick={() => loadQuery()}>Load query</button>
<button onClick={() => setShow((show) => !show)}>Toggle</button>
<Suspense fallback={<SuspenseFallback />}>
{show && queryRef && <ReadQueryHook queryRef={queryRef} />}
</Suspense>
</>
);
}

const { user } = renderWithClient(<App />, { 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<SimpleCaseData>();
const { SuspenseFallback, ReadQueryHook } =
createDefaultProfiledComponents(Profiler);

function App() {
useTrackRenders();
const [show, setShow] = React.useState(true);
const [loadQuery, queryRef] = useLoadableQuery(query);

return (
<>
<button onClick={() => loadQuery()}>Load query</button>
<button onClick={() => setShow((show) => !show)}>Toggle</button>
<Suspense fallback={<SuspenseFallback />}>
{show && queryRef && <ReadQueryHook queryRef={queryRef} />}
</Suspense>
</>
);
}

const { user } = renderWithClient(<App />, { 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();

Expand Down
2 changes: 0 additions & 2 deletions src/react/hooks/useLoadableQuery.ts
Expand Up @@ -132,8 +132,6 @@ export function useLoadableQuery<

const calledDuringRender = useRenderGuard();

React.useEffect(() => internalQueryRef?.retain(), [internalQueryRef]);

const fetchMore: FetchMoreFunction<TData, TVariables> = React.useCallback(
(options) => {
if (!internalQueryRef) {
Expand Down

0 comments on commit 4b6f2bc

Please sign in to comment.