Skip to content

Refactor app state listener to improve synchronization #102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: development
Choose a base branch
from
Open
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
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 (July XX, 2025)
- Updated the application state listener to synchronize feature flag definitions when the app returns to foreground after exceeding the SDK's features refresh rate.

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