-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
9 changed files
with
226 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
packages/sdk/react-native/src/provider/useAppState.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters