diff --git a/.vscode/settings.json b/.vscode/settings.json
index f83dad098228..38f726672216 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -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,
diff --git a/package.json b/package.json
index a165ec473dd3..017bad3ed85f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/core/src/state/GCPolicy.ts b/packages/core/src/state/GCPolicy.ts
index 9ed3dfb9fab4..9cc410b80eaf 100644
--- a/packages/core/src/state/GCPolicy.ts
+++ b/packages/core/src/state/GCPolicy.ts
@@ -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);
}
@@ -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 {
diff --git a/packages/core/src/state/reducer/createReducer.ts b/packages/core/src/state/reducer/createReducer.ts
index e0c1b839d93b..c53c971290b0 100644
--- a/packages/core/src/state/reducer/createReducer.ts
+++ b/packages/core/src/state/reducer/createReducer.ts
@@ -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];
diff --git a/packages/react/src/__tests__/integration-garbage-collection.native.tsx b/packages/react/src/__tests__/integration-garbage-collection.native.tsx
new file mode 100644
index 000000000000..9ff1b5dbbba8
--- /dev/null
+++ b/packages/react/src/__tests__/integration-garbage-collection.native.tsx
@@ -0,0 +1,150 @@
+import {
+ AsyncBoundary,
+ DataProvider,
+ GCPolicy,
+ useSuspense,
+} from '@data-client/react';
+import { MockResolver } from '@data-client/test';
+import { render, screen, act, fireEvent } 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 (
+
+ {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}
+
+ );
+};
+
+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 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();
+
+ // Initial render, should show list view
+ expect(await screen.findByText('Article 1')).toBeTruthy();
+
+ // Switch to detail view
+ act(() => {
+ fireEvent.press(screen.getByText('Article 1'));
+ });
+
+ // 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(() => {
+ fireEvent.press(screen.getByText('Home'));
+ });
+
+ // List view should instantly render
+ expect(await screen.findByText('Article 1')).toBeTruthy();
+
+ // Switch back to detail view
+ act(() => {
+ fireEvent.press(screen.getByText('Article 1'));
+ });
+
+ // 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(() => {
+ fireEvent.press(screen.getByText('Toggle Re-render'));
+ });
+ expect(await screen.findByText('Toggle state: true')).toBeTruthy();
+ expect(await screen.findByText('Content 1')).toBeTruthy();
+
+ // Visit list view and see suspense fallback
+ act(() => {
+ fireEvent.press(screen.getByText('Home'));
+ });
+
+ expect(screen.getByText('Loading...')).toBeTruthy();
+ jest.useRealTimers();
+ });
+});
diff --git a/packages/react/src/components/DataProvider.tsx b/packages/react/src/components/DataProvider.tsx
index 577e01876de9..0104e0244ab2 100644
--- a/packages/react/src/components/DataProvider.tsx
+++ b/packages/react/src/components/DataProvider.tsx
@@ -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';
@@ -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;
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 acba9b8bf676..b2ba172118eb 100644
--- a/packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap
+++ b/packages/react/src/components/__tests__/__snapshots__/provider.node.tsx.snap
@@ -1,12 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-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.",
-]
-`;
-
exports[` should warn users about SSR with DataProvider 1`] = `
[
"DataProvider from @data-client/react does not update while doing SSR.
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 8ed82cd20dea..a12941e66e1d 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -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,
@@ -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';
diff --git a/packages/react/src/server/redux/prepareStore.tsx b/packages/react/src/server/redux/prepareStore.tsx
index 0e4d2fde7505..ebab7faa9c51 100644
--- a/packages/react/src/server/redux/prepareStore.tsx
+++ b/packages/react/src/server/redux/prepareStore.tsx
@@ -5,7 +5,6 @@ import {
ActionTypes,
createReducer,
applyManager,
- GCPolicy,
GCInterface,
} from '@data-client/core';
@@ -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 = {},
diff --git a/packages/react/src/state/GCPolicy.native.ts b/packages/react/src/state/GCPolicy.native.ts
new file mode 100644
index 000000000000..ef657929d5d5
--- /dev/null
+++ b/packages/react/src/state/GCPolicy.native.ts
@@ -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);
+ }
+ }
+}
diff --git a/packages/react/src/state/GCPolicy.ts b/packages/react/src/state/GCPolicy.ts
new file mode 100644
index 000000000000..a16154463bfa
--- /dev/null
+++ b/packages/react/src/state/GCPolicy.ts
@@ -0,0 +1 @@
+export { GCPolicy as default } from '@data-client/core';
diff --git a/yarn.lock b/yarn.lock
index d746d08e74f2..f08a45db93e8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"
@@ -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:
@@ -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:
@@ -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:
@@ -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"