diff --git a/package.json b/package.json
index b1f9c64c885b..a165ec473dd3 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "16.1.0",
"@testing-library/react-hooks": "8.0.1",
"@testing-library/react-native": "13.0.0",
diff --git a/packages/core/src/state/GCPolicy.ts b/packages/core/src/state/GCPolicy.ts
index bb689118c163..9ed3dfb9fab4 100644
--- a/packages/core/src/state/GCPolicy.ts
+++ b/packages/core/src/state/GCPolicy.ts
@@ -57,25 +57,30 @@ export class GCPolicy implements GCInterface {
// decrement
return () => {
- const currentCount = this.endpointCount.get(key) ?? 0;
- if (currentCount <= 1) {
- this.endpointCount.delete(key);
- // queue for cleanup
- this.endpointsQ.add(key);
- } else {
- this.endpointCount.set(key, currentCount - 1);
+ const currentCount = this.endpointCount.get(key)!;
+ if (currentCount !== undefined) {
+ if (currentCount <= 1) {
+ this.endpointCount.delete(key);
+ // queue for cleanup
+ this.endpointsQ.add(key);
+ } else {
+ this.endpointCount.set(key, currentCount - 1);
+ }
}
paths.forEach(path => {
if (!this.entityCount.has(path.key)) {
return;
}
const instanceCount = this.entityCount.get(path.key)!;
- if (instanceCount.get(path.pk)! <= 1) {
- instanceCount.delete(path.pk);
- // queue for cleanup
- this.entitiesQ.push(path);
- } else {
- instanceCount.set(path.pk, instanceCount.get(path.pk)! - 1);
+ const entityCount = instanceCount.get(path.pk)!;
+ if (entityCount !== undefined) {
+ if (entityCount <= 1) {
+ instanceCount.delete(path.pk);
+ // queue for cleanup
+ this.entitiesQ.push(path);
+ } else {
+ instanceCount.set(path.pk, entityCount - 1);
+ }
}
});
};
diff --git a/packages/core/src/state/__tests__/GCPolicy.test.ts b/packages/core/src/state/__tests__/GCPolicy.test.ts
index 04ced0dfba11..cde95164e8b3 100644
--- a/packages/core/src/state/__tests__/GCPolicy.test.ts
+++ b/packages/core/src/state/__tests__/GCPolicy.test.ts
@@ -64,6 +64,35 @@ describe('GCPolicy', () => {
});
});
+ it('should dispatch GC action once no ref counts and is expired with extra decrements', () => {
+ const key = 'testEndpoint';
+ const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
+ const state = {
+ meta: { testEndpoint: { expiresAt: Date.now() - 1000 } },
+ entityMeta: { testEntity: { '1': { expiresAt: Date.now() - 1000 } } },
+ };
+ (controller.getState as jest.Mock).mockReturnValue(state);
+
+ const countRef = gcPolicy.createCountRef({ key, paths });
+
+ const decrement = countRef();
+ countRef(); // Increment again
+ gcPolicy['runSweep']();
+ expect(controller.dispatch).not.toHaveBeenCalled();
+ decrement();
+ gcPolicy['runSweep']();
+ expect(controller.dispatch).not.toHaveBeenCalled();
+ decrement(); // Decrement twice
+ decrement(); // Decrement extra time
+
+ gcPolicy['runSweep']();
+ expect(controller.dispatch).toHaveBeenCalledWith({
+ type: GC,
+ entities: [{ key: 'testEntity', pk: '1' }],
+ endpoints: ['testEndpoint'],
+ });
+ });
+
it('should dispatch GC action once no ref counts and no expiry state', () => {
const key = 'testEndpoint';
const paths: EntityPath[] = [{ key: 'testEntity', pk: '1' }];
diff --git a/packages/react/src/__tests__/integration-garbage-collection.web.tsx b/packages/react/src/__tests__/integration-garbage-collection.web.tsx
new file mode 100644
index 000000000000..51b536291f57
--- /dev/null
+++ b/packages/react/src/__tests__/integration-garbage-collection.web.tsx
@@ -0,0 +1,139 @@
+import {
+ AsyncBoundary,
+ DataProvider,
+ GCPolicy,
+ useSuspense,
+} from '@data-client/react';
+import { MockResolver } from '@data-client/test';
+import { render, screen, act } from '@testing-library/react';
+import { ArticleResource } from '__tests__/new';
+import '@testing-library/jest-dom';
+import { useState } from 'react';
+
+const mockGetList = jest.fn();
+const mockGet = jest.fn();
+const GC_INTERVAL = 5000;
+
+const ListView = ({ onSelect }: { onSelect: (id: number) => void }) => {
+ const articles = useSuspense(ArticleResource.getList);
+ return (
+
+ {articles.map(article => (
+
onSelect(article.id ?? 0)}>
+ {article.title}
+
+ ))}
+
+ );
+};
+
+const DetailView = ({ id }: { id: number }) => {
+ const article = useSuspense(ArticleResource.get, { id });
+ const [toggle, setToggle] = useState(false);
+
+ return (
+
+
{article.title}
+
{article.content}
+
+ {toggle &&
Toggle state: {toggle.toString()}
}
+
+ );
+};
+
+const TestComponent = () => {
+ const [view, setView] = useState<'list' | 'detail'>('list');
+ const [selectedId, setSelectedId] = useState(null);
+
+ return (
+
+
+ setView('list')}>Home
+ Loading...}>
+ {view === 'list' ?
+ {
+ setSelectedId(id);
+ setView('detail');
+ }}
+ />
+ : selectedId !== null && }
+
+
+
+ );
+};
+
+test('switch between list and detail view', async () => {
+ jest.useFakeTimers();
+ mockGetList.mockResolvedValue([
+ { id: 1, title: 'Article 1', content: 'Content 1' },
+ { id: 2, title: 'Article 2', content: 'Content 2' },
+ ]);
+ mockGet.mockResolvedValue({
+ id: 1,
+ title: 'Article 1',
+ content: 'Content 1',
+ });
+
+ render();
+
+ // Initial render, should show list view
+ expect(await screen.findByText('Article 1')).toBeInTheDocument();
+
+ // Switch to detail view
+ act(() => {
+ screen.getByText('Article 1').click();
+ });
+
+ // Detail view should render
+ expect(await screen.findByText('Content 1')).toBeInTheDocument();
+
+ // Jest time pass to trigger sweep but not expired
+ act(() => {
+ jest.advanceTimersByTime(GC_INTERVAL);
+ });
+
+ // Switch back to list view
+ act(() => {
+ screen.getByText('Home').click();
+ });
+
+ // List view should instantly render
+ expect(await screen.findByText('Article 1')).toBeInTheDocument();
+
+ // Switch back to detail view
+ act(() => {
+ screen.getByText('Article 1').click();
+ });
+
+ // Jest time pass to expiry
+ act(() => {
+ jest.advanceTimersByTime(ArticleResource.getList.dataExpiryLength ?? 60000);
+ });
+ expect(await screen.findByText('Content 1')).toBeInTheDocument();
+
+ // Re-render detail view to make sure it still renders
+ act(() => {
+ screen.getByText('Toggle Re-render').click();
+ });
+ expect(await screen.findByText('Toggle state: true')).toBeInTheDocument();
+ expect(await screen.findByText('Content 1')).toBeInTheDocument();
+
+ // Visit list view and see suspense fallback
+ act(() => {
+ screen.getByText('Home').click();
+ });
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ jest.useRealTimers();
+});
diff --git a/packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap b/packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap
index a9ce7cc40808..acba9b8bf676 100644
--- a/packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap
+++ b/packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap
@@ -6,3 +6,10 @@ exports[` should warn users about SSR with DataProvider 1`] =
See https://dataclient.io/docs/guides/ssr.",
]
`;
+
+exports[` should warn users about SSR with DataProvider 1`] = `
+[
+ "DataProvider from @data-client/react does not update while doing SSR.
+See https://dataclient.io/docs/guides/ssr.",
+]
+`;
diff --git a/packages/react/src/components/__tests__/__snapshots__/provider.tsx.snap b/packages/react/src/components/__tests__/__snapshots__/provider.tsx.snap
index f9dffeca4af4..e7435c407754 100644
--- a/packages/react/src/components/__tests__/__snapshots__/provider.tsx.snap
+++ b/packages/react/src/components/__tests__/__snapshots__/provider.tsx.snap
@@ -18,7 +18,7 @@ exports[` should change state 1`] = `
"CoolerArticle": {
"5": {
"date": 50,
- "expiresAt": 55,
+ "expiresAt": 60050,
"fetchedAt": 50,
},
},
@@ -28,7 +28,7 @@ exports[` should change state 1`] = `
"meta": {
"GET http://test.com/article-cooler/5": {
"date": 50,
- "expiresAt": 55,
+ "expiresAt": 60050,
"prevExpiresAt": undefined,
},
},
diff --git a/packages/react/src/components/__tests__/provider.node.tsx b/packages/react/src/components/__tests__/provider.node.tsx
index da6cc28e687e..4103c6cd728b 100644
--- a/packages/react/src/components/__tests__/provider.node.tsx
+++ b/packages/react/src/components/__tests__/provider.node.tsx
@@ -4,7 +4,7 @@ import { renderToString } from 'react-dom/server';
import DataProvider from '../DataProvider';
-describe('', () => {
+describe('', () => {
let warnspy: jest.SpyInstance;
beforeEach(() => {
warnspy = jest.spyOn(global.console, 'warn').mockImplementation(() => {});
diff --git a/packages/react/src/components/__tests__/provider.tsx b/packages/react/src/components/__tests__/provider.tsx
index 1a86483e0872..cf049ac2444f 100644
--- a/packages/react/src/components/__tests__/provider.tsx
+++ b/packages/react/src/components/__tests__/provider.tsx
@@ -1,11 +1,9 @@
// eslint-env jest
import {
NetworkManager,
- actionTypes,
Manager,
Middleware,
Controller,
- SetResponseAction,
} from '@data-client/core';
import { act, render } from '@testing-library/react';
import { CoolerArticleResource } from '__tests__/new';
@@ -18,8 +16,6 @@ import { payload } from '../../test-fixtures';
import DataProvider from '../DataProvider';
import { getDefaultManagers } from '../getDefaultManagers';
-const { SET_RESPONSE } = actionTypes;
-
describe('', () => {
let warnspy: jest.SpyInstance;
let debugspy: jest.SpyInstance;
@@ -123,11 +119,14 @@ describe('', () => {
expect(curDisp).not.toBe(dispatch);
expect(count).toBe(2);
});
+
it('should change state', () => {
- let dispatch: any, state;
+ jest.useFakeTimers({ now: 50 });
+ let ctrl: Controller | undefined = undefined;
+ let state;
let count = 0;
function ContextTester() {
- dispatch = useController().dispatch;
+ ctrl = useController();
state = useContext(StateContext);
count++;
return null;
@@ -135,25 +134,18 @@ describe('', () => {
const chil = ;
const tree = {chil};
render(tree);
- expect(dispatch).toBeDefined();
+ expect(ctrl).toBeDefined();
expect(state).toBeDefined();
- const action: SetResponseAction = {
- type: SET_RESPONSE,
- response: { id: 5, title: 'hi', content: 'more things here' },
- endpoint: CoolerArticleResource.get,
- args: [{ id: 5 }],
- key: CoolerArticleResource.get.key({ id: 5 }),
- meta: {
- fetchedAt: 50,
- date: 50,
- expiresAt: 55,
- },
- };
act(() => {
- dispatch(action);
+ ctrl?.setResponse(
+ CoolerArticleResource.get,
+ { id: 5 },
+ { id: 5, title: 'hi', content: 'more things here' },
+ );
});
expect(count).toBe(2);
expect(state).toMatchSnapshot();
+ jest.useRealTimers();
});
it('should ignore dispatches after unmount', async () => {
diff --git a/yarn.lock b/yarn.lock
index 157497ce5a84..d746d08e74f2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12,6 +12,13 @@ __metadata:
languageName: node
linkType: hard
+"@adobe/css-tools@npm:^4.4.0":
+ version: 4.4.1
+ resolution: "@adobe/css-tools@npm:4.4.1"
+ checksum: 10c0/1a68ad9af490f45fce7b6e50dd2d8ac0c546d74431649c0d42ee4ceb1a9fa057fae0a7ef1e148effa12d84ec00ed71869ebfe0fb1dcdcc80bfcb6048c12abcc0
+ languageName: node
+ linkType: hard
+
"@algolia/autocomplete-core@npm:1.17.7":
version: 1.17.7
resolution: "@algolia/autocomplete-core@npm:1.17.7"
@@ -6510,6 +6517,21 @@ __metadata:
languageName: node
linkType: hard
+"@testing-library/jest-dom@npm:^6.6.3":
+ version: 6.6.3
+ resolution: "@testing-library/jest-dom@npm:6.6.3"
+ dependencies:
+ "@adobe/css-tools": "npm:^4.4.0"
+ aria-query: "npm:^5.0.0"
+ chalk: "npm:^3.0.0"
+ css.escape: "npm:^1.5.1"
+ dom-accessibility-api: "npm:^0.6.3"
+ lodash: "npm:^4.17.21"
+ redent: "npm:^3.0.0"
+ checksum: 10c0/5566b6c0b7b0709bc244aec3aa3dc9e5f4663e8fb2b99d8cd456fc07279e59db6076cbf798f9d3099a98fca7ef4cd50e4e1f4c4dec5a60a8fad8d24a638a5bf6
+ languageName: node
+ linkType: hard
+
"@testing-library/react-hooks@npm:8.0.1":
version: 8.0.1
resolution: "@testing-library/react-hooks@npm:8.0.1"
@@ -8750,7 +8772,7 @@ __metadata:
languageName: node
linkType: hard
-"aria-query@npm:^5.3.2":
+"aria-query@npm:^5.0.0, aria-query@npm:^5.3.2":
version: 5.3.2
resolution: "aria-query@npm:5.3.2"
checksum: 10c0/003c7e3e2cff5540bf7a7893775fc614de82b0c5dde8ae823d47b7a28a9d4da1f7ed85f340bdb93d5649caa927755f0e31ecc7ab63edfdfc00c8ef07e505e03e
@@ -10243,6 +10265,16 @@ __metadata:
languageName: node
linkType: hard
+"chalk@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "chalk@npm:3.0.0"
+ dependencies:
+ ansi-styles: "npm:^4.1.0"
+ supports-color: "npm:^7.1.0"
+ checksum: 10c0/ee650b0a065b3d7a6fda258e75d3a86fc8e4effa55871da730a9e42ccb035bf5fd203525e5a1ef45ec2582ecc4f65b47eb11357c526b84dd29a14fb162c414d2
+ languageName: node
+ linkType: hard
+
"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
@@ -12927,6 +12959,13 @@ __metadata:
languageName: node
linkType: hard
+"dom-accessibility-api@npm:^0.6.3":
+ version: 0.6.3
+ resolution: "dom-accessibility-api@npm:0.6.3"
+ checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360
+ languageName: node
+ linkType: hard
+
"dom-converter@npm:^0.2.0":
version: 0.2.0
resolution: "dom-converter@npm:0.2.0"
@@ -26693,6 +26732,7 @@ __metadata:
"@react-navigation/native": "npm:^7.0.0"
"@react-navigation/native-stack": "npm:^7.0.0"
"@testing-library/dom": "npm:^10.4.0"
+ "@testing-library/jest-dom": "npm:^6.6.3"
"@testing-library/react": "npm:16.1.0"
"@testing-library/react-hooks": "npm:8.0.1"
"@testing-library/react-native": "npm:13.0.0"