Skip to content

Commit c69b768

Browse files
authored
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.
1 parent 95e58bd commit c69b768

File tree

9 files changed

+226
-3
lines changed

9 files changed

+226
-3
lines changed

packages/sdk/react-native/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"event-target-shim": "^6.0.2"
5151
},
5252
"devDependencies": {
53+
"@launchdarkly/private-js-mocks": "0.0.1",
5354
"@testing-library/react": "^14.1.2",
5455
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
5556
"@types/jest": "^29.5.11",

packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,10 @@ export default class EventSource<E extends string = never> {
335335
this.dispatch('close', { type: 'close' });
336336
}
337337

338+
getStatus() {
339+
return this.status;
340+
}
341+
338342
onopen() {}
339343
onclose() {}
340344
onerror(_err: any) {}

packages/sdk/react-native/src/platform/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,27 @@ import { ldApplication, ldDevice } from './autoEnv';
2222
import AsyncStorage from './ConditionalAsyncStorage';
2323
import PlatformCrypto from './crypto';
2424

25-
class PlatformRequests implements Requests {
25+
export class PlatformRequests implements Requests {
26+
eventSource?: RNEventSource<EventName>;
27+
2628
constructor(private readonly logger: LDLogger) {}
2729

2830
createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource {
29-
return new RNEventSource<EventName>(url, {
31+
this.eventSource = new RNEventSource<EventName>(url, {
3032
headers: eventSourceInitDict.headers,
3133
retryAndHandleError: eventSourceInitDict.errorFilter,
3234
logger: this.logger,
3335
});
36+
37+
return this.eventSource;
3438
}
3539

3640
fetch(url: string, options?: Options): Promise<Response> {
3741
// @ts-ignore
3842
return fetch(url, options);
3943
}
4044
}
45+
4146
class PlatformEncoding implements Encoding {
4247
btoa(data: string): string {
4348
return btoa(data);

packages/sdk/react-native/src/provider/LDProvider.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import ReactNativeLDClient from '../ReactNativeLDClient';
77
import LDProvider from './LDProvider';
88
import setupListeners from './setupListeners';
99

10-
jest.mock('./setupListeners');
10+
jest.mock('../provider/useAppState');
1111
jest.mock('../ReactNativeLDClient');
12+
jest.mock('./setupListeners');
1213

1314
const TestApp = () => {
1415
const ldClient = useLDClient();

packages/sdk/react-native/src/provider/LDProvider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type LDContext } from '@launchdarkly/js-client-sdk-common';
55
import ReactNativeLDClient from '../ReactNativeLDClient';
66
import { Provider, ReactContext } from './reactContext';
77
import setupListeners from './setupListeners';
8+
import useAppState from './useAppState';
89

910
type LDProps = {
1011
client: ReactNativeLDClient;
@@ -38,6 +39,8 @@ const LDProvider = ({ client, context, children }: PropsWithChildren<LDProps>) =
3839
}
3940
}, []);
4041

42+
useAppState(client);
43+
4144
return <Provider value={state}>{children}</Provider>;
4245
};
4346

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { renderHook } from '@testing-library/react';
2+
import React, { useRef } from 'react';
3+
import { AppState } from 'react-native';
4+
5+
import { AutoEnvAttributes, debounce } from '@launchdarkly/js-client-sdk-common';
6+
import { logger } from '@launchdarkly/private-js-mocks';
7+
8+
import EventSource from '../fromExternal/react-native-sse';
9+
import ReactNativeLDClient from '../ReactNativeLDClient';
10+
import useAppState from './useAppState';
11+
12+
jest.mock('@launchdarkly/js-client-sdk-common', () => {
13+
const actual = jest.requireActual('@launchdarkly/js-client-sdk-common');
14+
return {
15+
...actual,
16+
debounce: jest.fn(),
17+
};
18+
});
19+
20+
describe('useAppState', () => {
21+
const eventSourceOpen = 1;
22+
const eventSourceClosed = 2;
23+
24+
let appStateSpy: jest.SpyInstance;
25+
let ldc: ReactNativeLDClient;
26+
let mockEventSource: Partial<EventSource>;
27+
28+
beforeEach(() => {
29+
(debounce as jest.Mock).mockImplementation((f) => f);
30+
appStateSpy = jest.spyOn(AppState, 'addEventListener').mockReturnValue({ remove: jest.fn() });
31+
jest.spyOn(React, 'useRef').mockReturnValue({
32+
current: 'active',
33+
});
34+
35+
ldc = new ReactNativeLDClient('mob-test-key', AutoEnvAttributes.Enabled, { logger });
36+
37+
mockEventSource = {
38+
getStatus: jest.fn(() => eventSourceOpen),
39+
OPEN: eventSourceOpen,
40+
CLOSED: eventSourceClosed,
41+
};
42+
// @ts-ignore
43+
ldc.platform.requests = { eventSource: mockEventSource };
44+
// @ts-ignore
45+
ldc.streamer = { start: jest.fn().mockName('start'), stop: jest.fn().mockName('stop') };
46+
});
47+
48+
afterEach(() => {
49+
jest.resetAllMocks();
50+
});
51+
52+
test('stops streamer in background', () => {
53+
renderHook(() => useAppState(ldc));
54+
const onChange = appStateSpy.mock.calls[0][1];
55+
56+
onChange('background');
57+
58+
expect(ldc.streamer?.stop).toHaveBeenCalledTimes(1);
59+
});
60+
61+
test('starts streamer transitioning from background to active', () => {
62+
(useRef as jest.Mock).mockReturnValue({ current: 'background' });
63+
(mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed);
64+
65+
renderHook(() => useAppState(ldc));
66+
const onChange = appStateSpy.mock.calls[0][1];
67+
68+
onChange('active');
69+
70+
expect(ldc.streamer?.start).toHaveBeenCalledTimes(1);
71+
expect(ldc.streamer?.stop).not.toHaveBeenCalled();
72+
});
73+
74+
test('starts streamer transitioning from inactive to active', () => {
75+
(useRef as jest.Mock).mockReturnValue({ current: 'inactive' });
76+
(mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed);
77+
78+
renderHook(() => useAppState(ldc));
79+
const onChange = appStateSpy.mock.calls[0][1];
80+
81+
onChange('active');
82+
83+
expect(ldc.streamer?.start).toHaveBeenCalledTimes(1);
84+
expect(ldc.streamer?.stop).not.toHaveBeenCalled();
85+
});
86+
87+
test('does not start streamer in foreground because event source is already open', () => {
88+
(useRef as jest.Mock).mockReturnValue({ current: 'background' });
89+
(mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceOpen);
90+
91+
renderHook(() => useAppState(ldc));
92+
const onChange = appStateSpy.mock.calls[0][1];
93+
94+
onChange('active');
95+
96+
expect(ldc.streamer?.start).not.toHaveBeenCalled();
97+
expect(ldc.streamer?.stop).not.toHaveBeenCalled();
98+
expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/already open/));
99+
});
100+
101+
test('active state unchanged no action needed', () => {
102+
(useRef as jest.Mock).mockReturnValue({ current: 'active' });
103+
(mockEventSource.getStatus as jest.Mock).mockReturnValue(eventSourceClosed);
104+
105+
renderHook(() => useAppState(ldc));
106+
const onChange = appStateSpy.mock.calls[0][1];
107+
108+
onChange('active');
109+
110+
expect(ldc.streamer?.start).not.toHaveBeenCalled();
111+
expect(ldc.streamer?.stop).not.toHaveBeenCalled();
112+
expect(ldc.logger.debug).toHaveBeenCalledWith(expect.stringMatching(/no action needed/i));
113+
});
114+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useEffect, useRef } from 'react';
2+
import { AppState, AppStateStatus } from 'react-native';
3+
4+
import { debounce } from '@launchdarkly/js-client-sdk-common';
5+
6+
import { PlatformRequests } from '../platform';
7+
import ReactNativeLDClient from '../ReactNativeLDClient';
8+
9+
/**
10+
* Manages streamer connection based on AppState. Debouncing is used to prevent excessive starting
11+
* and stopping of the EventSource which are expensive.
12+
*
13+
* background to active - start streamer.
14+
* active to background - stop streamer.
15+
*
16+
* @param client
17+
*/
18+
const useAppState = (client: ReactNativeLDClient) => {
19+
const appState = useRef(AppState.currentState);
20+
21+
const isEventSourceClosed = () => {
22+
const { eventSource } = client.platform.requests as PlatformRequests;
23+
return eventSource?.getStatus() === eventSource?.CLOSED;
24+
};
25+
26+
const onChange = (nextAppState: AppStateStatus) => {
27+
client.logger.debug(`App state prev: ${appState.current}, next: ${nextAppState}`);
28+
29+
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
30+
if (isEventSourceClosed()) {
31+
client.logger.debug('Starting streamer after transitioning to foreground.');
32+
client.streamer?.start();
33+
} else {
34+
client.logger.debug('Not starting streamer because EventSource is already open.');
35+
}
36+
} else if (nextAppState === 'background') {
37+
client.logger.debug('App state background stopping streamer.');
38+
client.streamer?.stop();
39+
} else {
40+
client.logger.debug('No action needed.');
41+
}
42+
43+
appState.current = nextAppState;
44+
};
45+
46+
// debounce with a default delay of 5 seconds.
47+
const debouncedOnChange = debounce(onChange);
48+
49+
useEffect(() => {
50+
const sub = AppState.addEventListener('change', debouncedOnChange);
51+
52+
return () => {
53+
sub.remove();
54+
};
55+
}, []);
56+
};
57+
58+
export default useAppState;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Wait before calling the same function. Useful for expensive calls.
3+
* Adapted from https://amitd.co/code/typescript/debounce.
4+
*
5+
* @return The debounced function.
6+
*
7+
* @example
8+
*
9+
* ```js
10+
* const debouncedFunction = debounce(e => {
11+
* console.log(e);
12+
* }, 5000);
13+
*
14+
* // Console logs 'Hello world again ' after 5 seconds
15+
* debouncedFunction('Hello world');
16+
* debouncedFunction('Hello world again');
17+
* ```
18+
* @param fn The function to be debounced.
19+
* @param delayMs Defaults to 5 seconds.
20+
*/
21+
const debounce = <T extends (...args: any[]) => ReturnType<T>>(
22+
fn: T,
23+
delayMs: number = 5000,
24+
): ((...args: Parameters<T>) => void) => {
25+
let timer: ReturnType<typeof setTimeout>;
26+
27+
return (...args: Parameters<T>) => {
28+
clearTimeout(timer);
29+
timer = setTimeout(() => {
30+
fn(...args);
31+
}, delayMs);
32+
};
33+
};
34+
35+
export default debounce;

packages/shared/common/src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import clone from './clone';
22
import { secondsToMillis } from './date';
3+
import debounce from './debounce';
34
import deepCompact from './deepCompact';
45
import fastDeepEqual from './fast-deep-equal';
56
import { base64UrlEncode, defaultHeaders, httpErrorMessage, LDHeaders, shouldRetry } from './http';
@@ -10,6 +11,7 @@ import { VoidFunction } from './VoidFunction';
1011
export {
1112
base64UrlEncode,
1213
clone,
14+
debounce,
1315
deepCompact,
1416
defaultHeaders,
1517
fastDeepEqual,

0 commit comments

Comments
 (0)