diff --git a/package-lock.json b/package-lock.json index f27d14d..d68eb42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "splitio-react-native-private", - "version": "0.0.1-beta.4", + "version": "0.0.1-beta.5", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2453,9 +2453,9 @@ } }, "@splitsoftware/splitio-commons": { - "version": "0.1.1-canary.10", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-0.1.1-canary.10.tgz", - "integrity": "sha512-jwZ594xucI/dTyXaRgzBXp9VpRVKF4BWzkIjMKhZ0ChFxTGfCjrslKffyOdvtoZPlyzEVFaJoBWF47Hh2Hw2vw==", + "version": "0.1.1-canary.11", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-0.1.1-canary.11.tgz", + "integrity": "sha512-zbTmbEfpMiG8FynCLg8nVoDToXTDSVNGIpE7SIyw3gsKg2gJlzGAHkr419pV4c0uXTq+N6BvfwrdKxlmsSUgFg==", "requires": { "object-assign": "^4.1.1", "tslib": "^2.1.0" diff --git a/package.json b/package.json index d3205ae..3f7a41e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "splitio-react-native-private", - "version": "0.0.1-beta.4", + "version": "0.0.1-beta.5", "description": "Split SDK for React Native", "main": "lib/commonjs/index", "module": "lib/module/index", @@ -52,7 +52,7 @@ "registry": "https://splitio.jfrog.io/artifactory/api/npm/npm-split/" }, "dependencies": { - "@splitsoftware/splitio-commons": "0.1.1-canary.10" + "@splitsoftware/splitio-commons": "0.1.1-canary.11" }, "devDependencies": { "@react-native-community/eslint-config": "^2.0.0", @@ -79,8 +79,12 @@ }, "jest": { "preset": "react-native", + "testMatch": ["/src/**/__tests__/**/?(*.)+(spec|test).[jt]s"], "modulePathIgnorePatterns": [ "/lib/" + ], + "transformIgnorePatterns": [ + "/node_modules/(?!@splitsoftware)/" ] }, "husky": { @@ -98,14 +102,6 @@ "import" ], "rules": { - "no-restricted-syntax": [ - "error", - "ForOfStatement", - "ForInStatement" - ], - "no-throw-literal": "error", - "import/no-default-export": "error", - "import/no-self-import": "error", "prettier/prettier": [ "error", { @@ -117,11 +113,32 @@ "useTabs": false } ] - } + }, + "overrides": [ + { + "files": [ + "src/**/*.ts" + ], + "excludedFiles": [ + "src/**/__tests__/**" + ], + "rules": { + "no-restricted-syntax": [ + "error", + "ForOfStatement", + "ForInStatement" + ], + "no-throw-literal": "error", + "import/no-default-export": "error", + "import/no-self-import": "error" + } + } + ] }, "eslintIgnore": [ "node_modules/", - "lib/" + "lib/", + "example/" ], "prettier": { "quoteProps": "consistent", diff --git a/src/platform/EventSource/EventSource.ts b/src/platform/EventSource/EventSource.ts index 930d7fe..0ba13c5 100644 --- a/src/platform/EventSource/EventSource.ts +++ b/src/platform/EventSource/EventSource.ts @@ -54,6 +54,7 @@ class EventSource extends EventSourceBase { } _closeEventSource(id) { + this.readyState = this.CLOSED; RNEventSource.close(id); } diff --git a/src/platform/RNSignalListener.ts b/src/platform/RNSignalListener.ts new file mode 100644 index 0000000..9eadf05 --- /dev/null +++ b/src/platform/RNSignalListener.ts @@ -0,0 +1,96 @@ +import { ISignalListener } from '@splitsoftware/splitio-commons/src/listeners/types'; +import { CLEANUP_REGISTERING, CLEANUP_DEREGISTERING } from '@splitsoftware/splitio-commons/src/logger/constants'; +import { ISyncManager } from '@splitsoftware/splitio-commons/src/sync/types'; +import { ISettings } from '@splitsoftware/splitio-commons/src/types'; +import { AppState, AppStateStatus } from 'react-native'; + +type Transition = undefined | 'TO_FOREGROUND' | 'TO_BACKGROUND'; +const TO_FOREGROUND = 'TO_FOREGROUND'; +const TO_BACKGROUND = 'TO_BACKGROUND'; + +// const BACKGROUND_STOP_DELAY_MILLIS = 60000; // 1 minute +const FOREGROUND_MATCHER = /active/; + +const EVENT_NAME = 'for AppState change events.'; + +/** + * In ReactNative, we listen app state (foreground/background) to pause/resume synchronization. + */ +export class RNSignalListener implements ISignalListener { + private _lastTransition: Transition | undefined; + + constructor(private syncManager: ISyncManager, private settings: ISettings & { flushDataOnBackground?: boolean }) {} + + private _getTransition(nextAppState: AppStateStatus): Transition { + // 'inactive' iOS state and 'active' state are considered foreground states. + // In both states, the features used by the SDK (fetch, timers, EventSource, NetInfo) work fine. + // https://developer.apple.com/documentation/uikit/app_and_environment/managing_your_app_s_life_cycle + + let transition: Transition = + FOREGROUND_MATCHER.test(nextAppState) && this._lastTransition !== TO_FOREGROUND + ? TO_FOREGROUND + : nextAppState === 'background' && this._lastTransition !== TO_BACKGROUND + ? TO_BACKGROUND + : undefined; + + if (transition) this._lastTransition = transition; + return transition; + } + + private _handleAppStateChange = (nextAppState: AppStateStatus) => { + const action = this._getTransition(nextAppState); + + switch (action) { + case TO_FOREGROUND: + this.settings.log.debug(`App transition to foreground${this.syncManager.pushManager ? ': attempting to resume streaming' : ''}`); + + // This branch is called when app transition to foreground or it is launched, + // in which case calling pushManager.start has no effect (it has been already started). + // Here, synchronization is resumed. + if (this.syncManager.pushManager) this.syncManager.pushManager.start(); + + break; + case TO_BACKGROUND: + this.settings.log.debug( + `App transition to background${this.syncManager.pushManager ? ': pausing streaming' : ''}${ + this.settings.flushDataOnBackground ? ': flushing events and impressions' : '' + }` + ); + + // This branch is called when the app transition to background. + // Here, pushManager is stopped to close streaming connections for Android. + // In iOS, connections are automatically closed/resumed by the OS. + // Polling mode is paused during background, since JS timers callbacks are executed only + // when the app is in the foreground. Therefore, stopping syncManager is not necessary. + // Other features like Fetch, AsyncStorage, AppState and NetInfo listeners, can be used. + if (this.syncManager.pushManager) this.syncManager.pushManager.stop(); + + // We cannot listen when the app is evicted by the OS or the user, but it always transitions to background before that. + // So we should not flush events and impressions in the background, except that they cannot be stored in a persistent storage. + if (this.settings.flushDataOnBackground) this.syncManager.flush(); + + break; + } + }; + + /** + * start method. + * Called when SplitFactory is initialized. + */ + start() { + this.settings.log.debug(CLEANUP_REGISTERING, [EVENT_NAME]); + // Checking currentState in the rare case that the SDK is instantiated in the background, where streaming should not connect. + // The SDK should be instantiated when React Native JS context is initiated or the root componentDidMount method is called, where the app is in the foreground. + this._handleAppStateChange(AppState.currentState); + AppState.addEventListener('change', this._handleAppStateChange); + } + + /** + * stop method. + * Called when client is destroyed. + */ + stop() { + this.settings.log.debug(CLEANUP_DEREGISTERING, [EVENT_NAME]); + AppState.removeEventListener('change', this._handleAppStateChange); + } +} diff --git a/src/platform/__tests__/AppState.mock.ts b/src/platform/__tests__/AppState.mock.ts new file mode 100644 index 0000000..22fb472 --- /dev/null +++ b/src/platform/__tests__/AppState.mock.ts @@ -0,0 +1,28 @@ +import { AppStateStatus } from 'react-native'; + +export const AppStateMock = { + _changeListeners: new Set<(state: AppStateStatus) => void>(), + _emitChangeEvent(event: AppStateStatus) { + AppStateMock._changeListeners.forEach((listener) => { + listener(event); + }); + }, + mockClear() { + AppStateMock._changeListeners.clear(); + AppStateMock.addEventListener.mockClear(); + AppStateMock.removeEventListener.mockClear(); + }, + + // AppState API: + currentState: 'active', + addEventListener: jest.fn((type, listener) => { + if (type === 'change') { + AppStateMock._changeListeners.add(listener); + } + }), + removeEventListener: jest.fn((type, listener) => { + if (type === 'change') { + AppStateMock._changeListeners.delete(listener); + } + }), +}; diff --git a/src/platform/__tests__/RNSignalListener.spec.ts b/src/platform/__tests__/RNSignalListener.spec.ts new file mode 100644 index 0000000..0cb7da0 --- /dev/null +++ b/src/platform/__tests__/RNSignalListener.spec.ts @@ -0,0 +1,83 @@ +import { RNSignalListener } from '../RNSignalListener'; +import { AppStateMock } from './AppState.mock'; + +jest.doMock('react-native/Libraries/AppState/AppState', () => AppStateMock); + +const syncManagerMockWithPushManager = { + flush: jest.fn(), + pushManager: { start: jest.fn(), stop: jest.fn() }, +}; +const settingsMock = { + log: { debug: jest.fn() }, + flushDataOnBackground: true, +}; + +describe('RNSignalListener', () => { + beforeEach(() => { + AppStateMock.mockClear(); + syncManagerMockWithPushManager.flush.mockClear(); + syncManagerMockWithPushManager.pushManager.stop.mockClear(); + syncManagerMockWithPushManager.pushManager.start.mockClear(); + }); + + test('starting in foreground', () => { + // @ts-expect-error. SyncManager mock partially implemented + const signalListener = new RNSignalListener(syncManagerMockWithPushManager, settingsMock); + + // Starting with app in foreground + AppStateMock.currentState = 'active'; + signalListener.start(); + expect(AppStateMock.addEventListener).toBeCalledTimes(1); + expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(1); + + // Going to background should be handled + AppStateMock._emitChangeEvent('background'); + expect(syncManagerMockWithPushManager.flush).toBeCalledTimes(1); + expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(1); + + // Handling another background event, which shouldn't happen, have no effect + AppStateMock._emitChangeEvent('background'); + expect(syncManagerMockWithPushManager.flush).toBeCalledTimes(1); + expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(1); + expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(1); + + // Going to foreground should be handled + AppStateMock._emitChangeEvent('inactive'); + expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(2); + + // Handling another foreground event, have no effect + AppStateMock._emitChangeEvent('active'); + AppStateMock._emitChangeEvent('inactive'); + AppStateMock._emitChangeEvent('inactive'); + expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(2); + + // Going to background should be handled again + AppStateMock._emitChangeEvent('background'); + expect(syncManagerMockWithPushManager.flush).toBeCalledTimes(2); + expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(2); + + // Going to foreground should be handled again + AppStateMock._emitChangeEvent('active'); + expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(3); + + // Stopping RNSignalListener + signalListener.stop(); + expect(AppStateMock.removeEventListener).toBeCalledTimes(1); + expect(syncManagerMockWithPushManager.flush).toBeCalledTimes(2); + expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(2); + expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(3); + }); + + test('starting in background & without flushDataOnBackground', () => { + // @ts-expect-error. SyncManager mock partially implemented + const signalListener = new RNSignalListener(syncManagerMockWithPushManager, { ...settingsMock, flushDataOnBackground: undefined }); + + // Starting with app in background + AppStateMock.currentState = 'background'; + signalListener.start(); + expect(AppStateMock.addEventListener).toBeCalledTimes(1); + expect(syncManagerMockWithPushManager.flush).toBeCalledTimes(0); + expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(1); + expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(0); + }); +}); diff --git a/src/platform/getEventSource.ts b/src/platform/getEventSource.ts index 3a2503f..1c4b6ed 100644 --- a/src/platform/getEventSource.ts +++ b/src/platform/getEventSource.ts @@ -1,8 +1,12 @@ /* eslint-disable no-undef */ -import { NativeModules } from 'react-native'; +import reactNative from 'react-native'; let _RNEventSource: typeof EventSource | undefined; -if (NativeModules.RNEventSource) _RNEventSource = require('./EventSource/EventSource'); + +// Try-catch to avoid Jest error `Invariant Violation: __fbBatchedBridgeConfig is not set, cannot invoke native modules` +try { + if (reactNative.NativeModules && reactNative.NativeModules.RNEventSource) _RNEventSource = require('./EventSource/EventSource'); +} catch (e) {} /** * Returns native implementation of EventSource. If not available (e.g., Expo or other runtime than Android and iOS), diff --git a/src/platform/getModules.ts b/src/platform/getModules.ts index c839925..213f628 100644 --- a/src/platform/getModules.ts +++ b/src/platform/getModules.ts @@ -11,6 +11,7 @@ import { shouldAddPt } from '@splitsoftware/splitio-commons/src/trackers/impress import { ISettingsInternal } from '@splitsoftware/splitio-commons/src/utils/settingsValidation/types'; import { ISdkFactoryParams } from '@splitsoftware/splitio-commons/src/sdkFactory/types'; +import { RNSignalListener } from './RNSignalListener'; import { getEventSource } from './getEventSource'; const rnPlatform = { @@ -42,8 +43,8 @@ export function getModules(settings: ISettingsInternal): ISdkFactoryParams { sdkManagerFactory, sdkClientMethodFactory: sdkClientMethodCSFactory, - // @TODO provide RN signal listener - SignalListener: undefined, + + SignalListener: settings.mode === 'localhost' ? undefined : (RNSignalListener as ISdkFactoryParams['SignalListener']), // @ts-ignore impressionListener: settings.impressionListener, diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 7086314..ad309be 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -1,4 +1,4 @@ -const packageVersion = '0.0.1-beta.4'; +const packageVersion = '0.0.1-beta.5'; export const defaults = { startup: { diff --git a/src/settings/index.ts b/src/settings/index.ts index bd53491..35a593d 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -12,5 +12,8 @@ const params = { }; export function settingsValidator(config: any) { - return settingsValidation(config, params); + const settings = settingsValidation(config, params); + // @ts-ignore. For internal use, flush data on background until a persistent storage is provided. + settings.flushDataOnBackground = true; + return settings; }