From c69b7686eed1971288adfbe527b4bf53ba5fe2b7 Mon Sep 17 00:00:00 2001 From: Yusinto Ngadiman Date: Thu, 1 Feb 2024 12:46:45 -0800 Subject: [PATCH] fix: RN streamer connection in background and foreground. (#360) This PR adds a new hook `useAppState` to manage streamer connections on AppState transitions. This version is slightly different to the iOS and Flutter logic. The transition handler is debounced here so excessive transitions don't result in equally excessive start stop of the EventSource. --- packages/sdk/react-native/package.json | 1 + .../react-native-sse/EventSource.ts | 4 + .../sdk/react-native/src/platform/index.ts | 9 +- .../src/provider/LDProvider.test.tsx | 3 +- .../react-native/src/provider/LDProvider.tsx | 3 + .../src/provider/useAppState.test.ts | 114 ++++++++++++++++++ .../react-native/src/provider/useAppState.ts | 58 +++++++++ packages/shared/common/src/utils/debounce.ts | 35 ++++++ packages/shared/common/src/utils/index.ts | 2 + 9 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/react-native/src/provider/useAppState.test.ts create mode 100644 packages/sdk/react-native/src/provider/useAppState.ts create mode 100644 packages/shared/common/src/utils/debounce.ts 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,