Skip to content

Commit c94c83f

Browse files
committed
enhance: RN specific GC sweep prioritizer
1 parent 67b2fba commit c94c83f

File tree

14 files changed

+235
-34
lines changed

14 files changed

+235
-34
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ jobs:
239239
240240
workflows:
241241
version: 2
242-
all-tests:
242+
validation:
243243
jobs:
244244
- setup
245245
- unit_tests:

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"packages/**/index.d.ts": true,
4949
"packages/*/lib": true,
5050
"packages/*/legacy": true,
51+
"packages/*/native": true,
5152
"yarn.lock": true,
5253
"**/versioned_docs": true,
5354
"**/*_versioned_docs": true,

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@react-navigation/native-stack": "^7.0.0",
6868
"@testing-library/dom": "^10.4.0",
6969
"@testing-library/jest-dom": "^6.6.3",
70+
"@testing-library/jest-native": "^5.4.3",
7071
"@testing-library/react": "16.1.0",
7172
"@testing-library/react-hooks": "8.0.1",
7273
"@testing-library/react-native": "13.0.0",

packages/core/src/state/GCPolicy.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,7 @@ export class GCPolicy implements GCInterface {
2424
this.controller = controller;
2525

2626
this.intervalId = setInterval(() => {
27-
if (typeof requestIdleCallback === 'function') {
28-
requestIdleCallback(() => this.runSweep(), { timeout: 1000 });
29-
} else {
30-
/* TODO: React native
31-
import { InteractionManager } from 'react-native';
32-
InteractionManager.runAfterInteractions(callback);
33-
if (options?.timeout) {
34-
InteractionManager.setDeadline(options.timeout);
35-
}
36-
*/
37-
this.runSweep();
38-
}
27+
this.idleCallback(() => this.runSweep(), { timeout: 1000 });
3928
}, this.options.intervalMS);
4029
}
4130

@@ -119,6 +108,21 @@ export class GCPolicy implements GCInterface {
119108
this.controller.dispatch({ type: GC, entities, endpoints });
120109
}
121110
}
111+
112+
/** Calls the callback when client is not 'busy' with high priority interaction tasks
113+
*
114+
* Override for platform-specific implementations
115+
*/
116+
protected idleCallback(
117+
callback: (...args: any[]) => void,
118+
options?: IdleRequestOptions,
119+
) {
120+
if (typeof requestIdleCallback === 'function') {
121+
requestIdleCallback(callback, options);
122+
} else {
123+
callback();
124+
}
125+
}
122126
}
123127

124128
export class ImmortalGCPolicy implements GCInterface {
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import {
2+
AsyncBoundary,
3+
DataProvider,
4+
GCPolicy,
5+
useSuspense,
6+
} from '@data-client/react';
7+
import { MockResolver } from '@data-client/test';
8+
import { render, screen, act, fireEvent } from '@testing-library/react-native';
9+
import { ArticleResource } from '__tests__/new';
10+
import { useState } from 'react';
11+
import '@testing-library/jest-native';
12+
import {
13+
View,
14+
Text,
15+
Button,
16+
TouchableOpacity,
17+
InteractionManager,
18+
} from 'react-native';
19+
20+
const mockGetList = jest.fn();
21+
const mockGet = jest.fn();
22+
const GC_INTERVAL = 5000;
23+
24+
const ListView = ({ onSelect }: { onSelect: (id: number) => void }) => {
25+
const articles = useSuspense(ArticleResource.getList);
26+
return (
27+
<View>
28+
{articles.map(article => (
29+
<TouchableOpacity
30+
key={article.id}
31+
onPress={() => onSelect(article.id ?? 0)}
32+
>
33+
<Text>{article.title}</Text>
34+
</TouchableOpacity>
35+
))}
36+
</View>
37+
);
38+
};
39+
40+
const DetailView = ({ id }: { id: number }) => {
41+
const article = useSuspense(ArticleResource.get, { id });
42+
const [toggle, setToggle] = useState(false);
43+
44+
return (
45+
<View>
46+
<Text>{article.title}</Text>
47+
<Text>{article.content}</Text>
48+
<Button title="Toggle Re-render" onPress={() => setToggle(!toggle)} />
49+
{toggle && <Text>Toggle state: {toggle.toString()}</Text>}
50+
</View>
51+
);
52+
};
53+
54+
const TestComponent = () => {
55+
const [view, setView] = useState<'list' | 'detail'>('list');
56+
const [selectedId, setSelectedId] = useState<number | null>(null);
57+
58+
return (
59+
<DataProvider gcPolicy={new GCPolicy({ intervalMS: GC_INTERVAL })}>
60+
<MockResolver
61+
fixtures={[
62+
{
63+
endpoint: ArticleResource.getList,
64+
response: mockGetList,
65+
delay: 100,
66+
},
67+
{ endpoint: ArticleResource.get, response: mockGet, delay: 100 },
68+
]}
69+
>
70+
<TouchableOpacity onPress={() => setView('list')}>
71+
<Text>Home</Text>
72+
</TouchableOpacity>
73+
<AsyncBoundary fallback={<Text>Loading...</Text>}>
74+
{view === 'list' ?
75+
<ListView
76+
onSelect={id => {
77+
setSelectedId(id);
78+
setView('detail');
79+
}}
80+
/>
81+
: selectedId !== null && <DetailView id={selectedId} />}
82+
</AsyncBoundary>
83+
</MockResolver>
84+
</DataProvider>
85+
);
86+
};
87+
88+
// Test cases
89+
describe('Integration Garbage Collection React Native', () => {
90+
it('should render list view and detail view correctly', async () => {
91+
jest.useFakeTimers();
92+
mockGetList.mockResolvedValue([
93+
{ id: 1, title: 'Article 1', content: 'Content 1' },
94+
{ id: 2, title: 'Article 2', content: 'Content 2' },
95+
]);
96+
mockGet.mockResolvedValue({
97+
id: 1,
98+
title: 'Article 1',
99+
content: 'Content 1',
100+
});
101+
102+
render(<TestComponent />);
103+
104+
// Initial render, should show list view
105+
expect(await screen.findByText('Article 1')).toBeTruthy();
106+
107+
// Switch to detail view
108+
act(() => {
109+
fireEvent.press(screen.getByText('Article 1'));
110+
});
111+
112+
// Detail view should render
113+
expect(await screen.findByText('Content 1')).toBeTruthy();
114+
115+
// Jest time pass to trigger sweep but not expired
116+
act(() => {
117+
jest.advanceTimersByTime(GC_INTERVAL);
118+
InteractionManager.setDeadline(0);
119+
});
120+
121+
// Switch back to list view
122+
act(() => {
123+
fireEvent.press(screen.getByText('Home'));
124+
});
125+
126+
// List view should instantly render
127+
expect(await screen.findByText('Article 1')).toBeTruthy();
128+
129+
// Switch back to detail view
130+
act(() => {
131+
fireEvent.press(screen.getByText('Article 1'));
132+
});
133+
134+
// Jest time pass to expiry
135+
act(() => {
136+
jest.advanceTimersByTime(
137+
ArticleResource.getList.dataExpiryLength ?? 60000,
138+
);
139+
InteractionManager.setDeadline(0);
140+
});
141+
142+
expect(await screen.findByText('Content 1')).toBeTruthy();
143+
144+
// Re-render detail view to make sure it still renders
145+
act(() => {
146+
fireEvent.press(screen.getByText('Toggle Re-render'));
147+
});
148+
expect(await screen.findByText('Toggle state: true')).toBeTruthy();
149+
expect(await screen.findByText('Content 1')).toBeTruthy();
150+
151+
// Visit list view and see suspense fallback
152+
act(() => {
153+
fireEvent.press(screen.getByText('Home'));
154+
});
155+
156+
expect(screen.getByText('Loading...')).toBeTruthy();
157+
jest.useRealTimers();
158+
});
159+
});

packages/react/src/__tests__/integration-garbage-collection.web.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,11 @@ const TestComponent = () => {
7575

7676
test('switch between list and detail view', async () => {
7777
jest.useFakeTimers();
78-
mockGetList.mockResolvedValue([
78+
mockGetList.mockReturnValue([
7979
{ id: 1, title: 'Article 1', content: 'Content 1' },
8080
{ id: 2, title: 'Article 2', content: 'Content 2' },
8181
]);
82-
mockGet.mockResolvedValue({
82+
mockGet.mockReturnValue({
8383
id: 1,
8484
title: 'Article 1',
8585
content: 'Content 1',

packages/react/src/components/DataProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
initialState as defaultState,
44
Controller as DataController,
55
applyManager,
6-
GCPolicy,
76
initManager,
87
} from '@data-client/core';
98
import type { State, Manager, GCInterface } from '@data-client/core';
@@ -17,6 +16,7 @@ import { SSR } from './LegacyReact.js';
1716
import { renderDevButton } from './renderDevButton.js';
1817
import { ControllerContext } from '../context.js';
1918
import { DevToolsManager } from '../managers/index.js';
19+
import GCPolicy from '../state/GCPolicy.js';
2020

2121
export interface ProviderProps {
2222
children: React.ReactNode;

packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`<BackupBoundary /> should warn users about SSR with DataProvider 1`] = `
4-
[
5-
"DataProvider from @data-client/react does not update while doing SSR.
6-
See https://dataclient.io/docs/guides/ssr.",
7-
]
8-
`;
9-
103
exports[`<DataProvider /> should warn users about SSR with DataProvider 1`] = `
114
[
125
"DataProvider from @data-client/react does not update while doing SSR.
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1-
export { default as useUniveralEffect } from './useFocusEffect';
1+
import type { DependencyList, EffectCallback } from 'react';
2+
3+
import { default as useFocusEffect } from './useFocusEffect';
4+
5+
export function useUniveralEffect(
6+
effect: EffectCallback,
7+
deps?: DependencyList,
8+
) {
9+
return useFocusEffect(effect, deps, true);
10+
}

packages/react/src/index.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,7 @@ Object.hasOwn =
33
/* istanbul ignore next */ function hasOwn(it, key) {
44
return Object.prototype.hasOwnProperty.call(it, key);
55
};
6-
export {
7-
Controller,
8-
ExpiryStatus,
9-
actionTypes,
10-
GCPolicy,
11-
} from '@data-client/core';
6+
export { Controller, ExpiryStatus, actionTypes } from '@data-client/core';
127
export type {
138
EndpointExtraOptions,
149
FetchFunction,
@@ -49,6 +44,7 @@ export type {
4944
DataClientDispatch,
5045
GenericDispatch,
5146
} from '@data-client/core';
47+
export { default as GCPolicy } from './state/GCPolicy.js';
5248
export * from './managers/index.js';
5349
export * from './components/index.js';
5450
export * from './hooks/index.js';

0 commit comments

Comments
 (0)