Skip to content

Commit

Permalink
enhance: RN specific GC sweep prioritizer
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed Jan 5, 2025
1 parent 67b2fba commit 61a0ffb
Show file tree
Hide file tree
Showing 12 changed files with 214 additions and 30 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"packages/**/index.d.ts": true,
"packages/*/lib": true,
"packages/*/legacy": true,
"packages/*/native": true,
"yarn.lock": true,
"**/versioned_docs": true,
"**/*_versioned_docs": true,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"@react-navigation/native-stack": "^7.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react": "16.1.0",
"@testing-library/react-hooks": "8.0.1",
"@testing-library/react-native": "13.0.0",
Expand Down
28 changes: 16 additions & 12 deletions packages/core/src/state/GCPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,7 @@ export class GCPolicy implements GCInterface {
this.controller = controller;

this.intervalId = setInterval(() => {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => this.runSweep(), { timeout: 1000 });
} else {
/* TODO: React native
import { InteractionManager } from 'react-native';
InteractionManager.runAfterInteractions(callback);
if (options?.timeout) {
InteractionManager.setDeadline(options.timeout);
}
*/
this.runSweep();
}
this.idleCallback(() => this.runSweep(), { timeout: 1000 });
}, this.options.intervalMS);
}

Expand Down Expand Up @@ -119,6 +108,21 @@ export class GCPolicy implements GCInterface {
this.controller.dispatch({ type: GC, entities, endpoints });
}
}

/** Calls the callback when client is not 'busy' with high priority interaction tasks
*
* Override for platform-specific implementations
*/
protected idleCallback(
callback: (...args: any[]) => void,
options?: IdleRequestOptions,
) {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(callback, options);
} else {
callback();
}
}
}

export class ImmortalGCPolicy implements GCInterface {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/state/reducer/createReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default function createReducer(controller: Controller): ReducerType {
if (!state) state = initialState;
switch (action.type) {
case GC:
console.log('GC action', action.endpoints, action.entities);
// inline deletes are fine as these should have 0 refcounts
action.entities.forEach(({ key, pk }) => {
delete (state as any).entities[key]?.[pk];
Expand Down
150 changes: 150 additions & 0 deletions packages/react/src/__tests__/integration-garbage-collection.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
AsyncBoundary,
DataProvider,
GCPolicy,
useSuspense,
} from '@data-client/react';
import { MockResolver } from '@data-client/test';
import { render, screen, act } from '@testing-library/react-native';
import { ArticleResource } from '__tests__/new';
import { useState } from 'react';
import '@testing-library/jest-native';
import { View, Text, Button, TouchableOpacity } from 'react-native';

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 (
<View>
{articles.map(article => (
<TouchableOpacity
key={article.id}
onPress={() => onSelect(article.id ?? 0)}
>
<Text>{article.title}</Text>
</TouchableOpacity>
))}
</View>
);
};

const DetailView = ({ id }: { id: number }) => {
const article = useSuspense(ArticleResource.get, { id });
const [toggle, setToggle] = useState(false);

return (
<View>
<Text>{article.title}</Text>
<Text>{article.content}</Text>
<Button title="Toggle Re-render" onPress={() => setToggle(!toggle)} />
{toggle && <Text>Toggle state: {toggle.toString()}</Text>}
</View>
);
};

const TestComponent = () => {
const [view, setView] = useState<'list' | 'detail'>('list');
const [selectedId, setSelectedId] = useState<number | null>(null);

return (
<DataProvider gcPolicy={new GCPolicy({ intervalMS: GC_INTERVAL })}>
<MockResolver
fixtures={[
{
endpoint: ArticleResource.getList,
response: mockGetList,
delay: 100,
},
{ endpoint: ArticleResource.get, response: mockGet, delay: 100 },
]}
>
<TouchableOpacity onPress={() => setView('list')}>
<Text>Home</Text>
</TouchableOpacity>
<AsyncBoundary fallback={<Text>Loading...</Text>}>
{view === 'list' ?
<ListView
onSelect={id => {
setSelectedId(id);
setView('detail');
}}
/>
: selectedId !== null && <DetailView id={selectedId} />}
</AsyncBoundary>
</MockResolver>
</DataProvider>
);
};

// Test cases
describe('Integration Garbage Collection React Native', () => {
it('should render list view and detail view correctly', 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(<TestComponent />);

// Initial render, should show list view
expect(await screen.findByText('Article 1')).toBeTruthy();

// Switch to detail view
act(() => {
screen.getByText('Article 1').props.onPress();
});

// Detail view should render
expect(await screen.findByText('Content 1')).toBeTruthy();

// Jest time pass to trigger sweep but not expired
act(() => {
jest.advanceTimersByTime(GC_INTERVAL);
});

// Switch back to list view
act(() => {
screen.getByText('Home').props.onPress();
});

// List view should instantly render
expect(await screen.findByText('Article 1')).toBeTruthy();

// Switch back to detail view
act(() => {
screen.getByText('Article 1').props.onPress();
});

// Jest time pass to expiry
act(() => {
jest.advanceTimersByTime(
ArticleResource.getList.dataExpiryLength ?? 60000,
);
});
expect(await screen.findByText('Content 1')).toBeTruthy();

// Re-render detail view to make sure it still renders
act(() => {
screen.getByText('Toggle Re-render').props.onPress();
});
expect(await screen.findByText('Toggle state: true')).toBeTruthy();
expect(await screen.findByText('Content 1')).toBeTruthy();

// Visit list view and see suspense fallback
act(() => {
screen.getByText('Home').props.onPress();
});

expect(screen.getByText('Loading...')).toBeTruthy();
jest.useRealTimers();
});
});
2 changes: 1 addition & 1 deletion packages/react/src/components/DataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
initialState as defaultState,
Controller as DataController,
applyManager,
GCPolicy,
initManager,
} from '@data-client/core';
import type { State, Manager, GCInterface } from '@data-client/core';
Expand All @@ -17,6 +16,7 @@ import { SSR } from './LegacyReact.js';
import { renderDevButton } from './renderDevButton.js';
import { ControllerContext } from '../context.js';
import { DevToolsManager } from '../managers/index.js';
import GCPolicy from '../state/GCPolicy.js';

export interface ProviderProps {
children: React.ReactNode;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<BackupBoundary /> 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.",
]
`;

exports[`<DataProvider /> should warn users about SSR with DataProvider 1`] = `
[
"DataProvider from @data-client/react does not update while doing SSR.
Expand Down
8 changes: 2 additions & 6 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@ Object.hasOwn =
/* istanbul ignore next */ function hasOwn(it, key) {
return Object.prototype.hasOwnProperty.call(it, key);
};
export {
Controller,
ExpiryStatus,
actionTypes,
GCPolicy,
} from '@data-client/core';
export { Controller, ExpiryStatus, actionTypes } from '@data-client/core';
export type {
EndpointExtraOptions,
FetchFunction,
Expand Down Expand Up @@ -49,6 +44,7 @@ export type {
DataClientDispatch,
GenericDispatch,
} from '@data-client/core';
export { default as GCPolicy } from './state/GCPolicy.js';
export * from './managers/index.js';
export * from './components/index.js';
export * from './hooks/index.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/server/redux/prepareStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
ActionTypes,
createReducer,
applyManager,
GCPolicy,
GCInterface,
} from '@data-client/core';

Expand All @@ -15,6 +14,7 @@ import { default as PromiseifyMiddleware } from './PromiseifyMiddleware.js';
import { createStore, applyMiddleware } from './redux.js';
import type { Reducer, Middleware } from './redux.js';
import type { Store } from '../../context.js';
import GCPolicy from '../../state/GCPolicy.js';

export function prepareStore<
R extends ReducersMapObject<any, ActionTypes> = {},
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/state/GCPolicy.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { GCPolicy } from '@data-client/core';
import { InteractionManager } from 'react-native';

/** Can help prevent stuttering by waiting for idle before performing GC sweeps */
export default class NativeGCPolicy extends GCPolicy {
/** Calls the callback when client is not 'busy' with high priority interaction tasks
*
* Override for platform-specific implementations
*/
protected idleCallback(
callback: (...args: any[]) => void,
options?: IdleRequestOptions,
) {
InteractionManager.runAfterInteractions(callback);
if (options?.timeout) {
InteractionManager.setDeadline(options.timeout);
}
}
}
1 change: 1 addition & 0 deletions packages/react/src/state/GCPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GCPolicy as default } from '@data-client/core';
24 changes: 21 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6532,6 +6532,23 @@ __metadata:
languageName: node
linkType: hard

"@testing-library/jest-native@npm:^5.4.3":
version: 5.4.3
resolution: "@testing-library/jest-native@npm:5.4.3"
dependencies:
chalk: "npm:^4.1.2"
jest-diff: "npm:^29.0.1"
jest-matcher-utils: "npm:^29.0.1"
pretty-format: "npm:^29.0.3"
redent: "npm:^3.0.0"
peerDependencies:
react: ">=16.0.0"
react-native: ">=0.59"
react-test-renderer: ">=16.0.0"
checksum: 10c0/9315989dc0377a778f7ec9317b98c1dab9a7e2ebe0ca20a5c75f9dba3a4b486c02452b4399dd1aeef9ebf0007247594c6357f62089f095fde5280ce0344f5e65
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"
Expand Down Expand Up @@ -18065,7 +18082,7 @@ __metadata:
languageName: node
linkType: hard

"jest-diff@npm:^29.7.0":
"jest-diff@npm:^29.0.1, jest-diff@npm:^29.7.0":
version: 29.7.0
resolution: "jest-diff@npm:29.7.0"
dependencies:
Expand Down Expand Up @@ -18174,7 +18191,7 @@ __metadata:
languageName: node
linkType: hard

"jest-matcher-utils@npm:^29.7.0":
"jest-matcher-utils@npm:^29.0.1, jest-matcher-utils@npm:^29.7.0":
version: 29.7.0
resolution: "jest-matcher-utils@npm:29.7.0"
dependencies:
Expand Down Expand Up @@ -24705,7 +24722,7 @@ __metadata:
languageName: node
linkType: hard

"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0":
"pretty-format@npm:^29.0.0, pretty-format@npm:^29.0.3, pretty-format@npm:^29.7.0":
version: 29.7.0
resolution: "pretty-format@npm:29.7.0"
dependencies:
Expand Down Expand Up @@ -26733,6 +26750,7 @@ __metadata:
"@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/jest-native": "npm:^5.4.3"
"@testing-library/react": "npm:16.1.0"
"@testing-library/react-hooks": "npm:8.0.1"
"@testing-library/react-native": "npm:13.0.0"
Expand Down

0 comments on commit 61a0ffb

Please sign in to comment.