diff --git a/CHANGES.txt b/CHANGES.txt index d88d728..f9c8ceb 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -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. diff --git a/src/platform/RNSignalListener.ts b/src/platform/RNSignalListener.ts index aea0155..2385288 100644 --- a/src/platform/RNSignalListener.ts +++ b/src/platform/RNSignalListener.ts @@ -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 }) {} @@ -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); @@ -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' : '' @@ -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(); diff --git a/src/platform/__tests__/RNSignalListener.spec.ts b/src/platform/__tests__/RNSignalListener.spec.ts index 875909d..bdd1406 100644 --- a/src/platform/__tests__/RNSignalListener.spec.ts +++ b/src/platform/__tests__/RNSignalListener.spec.ts @@ -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', () => { @@ -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); @@ -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'); @@ -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