Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 29 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -79,8 +79,12 @@
},
"jest": {
"preset": "react-native",
"testMatch": ["<rootDir>/src/**/__tests__/**/?(*.)+(spec|test).[jt]s"],
"modulePathIgnorePatterns": [
"<rootDir>/lib/"
],
"transformIgnorePatterns": [
"<rootDir>/node_modules/(?!@splitsoftware)/"
]
},
"husky": {
Expand All @@ -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",
{
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/platform/EventSource/EventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class EventSource extends EventSourceBase {
}

_closeEventSource(id) {
this.readyState = this.CLOSED;
RNEventSource.close(id);
}

Expand Down
96 changes: 96 additions & 0 deletions src/platform/RNSignalListener.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
28 changes: 28 additions & 0 deletions src/platform/__tests__/AppState.mock.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}),
};
83 changes: 83 additions & 0 deletions src/platform/__tests__/RNSignalListener.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
8 changes: 6 additions & 2 deletions src/platform/getEventSource.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down
5 changes: 3 additions & 2 deletions src/platform/getModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,

Expand Down
2 changes: 1 addition & 1 deletion src/settings/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const packageVersion = '0.0.1-beta.4';
const packageVersion = '0.0.1-beta.5';

export const defaults = {
startup: {
Expand Down
5 changes: 4 additions & 1 deletion src/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}