Skip to content

Commit

Permalink
fix: RN streamer connection in background and foreground. (#360)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
yusinto authored Feb 1, 2024
1 parent 95e58bd commit c69b768
Show file tree
Hide file tree
Showing 9 changed files with 226 additions and 3 deletions.
1 change: 1 addition & 0 deletions packages/sdk/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,10 @@ export default class EventSource<E extends string = never> {
this.dispatch('close', { type: 'close' });
}

getStatus() {
return this.status;
}

onopen() {}
onclose() {}
onerror(_err: any) {}
Expand Down
9 changes: 7 additions & 2 deletions packages/sdk/react-native/src/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,27 @@ import { ldApplication, ldDevice } from './autoEnv';
import AsyncStorage from './ConditionalAsyncStorage';
import PlatformCrypto from './crypto';

class PlatformRequests implements Requests {
export class PlatformRequests implements Requests {
eventSource?: RNEventSource<EventName>;

constructor(private readonly logger: LDLogger) {}

createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource {
return new RNEventSource<EventName>(url, {
this.eventSource = new RNEventSource<EventName>(url, {
headers: eventSourceInitDict.headers,
retryAndHandleError: eventSourceInitDict.errorFilter,
logger: this.logger,
});

return this.eventSource;
}

fetch(url: string, options?: Options): Promise<Response> {
// @ts-ignore
return fetch(url, options);
}
}

class PlatformEncoding implements Encoding {
btoa(data: string): string {
return btoa(data);
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/react-native/src/provider/LDProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/react-native/src/provider/LDProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,8 @@ const LDProvider = ({ client, context, children }: PropsWithChildren<LDProps>) =
}
}, []);

useAppState(client);

return <Provider value={state}>{children}</Provider>;
};

Expand Down
114 changes: 114 additions & 0 deletions packages/sdk/react-native/src/provider/useAppState.test.ts
Original file line number Diff line number Diff line change
@@ -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<EventSource>;

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));
});
});
58 changes: 58 additions & 0 deletions packages/sdk/react-native/src/provider/useAppState.ts
Original file line number Diff line number Diff line change
@@ -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;
35 changes: 35 additions & 0 deletions packages/shared/common/src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends (...args: any[]) => ReturnType<T>>(
fn: T,
delayMs: number = 5000,
): ((...args: Parameters<T>) => void) => {
let timer: ReturnType<typeof setTimeout>;

return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => {
fn(...args);
}, delayMs);
};
};

export default debounce;
2 changes: 2 additions & 0 deletions packages/shared/common/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +11,7 @@ import { VoidFunction } from './VoidFunction';
export {
base64UrlEncode,
clone,
debounce,
deepCompact,
defaultHeaders,
fastDeepEqual,
Expand Down

0 comments on commit c69b768

Please sign in to comment.