Skip to content

Commit 87fc34d

Browse files
committed
Add support for autoDisposeTimeoutMs in useSuspenseFragment
1 parent ef27883 commit 87fc34d

File tree

3 files changed

+237
-3
lines changed

3 files changed

+237
-3
lines changed

src/react/hooks/__tests__/useSuspenseFragment.test.tsx

+218-3
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import {
2020
renderHookToSnapshotStream,
2121
useTrackRenders,
2222
} from "@testing-library/react-render-stream";
23-
import { spyOnConsole } from "../../../testing/internal";
24-
import { renderHook } from "@testing-library/react";
23+
import { renderAsync, spyOnConsole } from "../../../testing/internal";
24+
import { act, renderHook, screen, waitFor } from "@testing-library/react";
2525
import { InvariantError } from "ts-invariant";
26-
import { MockedProvider, wait } from "../../../testing";
26+
import { MockedProvider, MockSubscriptionLink, wait } from "../../../testing";
2727
import { expectTypeOf } from "expect-type";
28+
import userEvent from "@testing-library/user-event";
2829

2930
function createDefaultRenderStream<TData = unknown>() {
3031
return createRenderStream({
@@ -1566,6 +1567,220 @@ test("tears down all watches when rendering multiple records", async () => {
15661567
expect(cache["watches"].size).toBe(0);
15671568
});
15681569

1570+
test("tears down watches after default autoDisposeTimeoutMs if component never renders again after suspending", async () => {
1571+
jest.useFakeTimers();
1572+
interface ItemFragment {
1573+
__typename: "Item";
1574+
id: number;
1575+
text: string;
1576+
}
1577+
1578+
const fragment: TypedDocumentNode<ItemFragment> = gql`
1579+
fragment ItemFragment on Item {
1580+
id
1581+
text
1582+
}
1583+
`;
1584+
1585+
const cache = new InMemoryCache();
1586+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
1587+
const link = new MockSubscriptionLink();
1588+
const client = new ApolloClient({ link, cache });
1589+
1590+
function App() {
1591+
const [showItem, setShowItem] = React.useState(true);
1592+
1593+
return (
1594+
<ApolloProvider client={client}>
1595+
<button onClick={() => setShowItem(false)}>Hide item</button>
1596+
{showItem && (
1597+
<Suspense fallback="Loading item...">
1598+
<Item />
1599+
</Suspense>
1600+
)}
1601+
</ApolloProvider>
1602+
);
1603+
}
1604+
1605+
function Item() {
1606+
const { data } = useSuspenseFragment({
1607+
fragment,
1608+
from: { __typename: "Item", id: 1 },
1609+
});
1610+
1611+
return <span>{data.text}</span>;
1612+
}
1613+
1614+
await renderAsync(<App />);
1615+
1616+
// Ensure <Greeting /> suspends immediately
1617+
expect(screen.getByText("Loading item...")).toBeInTheDocument();
1618+
1619+
// Hide the greeting before it finishes loading data
1620+
await act(() => user.click(screen.getByText("Hide item")));
1621+
1622+
expect(screen.queryByText("Loading item...")).not.toBeInTheDocument();
1623+
1624+
client.writeFragment({
1625+
fragment,
1626+
data: { __typename: "Item", id: 1, text: "Item #1" },
1627+
});
1628+
1629+
// clear the microtask queue
1630+
await act(() => Promise.resolve());
1631+
1632+
expect(cache["watches"].size).toBe(1);
1633+
1634+
jest.advanceTimersByTime(30_000);
1635+
1636+
expect(cache["watches"].size).toBe(0);
1637+
1638+
jest.useRealTimers();
1639+
});
1640+
1641+
test("tears down watches after configured autoDisposeTimeoutMs if component never renders again after suspending", async () => {
1642+
jest.useFakeTimers();
1643+
interface ItemFragment {
1644+
__typename: "Item";
1645+
id: number;
1646+
text: string;
1647+
}
1648+
1649+
const fragment: TypedDocumentNode<ItemFragment> = gql`
1650+
fragment ItemFragment on Item {
1651+
id
1652+
text
1653+
}
1654+
`;
1655+
1656+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
1657+
const link = new MockSubscriptionLink();
1658+
const cache = new InMemoryCache();
1659+
const client = new ApolloClient({
1660+
link,
1661+
cache,
1662+
defaultOptions: {
1663+
react: {
1664+
suspense: {
1665+
autoDisposeTimeoutMs: 5000,
1666+
},
1667+
},
1668+
},
1669+
});
1670+
1671+
function App() {
1672+
const [showItem, setShowItem] = React.useState(true);
1673+
1674+
return (
1675+
<ApolloProvider client={client}>
1676+
<button onClick={() => setShowItem(false)}>Hide item</button>
1677+
{showItem && (
1678+
<Suspense fallback="Loading item...">
1679+
<Item />
1680+
</Suspense>
1681+
)}
1682+
</ApolloProvider>
1683+
);
1684+
}
1685+
1686+
function Item() {
1687+
const { data } = useSuspenseFragment({
1688+
fragment,
1689+
from: { __typename: "Item", id: 1 },
1690+
});
1691+
1692+
return <span>{data.text}</span>;
1693+
}
1694+
1695+
await renderAsync(<App />);
1696+
1697+
// Ensure <Greeting /> suspends immediately
1698+
expect(screen.getByText("Loading item...")).toBeInTheDocument();
1699+
1700+
// Hide the greeting before it finishes loading data
1701+
await act(() => user.click(screen.getByText("Hide item")));
1702+
1703+
expect(screen.queryByText("Loading item...")).not.toBeInTheDocument();
1704+
1705+
client.writeFragment({
1706+
fragment,
1707+
data: { __typename: "Item", id: 1, text: "Item #1" },
1708+
});
1709+
1710+
// clear the microtask queue
1711+
await act(() => Promise.resolve());
1712+
1713+
expect(cache["watches"].size).toBe(1);
1714+
1715+
jest.advanceTimersByTime(5000);
1716+
1717+
expect(cache["watches"].size).toBe(0);
1718+
1719+
jest.useRealTimers();
1720+
});
1721+
1722+
test("cancels autoDisposeTimeoutMs if the component renders before timer finishes", async () => {
1723+
jest.useFakeTimers();
1724+
interface ItemFragment {
1725+
__typename: "Item";
1726+
id: number;
1727+
text: string;
1728+
}
1729+
1730+
const fragment: TypedDocumentNode<ItemFragment> = gql`
1731+
fragment ItemFragment on Item {
1732+
id
1733+
text
1734+
}
1735+
`;
1736+
1737+
const link = new MockSubscriptionLink();
1738+
const cache = new InMemoryCache();
1739+
const client = new ApolloClient({ link, cache });
1740+
1741+
function App() {
1742+
return (
1743+
<ApolloProvider client={client}>
1744+
<Suspense fallback="Loading item...">
1745+
<Item />
1746+
</Suspense>
1747+
</ApolloProvider>
1748+
);
1749+
}
1750+
1751+
function Item() {
1752+
const { data } = useSuspenseFragment({
1753+
fragment,
1754+
from: { __typename: "Item", id: 1 },
1755+
});
1756+
1757+
return <span>{data.text}</span>;
1758+
}
1759+
1760+
await renderAsync(<App />);
1761+
1762+
// Ensure <Greeting /> suspends immediately
1763+
expect(screen.getByText("Loading item...")).toBeInTheDocument();
1764+
1765+
client.writeFragment({
1766+
fragment,
1767+
data: { __typename: "Item", id: 1, text: "Item #1" },
1768+
});
1769+
1770+
// clear the microtask queue
1771+
await act(() => Promise.resolve());
1772+
1773+
await waitFor(() => {
1774+
expect(screen.getByText("Item #1")).toBeInTheDocument();
1775+
});
1776+
1777+
jest.advanceTimersByTime(30_000);
1778+
1779+
expect(cache["watches"].size).toBe(1);
1780+
1781+
jest.useRealTimers();
1782+
});
1783+
15691784
describe.skip("type tests", () => {
15701785
test("returns TData when from is a non-null value", () => {
15711786
const fragment: TypedDocumentNode<{ foo: string }> = gql``;

src/react/internal/cache/FragmentReference.ts

+18
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type FragmentRefPromise<TData> = PromiseWithState<TData>;
2020
type Listener<TData> = (promise: FragmentRefPromise<TData>) => void;
2121

2222
interface FragmentReferenceOptions {
23+
autoDisposeTimeoutMs?: number;
2324
onDispose?: () => void;
2425
}
2526

@@ -36,6 +37,7 @@ export class FragmentReference<
3637

3738
private subscription!: ObservableSubscription;
3839
private listeners = new Set<Listener<MaybeMasked<TData>>>();
40+
private autoDisposeTimeoutId?: NodeJS.Timeout;
3941

4042
private references = 0;
4143

@@ -58,11 +60,26 @@ export class FragmentReference<
5860

5961
const diff = this.getDiff(client, watchFragmentOptions);
6062

63+
// Start a timer that will automatically dispose of the query if the
64+
// suspended resource does not use this fragmentRef in the given time. This
65+
// helps prevent memory leaks when a component has unmounted before the
66+
// query has finished loading.
67+
const startDisposeTimer = () => {
68+
if (!this.references) {
69+
this.autoDisposeTimeoutId = setTimeout(
70+
this.dispose,
71+
options.autoDisposeTimeoutMs ?? 30_000
72+
);
73+
}
74+
};
75+
6176
this.promise =
6277
diff.complete ?
6378
createFulfilledPromise(diff.result)
6479
: this.createPendingPromise();
6580
this.subscribeToFragment();
81+
82+
this.promise.then(startDisposeTimer, startDisposeTimer);
6683
}
6784

6885
listen(listener: Listener<MaybeMasked<TData>>) {
@@ -75,6 +92,7 @@ export class FragmentReference<
7592

7693
retain() {
7794
this.references++;
95+
clearTimeout(this.autoDisposeTimeoutId);
7896
let disposed = false;
7997

8098
return () => {

src/react/internal/cache/SuspenseCache.ts

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export class SuspenseCache {
6868

6969
if (!ref.current) {
7070
ref.current = new FragmentReference(client, options, {
71+
autoDisposeTimeoutMs: this.options.autoDisposeTimeoutMs,
7172
onDispose: () => {
7273
delete ref.current;
7374
},

0 commit comments

Comments
 (0)