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"