diff --git a/packages/jest-preset/jest/mocks/useAppState.js b/packages/jest-preset/jest/mocks/useAppState.js new file mode 100644 index 000000000000..f9ebbb3d1e45 --- /dev/null +++ b/packages/jest-preset/jest/mocks/useAppState.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {AppStateStatus} from 'react-native/Libraries/AppState/AppState'; + +const useAppState = jest.fn(() => 'active') as JestMockFn< + [], + AppStateStatus, +>; + +export default useAppState; diff --git a/packages/jest-preset/jest/setup.js b/packages/jest-preset/jest/setup.js index 29f86698a129..15bb597e1a5b 100644 --- a/packages/jest-preset/jest/setup.js +++ b/packages/jest-preset/jest/setup.js @@ -91,6 +91,11 @@ mock( // $FlowFixMe[incompatible-type] - `./mocks/AppState` is incomplete. 'm#./mocks/AppState', ); +mock( + 'm#react-native/Libraries/AppState/useAppState', + // $FlowFixMe[react-rule-hook-incompatible] + 'm#./mocks/useAppState', +); mock( 'm#react-native/Libraries/BatchedBridge/NativeModules', 'm#./mocks/NativeModules', diff --git a/packages/react-native/Libraries/AppState/__tests__/useAppState-test.js b/packages/react-native/Libraries/AppState/__tests__/useAppState-test.js new file mode 100644 index 000000000000..6b9413ce231e --- /dev/null +++ b/packages/react-native/Libraries/AppState/__tests__/useAppState-test.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import useAppState from '../useAppState'; + +describe('useAppState', () => { + it('should return a mocked active state by default', () => { + expect(jest.isMockFunction(useAppState)).toBe(true); + // $FlowFixMe[react-rule-hook] + expect(useAppState()).toBe('active'); + }); + + it('should have console.error when not using mock', () => { + const useAppStateActual = jest.requireActual<{ + default: typeof useAppState, + }>('../useAppState').default; + const spy = jest.spyOn(console, 'error').mockImplementationOnce(() => { + throw new Error('console.error() was called'); + }); + + expect(() => { + // $FlowFixMe[react-rule-hook] + useAppStateActual(); + }).toThrow(); + + expect(spy).toHaveBeenCalledWith( + expect.stringMatching( + /Invalid hook call. Hooks can only be called inside of the body of a function component./, + ), + ); + }); +}); diff --git a/packages/react-native/Libraries/AppState/useAppState.d.ts b/packages/react-native/Libraries/AppState/useAppState.d.ts new file mode 100644 index 000000000000..abf529fe9b53 --- /dev/null +++ b/packages/react-native/Libraries/AppState/useAppState.d.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import type {AppStateStatus} from './AppState'; + +/** + * `useAppState` is a React hook that returns the current app state. + * The component will re-render whenever the app state changes. + * + * Returns one of: 'active', 'background', 'inactive', 'unknown', or 'extension'. + * + * @see https://reactnative.dev/docs/appstate + */ +export default function useAppState(): AppStateStatus; diff --git a/packages/react-native/Libraries/AppState/useAppState.js b/packages/react-native/Libraries/AppState/useAppState.js new file mode 100644 index 000000000000..f4a94e64e76f --- /dev/null +++ b/packages/react-native/Libraries/AppState/useAppState.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {AppStateStatus} from './AppState'; + +import AppState from './AppState'; +import {useSyncExternalStore} from 'react'; + +const subscribe = (onStoreChange: () => void) => { + const subscription = AppState.addEventListener('change', onStoreChange); + return () => subscription.remove(); +}; + +const getSnapshot = (): AppStateStatus => { + return AppState.currentState ?? 'unknown'; +}; + +/** + * `useAppState` is a React hook that returns the current app state. + * + * The value will be one of: + * - `active` - The app is running in the foreground + * - `background` - The app is running in the background + * - `inactive` - (iOS only) Transitioning between foreground and background + * - `unknown` - The initial state before the app state is determined + * + * The hook automatically subscribes to app state changes and re-renders the + * component when the state changes. + * + * Usage: + * ``` + * function MyComponent() { + * const appState = useAppState(); + * + * useEffect(() => { + * if (appState === 'active') { + * // App came to foreground - refresh data + * } + * }, [appState]); + * + * return App is {appState}; + * } + * ``` + * + * See https://reactnative.dev/docs/appstate + */ +export default function useAppState(): AppStateStatus { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/packages/react-native/index.js b/packages/react-native/index.js index baa4d5b51bff..63ad8ee94e9b 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -375,6 +375,9 @@ module.exports = { get useAnimatedColor() { return require('./Libraries/Animated/useAnimatedColor').default; }, + get useAppState() { + return require('./Libraries/AppState/useAppState').default; + }, get useColorScheme() { return require('./Libraries/Utilities/useColorScheme').default; }, diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index fcb3ffc5ca4b..9389aa33ca95 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -414,6 +414,7 @@ export type { EventHandlers as PressabilityEventHandlers, } from './Libraries/Pressability/Pressability'; export {default as usePressability} from './Libraries/Pressability/usePressability'; +export {default as useAppState} from './Libraries/AppState/useAppState'; export {default as useColorScheme} from './Libraries/Utilities/useColorScheme'; export {default as useWindowDimensions} from './Libraries/Utilities/useWindowDimensions'; export {default as UTFSequence} from './Libraries/UTFSequence';