Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions .changeset/react-native-087-idle-callback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@data-client/react': patch
---

Fix React Native 0.87 support

React Native 0.87 removed `InteractionManager`, so using `@data-client/react` there threw
"InteractionManager has been removed from react-native core" in development. Low-priority work now runs through
`requestIdleCallback`: refetches when a screen regains focus in [useSuspense()](https://dataclient.io/docs/api/useSuspense),
[useFetch()](https://dataclient.io/docs/api/useFetch) and [useDLE()](https://dataclient.io/docs/api/useDLE),
sweeps by `GCPolicy`, and fetches by
[IdlingNetworkManager](https://dataclient.io/docs/api/getDefaultManagers#manager-inheritance). This works on every
supported React Native version.
2 changes: 1 addition & 1 deletion docs/core/api/getDefaultManagers.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ const managers = getDefaultManagers({
```

`IdlingNetworkManager` can prevent stuttering by delaying [sideEffect](/rest/api/Endpoint#sideeffect)-free (read-only/GET) fetches
until animations are complete. This works in web using [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback), and react native using InteractionManager.runAfterInteractions.
until animations are complete. This works in both web and React Native using [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback).

:::

Expand Down
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ const projects = [
resolver: require.resolve('@react-native/jest-preset/jest/resolver.js'),
moduleNameMapper: {
...baseConfig.moduleNameMapper,
'^react-native/setup-env$': `${path.dirname(
require.resolve('react-native/package.json'),
)}/src/setup-env.js`,
'^react-native($|/.*)': `${path.dirname(
require.resolve('react-native/package.json'),
)}$1`,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"@data-client/react": "workspace:*",
"@data-client/rest": "workspace:*",
"@data-client/test": "workspace:*",
"@react-native/jest-preset": "0.86.2",
"@react-native/jest-preset": "0.87.1",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@testing-library/dom": "^10.4.0",
Expand Down Expand Up @@ -107,7 +107,7 @@
"prettier": "3.9.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native": "0.87.1",
"react-native-safe-area-context": "^5.0.0",
"react-native-screens": "^4.1.0",
"react-test-renderer": "19.2.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
"qs": "^6.14.2",
"react": "19.2.3",
"react-dom": "^19.0.0",
"react-native": "0.86.2",
"react-native": "0.87.1",
"react-native-safe-area-context": "^5.0.0",
"react-native-screens": "^4.1.0",
"react-test-renderer": "^19.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,7 @@ 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 {
View,
Text,
Button,
TouchableOpacity,
InteractionManager,
} from 'react-native';
import { View, Text, Button, TouchableOpacity } from 'react-native';

const mockGetList = jest.fn();
const mockGet = jest.fn();
Expand Down Expand Up @@ -114,7 +108,6 @@ describe('Integration Garbage Collection React Native', () => {

await act(async () => {
jest.advanceTimersByTime(1000);
InteractionManager.setDeadline(0);
await jest.runOnlyPendingTimersAsync();
});

Expand All @@ -132,7 +125,6 @@ describe('Integration Garbage Collection React Native', () => {
// Jest time pass to trigger sweep but not expired
act(() => {
jest.advanceTimersByTime(GC_INTERVAL);
InteractionManager.setDeadline(0);
});

// Switch back to list view
Expand All @@ -156,7 +148,6 @@ describe('Integration Garbage Collection React Native', () => {
GC_INTERVAL,
),
);
InteractionManager.setDeadline(0);
await jest.runOnlyPendingTimersAsync();
});

Expand Down
57 changes: 57 additions & 0 deletions packages/react/src/hooks/__tests__/runWhenIdle.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import runWhenIdle from '../runWhenIdle.native';

describe('runWhenIdle', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
delete (global as any).requestIdleCallback;
delete (global as any).cancelIdleCallback;
jest.useRealTimers();
});

describe('with requestIdleCallback', () => {
beforeEach(() => {
(global as any).requestIdleCallback = jest.fn((cb: () => void) =>
setTimeout(cb, 0),
);
(global as any).cancelIdleCallback = jest.fn(clearTimeout);
});

it('should defer callback until idle', () => {
const fn = jest.fn();
runWhenIdle(fn);
expect((global as any).requestIdleCallback).toHaveBeenCalledWith(fn);
expect(fn).not.toHaveBeenCalled();
jest.runAllTimers();
expect(fn).toHaveBeenCalledTimes(1);
});

it('should cancel with cancelIdleCallback', () => {
const fn = jest.fn();
const cancel = runWhenIdle(fn);
cancel();
expect((global as any).cancelIdleCallback).toHaveBeenCalled();
jest.runAllTimers();
expect(fn).not.toHaveBeenCalled();
});
});

describe('without requestIdleCallback', () => {
it('should defer callback to a timeout', () => {
const fn = jest.fn();
runWhenIdle(fn);
expect(fn).not.toHaveBeenCalled();
jest.runAllTimers();
expect(fn).toHaveBeenCalledTimes(1);
});

it('should cancel pending timeout', () => {
const fn = jest.fn();
const cancel = runWhenIdle(fn);
cancel();
jest.runAllTimers();
expect(fn).not.toHaveBeenCalled();
});
});
});
14 changes: 3 additions & 11 deletions packages/react/src/hooks/__tests__/useDLE.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { createEntityMeta } from '__tests__/utils';
import nock from 'nock';
import React, { Suspense } from 'react';
// relative imports to avoid circular dependency in tsconfig references
import { InteractionManager, Text, View } from 'react-native';
import { Text, View } from 'react-native';

import { AsyncBoundary } from '../..';
import { StateContext, ControllerContext } from '../../context';
Expand Down Expand Up @@ -276,22 +276,14 @@ describe('useDLE', () => {

const { getByText, getByTestId } = render(tree);
expect(fbmock).not.toHaveBeenCalled();
await new Promise(resolve =>
InteractionManager.runAfterInteractions(() => {
resolve(null);
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
// still should revalidate
expect(dispatch.mock.calls.length).toBe(1);
act(() => thenavigation.navigate('Home'));
expect(getByText('Home')).toBeDefined();

act(() => thenavigation.goBack());
await new Promise(resolve =>
InteractionManager.runAfterInteractions(() => {
resolve(null);
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
expect(getByTestId('article')).toBeDefined();
// since we got focus back we should have called again
expect(dispatch.mock.calls.length).toBe(2);
Expand Down
14 changes: 3 additions & 11 deletions packages/react/src/hooks/__tests__/useFetch.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { createEntityMeta } from '__tests__/utils';
import nock from 'nock';
import React, { Suspense } from 'react';
// relative imports to avoid circular dependency in tsconfig references
import { InteractionManager, Text, View } from 'react-native';
import { Text, View } from 'react-native';

import { AsyncBoundary } from '../..';
import { StateContext, ControllerContext } from '../../context';
Expand Down Expand Up @@ -309,22 +309,14 @@ describe('useFetch', () => {

const { getByText, getByTestId } = render(tree);
expect(fbmock).not.toHaveBeenCalled();
await new Promise(resolve =>
InteractionManager.runAfterInteractions(() => {
resolve(null);
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
// still should revalidate
expect(dispatch.mock.calls.length).toBe(1);
act(() => thenavigation.navigate('Home'));
expect(getByText('Home')).toBeDefined();

act(() => thenavigation.goBack());
await new Promise(resolve =>
InteractionManager.runAfterInteractions(() => {
resolve(null);
}),
);
await new Promise(resolve => setTimeout(resolve, 0));

// since we got focus back we should have called again
expect(dispatch.mock.calls.length).toBe(2);
Expand Down
13 changes: 2 additions & 11 deletions packages/react/src/hooks/__tests__/useSuspense.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import { createEntityMeta } from '__tests__/utils';
import nock from 'nock';
import React, { Suspense } from 'react';
import { Text, View } from 'react-native';
import { InteractionManager } from 'react-native';
import { Temporal } from 'temporal-polyfill';

// relative imports to avoid circular dependency in tsconfig references
Expand Down Expand Up @@ -287,22 +286,14 @@ describe('useSuspense()', () => {

const { getByText, getByTestId } = render(tree);
expect(fbmock).not.toHaveBeenCalled();
await new Promise(resolve =>
InteractionManager.runAfterInteractions(() => {
resolve(null);
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
// still should revalidate
expect(dispatch.mock.calls.length).toBe(1);
act(() => thenavigation.navigate('Home'));
expect(getByText('Home')).toBeDefined();

act(() => thenavigation.goBack());
await new Promise(resolve =>
InteractionManager.runAfterInteractions(() => {
resolve(null);
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
expect(getByTestId('article')).toBeDefined();
// since we got focus back we should have called again
expect(dispatch.mock.calls.length).toBe(2);
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/hooks/runWhenIdle.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Defers low-priority work until the JS thread is idle
*
* @returns cancels the callback if it has not yet run
*/
export default function runWhenIdle(callback: () => void): () => void {
// @react-native/jest-preset runs in a plain Node environment without requestIdleCallback
if (typeof requestIdleCallback === 'function') {
const handle = requestIdleCallback(callback);
return () => cancelIdleCallback(handle);
}
const handle = setTimeout(callback, 0);
return () => clearTimeout(handle);
}
6 changes: 2 additions & 4 deletions packages/react/src/hooks/useDLE.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import type {
} from '@data-client/core';
import { ExpiryStatus } from '@data-client/core';
import { useEffect, useMemo } from 'react';
import { InteractionManager } from 'react-native';

import runWhenIdle from './runWhenIdle.native.js';
import useCacheState from './useCacheState.js';
import useController from './useController.js';
import useFocusEffect from './useFocusEffect.native.js';
Expand Down Expand Up @@ -119,13 +119,11 @@ export default function useDLE<

useFocusEffect(() => {
// revalidating non-suspending data is low priority, so make sure it doesn't stutter animations
const task = InteractionManager.runAfterInteractions(() => {
return runWhenIdle(() => {
if (Date.now() > expiresAt && key) {
controller.fetch(endpoint, ...(args as Parameters<E>));
}
});

return () => task.cancel();
}, []);

const data = useMemo(() => {
Expand Down
6 changes: 2 additions & 4 deletions packages/react/src/hooks/useFetch.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import type {
ResolveType,
} from '@data-client/core';
import { useEffect, useMemo } from 'react';
import { InteractionManager } from 'react-native';

import runWhenIdle from './runWhenIdle.native.js';
import {
UsablePromise,
createFulfilled,
Expand Down Expand Up @@ -109,13 +109,11 @@ export default function useFetch<

useFocusEffect(() => {
// revalidating non-suspending data is low priority, so make sure it doesn't stutter animations
const task = InteractionManager.runAfterInteractions(() => {
return runWhenIdle(() => {
if (Date.now() > expiresAt && key) {
controller.fetch(endpoint, ...(args as Parameters<E>));
}
});

return () => task.cancel();
}, []);

if (!key) return undefined;
Expand Down
6 changes: 2 additions & 4 deletions packages/react/src/hooks/useSuspense.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import type {
NI,
} from '@data-client/core';
import { useEffect, useMemo } from 'react';
import { InteractionManager } from 'react-native';

import runWhenIdle from './runWhenIdle.native.js';
import useCacheState from './useCacheState.js';
import useController from './useController.js';
import useFocusEffect from './useFocusEffect.native.js';
Expand Down Expand Up @@ -106,13 +106,11 @@ export default function useSuspense<

useFocusEffect(() => {
// revalidating non-suspending data is low priority, so make sure it doesn't stutter animations
const task = InteractionManager.runAfterInteractions(() => {
return runWhenIdle(() => {
if (Date.now() > expiresAt && key) {
controller.fetch(endpoint, ...(args as Parameters<E>));
}
});

return () => task.cancel();
}, []);

// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
19 changes: 0 additions & 19 deletions packages/react/src/managers/IdlingNetworkManager.native.ts

This file was deleted.

Loading
Loading