Skip to content
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
1.2.1 (September 22, 2025)
- Updated the application state listener to sync feature flag definitions when the app returns to the foreground and has exceeded the SDK’s feature refresh interval.

1.2.0 (June 25, 2025)
- Added support for rule-based segments. These segments determine membership at runtime by evaluating their configured rules against the user attributes provided to the SDK.
- Added support for feature flag prerequisites. This allows customers to define dependency conditions between flags, which are evaluated before any allowlists or targeting rules.
Expand Down
16 changes: 14 additions & 2 deletions src/platform/RNSignalListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const EVENT_NAME = 'for AppState change events.';
export class RNSignalListener implements ISignalListener {
private _lastTransition: Transition | undefined;
private _appStateSubscription: NativeEventSubscription | undefined;
private _lastBgTimestamp: number | undefined;

constructor(private syncManager: ISyncManager, private settings: ISettings & { flushDataOnBackground?: boolean }) {}

Expand All @@ -39,6 +40,10 @@ export class RNSignalListener implements ISignalListener {
return transition;
}

private _mustSyncAll() {
return this.settings.sync.enabled && this._lastBgTimestamp && this._lastBgTimestamp < Date.now() - this.settings.scheduler.featuresRefreshRate;
}

private _handleAppStateChange = (nextAppState: AppStateStatus) => {
const action = this._getTransition(nextAppState);

Expand All @@ -51,10 +56,17 @@ export class RNSignalListener implements ISignalListener {
// In 2, PushManager is resumed in case it was paused and the SDK is running in push mode.
// If running in polling mode, either pushManager is not defined (e.g., streamingEnabled is false)
// or calling pushManager.start has no effect because it was disabled (PUSH_NONRETRYABLE_ERROR).
if (this.syncManager.pushManager) this.syncManager.pushManager.start();
if (this.syncManager.pushManager) {
this.syncManager.pushManager.start();

// Sync all if singleSync is disabled and background time exceeds features refresh rate
// For streaming, this compensates the re-connection delay, and for polling, it compensates the suspension of scheduled tasks during background.
if (this._mustSyncAll()) this.syncManager.pollingManager!.syncAll();
}

break;
case TO_BACKGROUND:
this._lastBgTimestamp = Date.now();
this.settings.log.debug(
`App transition to background${this.syncManager.pushManager ? '. Pausing streaming' : ''}${
this.settings.flushDataOnBackground ? '. Flushing events and impressions' : ''
Expand All @@ -65,7 +77,7 @@ export class RNSignalListener implements ISignalListener {
// Here, PushManager is paused in case the SDK is running in push mode, to close streaming connection for Android.
// In iOS it is not strictly required, since connections are automatically closed/resumed by the OS.
// The remaining SyncManager components (PollingManager and Submitter) don't need to be stopped, even if running in
// polling mode, because sync tasks are "delayed" during background, since JS timers callbacks are executed only
// polling mode, because sync tasks are suspended during background, since JS timers callbacks are executed only
// when the app is in foreground (https://github.com/facebook/react-native/issues/12981#issuecomment-652745831).
// Other features like Fetch, AsyncStorage, AppState and NetInfo listeners, can be used in background.
if (this.syncManager.pushManager) this.syncManager.pushManager.stop();
Expand Down
15 changes: 14 additions & 1 deletion src/platform/__tests__/RNSignalListener.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ jest.doMock('react-native/Libraries/AppState/AppState', () => AppStateMock);
const syncManagerMockWithPushManager = {
flush: jest.fn(),
pushManager: { start: jest.fn(), stop: jest.fn() },
pollingManager: { syncAll: jest.fn() },
};
const settingsMock = {
log: { debug: jest.fn() },
flushDataOnBackground: true,
scheduler: { featuresRefreshRate: 100 },
sync: { enabled: true },
};

describe('RNSignalListener', () => {
Expand All @@ -20,7 +23,7 @@ describe('RNSignalListener', () => {
syncManagerMockWithPushManager.pushManager.start.mockClear();
});

test('starting in foreground', () => {
test('starting in foreground', async () => {
// @ts-expect-error. SyncManager mock partially implemented
const signalListener = new RNSignalListener(syncManagerMockWithPushManager, settingsMock);

Expand All @@ -41,9 +44,14 @@ describe('RNSignalListener', () => {
expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(1);
expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(1);

// Wait for features refresh rate to validate that syncAll is called when resuming foreground
expect(syncManagerMockWithPushManager.pollingManager!.syncAll).toBeCalledTimes(0);
await new Promise((resolve) => setTimeout(resolve, settingsMock.scheduler.featuresRefreshRate));

// Going to foreground should be handled
AppStateMock._emitChangeEvent('inactive');
expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(2);
expect(syncManagerMockWithPushManager.pollingManager!.syncAll).toBeCalledTimes(1);

// Handling another foreground event, have no effect
AppStateMock._emitChangeEvent('active');
Expand All @@ -56,9 +64,14 @@ describe('RNSignalListener', () => {
expect(syncManagerMockWithPushManager.flush).toBeCalledTimes(2);
expect(syncManagerMockWithPushManager.pushManager.stop).toBeCalledTimes(2);

// Validate that syncAll is not called if singleSync is enabled
settingsMock.sync.enabled = false;
await new Promise((resolve) => setTimeout(resolve, settingsMock.scheduler.featuresRefreshRate));

// Going to foreground should be handled again
AppStateMock._emitChangeEvent('active');
expect(syncManagerMockWithPushManager.pushManager.start).toBeCalledTimes(3);
expect(syncManagerMockWithPushManager.pollingManager!.syncAll).toBeCalledTimes(1);

// Stopping RNSignalListener
signalListener.stop(); // @ts-ignore access private property
Expand Down