diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index 09fd6a7b1..f98d037e5 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -50,6 +50,7 @@ "event-target-shim": "^6.0.2" }, "devDependencies": { + "@launchdarkly/private-js-mocks": "0.0.1", "@testing-library/react": "^14.1.2", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.11", diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index 06ac9e452..2e121f49c 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -335,6 +335,10 @@ export default class EventSource { this.dispatch('close', { type: 'close' }); } + getStatus() { + return this.status; + } + onopen() {} onclose() {} onerror(_err: any) {} diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index 7702f0aa1..fb87893fb 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -22,15 +22,19 @@ import { ldApplication, ldDevice } from './autoEnv'; import AsyncStorage from './ConditionalAsyncStorage'; import PlatformCrypto from './crypto'; -class PlatformRequests implements Requests { +export class PlatformRequests implements Requests { + eventSource?: RNEventSource; + constructor(private readonly logger: LDLogger) {} createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { - return new RNEventSource(url, { + this.eventSource = new RNEventSource(url, { headers: eventSourceInitDict.headers, retryAndHandleError: eventSourceInitDict.errorFilter, logger: this.logger, }); + + return this.eventSource; } fetch(url: string, options?: Options): Promise { @@ -38,6 +42,7 @@ class PlatformRequests implements Requests { return fetch(url, options); } } + class PlatformEncoding implements Encoding { btoa(data: string): string { return btoa(data); diff --git a/packages/sdk/react-native/src/provider/LDProvider.test.tsx b/packages/sdk/react-native/src/provider/LDProvider.test.tsx index 548aec1e8..6df9625ce 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.test.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.test.tsx @@ -7,8 +7,9 @@ import ReactNativeLDClient from '../ReactNativeLDClient'; import LDProvider from './LDProvider'; import setupListeners from './setupListeners'; -jest.mock('./setupListeners'); +jest.mock('../provider/useAppState'); jest.mock('../ReactNativeLDClient'); +jest.mock('./setupListeners'); const TestApp = () => { const ldClient = useLDClient(); diff --git a/packages/sdk/react-native/src/provider/LDProvider.tsx b/packages/sdk/react-native/src/provider/LDProvider.tsx index 18396bbec..0cb3ddb8b 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.tsx @@ -5,6 +5,7 @@ import { type LDContext } from '@launchdarkly/js-client-sdk-common'; import ReactNativeLDClient from '../ReactNativeLDClient'; import { Provider, ReactContext } from './reactContext'; import setupListeners from './setupListeners'; +import useAppState from './useAppState'; type LDProps = { client: ReactNativeLDClient; @@ -38,6 +39,8 @@ const LDProvider = ({ client, context, children }: PropsWithChildren) = } }, []); + useAppState(client); + return {children}; }; diff --git a/packages/sdk/react-native/src/provider/useAppState.test.ts b/packages/sdk/react-native/src/provider/useAppState.test.ts new file mode 100644 index 000000000..6bb3d1eca --- /dev/null +++ b/packages/sdk/react-native/src/provider/useAppState.test.ts @@ -0,0 +1,114 @@ +import { renderHook } from '@testing-library/react'; +import React, { useRef } from 'react'; +import { AppState } from 'react-native'; + +import { AutoEnvAttributes, debounce } from '@launchdarkly/js-client-sdk-common'; +import { logger } from '@launchdarkly/private-js-mocks'; + +import EventSource from '../fromExternal/react-native-sse'; +import ReactNativeLDClient from '../ReactNativeLDClient'; +import useAppState from './useAppState'; + +jest.mock('@launchdarkly/js-client-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-client-sdk-common'); + return { + ...actual, + debounce: jest.fn(), + }; +}); + +describe('useAppState', () => { + const eventSourceOpen = 1; + const eventSourceClosed = 2; + + let appStateSpy: jest.SpyInstance; + let ldc: ReactNativeLDClient; + let mockEventSource: Partial; + + beforeEach(() => { + (debounce as jest.Mock).mockImplementation((f) => f); + appStateSpy = jest.spyOn(AppState, 'addEventListener').mockReturnValue({ remove: jest.fn() }); + jest.spyOn(React, 'useRef').mockReturnValue({ + current: 'active', + }); + + ldc = new ReactNativeLDClient('mob-test-key', AutoEnvAttributes.Enabled, { logger }); + + mockEventSource = { + getStatus: jest.fn(() => eventSourceOpen), + OPEN: eventSourceOpen, + CLOSED: eventSourceClosed, + }; + // @ts-ignore + ldc.platform.requests = { eventSource: mockEventSource }; + // @ts-ignore + ldc.streamer = { start: jest.fn().mockName('start'), stop: jest.fn().mockName('stop') }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('stops streamer in background', () => { + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('background'); + + expect(ldc.streamer?.stop).toHaveBeenCalledTimes(1); + }); + + test('starts streamer transitioning from background to active', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'background' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).toHaveBeenCalledTimes(1); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + }); + + test('starts streamer transitioning from inactive to active', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'inactive' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).toHaveBeenCalledTimes(1); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + }); + + test('does not start streamer in foreground because event source is already open', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'background' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceOpen); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).not.toHaveBeenCalled(); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/already open/)); + }); + + test('active state unchanged no action needed', () => { + (useRef as jest.Mock).mockReturnValue({ current: 'active' }); + (mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed); + + renderHook(() => useAppState(ldc)); + const onChange = appStateSpy.mock.calls[0][1]; + + onChange('active'); + + expect(ldc.streamer?.start).not.toHaveBeenCalled(); + expect(ldc.streamer?.stop).not.toHaveBeenCalled(); + expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/no action needed/i)); + }); +}); diff --git a/packages/sdk/react-native/src/provider/useAppState.ts b/packages/sdk/react-native/src/provider/useAppState.ts new file mode 100644 index 000000000..0421881cd --- /dev/null +++ b/packages/sdk/react-native/src/provider/useAppState.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; + +import { debounce } from '@launchdarkly/js-client-sdk-common'; + +import { PlatformRequests } from '../platform'; +import ReactNativeLDClient from '../ReactNativeLDClient'; + +/** + * Manages streamer connection based on AppState. Debouncing is used to prevent excessive starting + * and stopping of the EventSource which are expensive. + * + * background to active - start streamer. + * active to background - stop streamer. + * + * @param client + */ +const useAppState = (client: ReactNativeLDClient) => { + const appState = useRef(AppState.currentState); + + const isEventSourceClosed = () => { + const { eventSource } = client.platform.requests as PlatformRequests; + return eventSource?.getStatus() === eventSource?.CLOSED; + }; + + const onChange = (nextAppState: AppStateStatus) => { + client.logger.debug(`App state prev: ${appState.current}, next: ${nextAppState}`); + + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + if (isEventSourceClosed()) { + client.logger.debug('Starting streamer after transitioning to foreground.'); + client.streamer?.start(); + } else { + client.logger.debug('Not starting streamer because EventSource is already open.'); + } + } else if (nextAppState === 'background') { + client.logger.debug('App state background stopping streamer.'); + client.streamer?.stop(); + } else { + client.logger.debug('No action needed.'); + } + + appState.current = nextAppState; + }; + + // debounce with a default delay of 5 seconds. + const debouncedOnChange = debounce(onChange); + + useEffect(() => { + const sub = AppState.addEventListener('change', debouncedOnChange); + + return () => { + sub.remove(); + }; + }, []); +}; + +export default useAppState; diff --git a/packages/shared/common/src/utils/debounce.ts b/packages/shared/common/src/utils/debounce.ts new file mode 100644 index 000000000..c4639ff3c --- /dev/null +++ b/packages/shared/common/src/utils/debounce.ts @@ -0,0 +1,35 @@ +/** + * Wait before calling the same function. Useful for expensive calls. + * Adapted from https://amitd.co/code/typescript/debounce. + * + * @return The debounced function. + * + * @example + * + * ```js + * const debouncedFunction = debounce(e => { + * console.log(e); + * }, 5000); + * + * // Console logs 'Hello world again ' after 5 seconds + * debouncedFunction('Hello world'); + * debouncedFunction('Hello world again'); + * ``` + * @param fn The function to be debounced. + * @param delayMs Defaults to 5 seconds. + */ +const debounce = ReturnType>( + fn: T, + delayMs: number = 5000, +): ((...args: Parameters) => void) => { + let timer: ReturnType; + + return (...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => { + fn(...args); + }, delayMs); + }; +}; + +export default debounce; diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts index ec5abefdb..2c2130fe3 100644 --- a/packages/shared/common/src/utils/index.ts +++ b/packages/shared/common/src/utils/index.ts @@ -1,5 +1,6 @@ import clone from './clone'; import { secondsToMillis } from './date'; +import debounce from './debounce'; import deepCompact from './deepCompact'; import fastDeepEqual from './fast-deep-equal'; import { base64UrlEncode, defaultHeaders, httpErrorMessage, LDHeaders, shouldRetry } from './http'; @@ -10,6 +11,7 @@ import { VoidFunction } from './VoidFunction'; export { base64UrlEncode, clone, + debounce, deepCompact, defaultHeaders, fastDeepEqual,