From 4c2c9e0226cf5cf5354d9f6acf07d58ab75a807e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Wed, 1 Jul 2026 20:10:22 +0100 Subject: [PATCH 1/3] feat(SDK-520): replace magic 1000ms timeouts with named, configurable constants - Add IterableConfig.androidWakeDelayMs and authCallbackTimeoutMs to expose the two hardcoded timeouts as tunable, documented values. - Replace the fixed-window auth callback gate with an event-driven Promise latch; the timer survives only as a safety-net fallback. --- CHANGELOG.md | 12 ++ src/core/classes/Iterable.test.ts | 173 +++++++++++++++++++++++++++++ src/core/classes/Iterable.ts | 144 +++++++++++++++++++----- src/core/classes/IterableConfig.ts | 59 ++++++++++ 4 files changed, 363 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 499c0eda5..22f26f78f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ - Added `Iterable.registerDeviceToken(token)` to re-enable push for the current device. - Android accepts an FCM token string. - iOS accepts a continuous hex string representation of the APNS token. +- Replaced the two hardcoded 1000ms magic timeouts in the RN SDK with named, + documented, and configurable values (SDK-520). + - Added `IterableConfig.androidWakeDelayMs` (default `1000`) to tune the + Android deep-link wake delay before the SDK invokes `urlHandler`. Set to + `0` to dispatch synchronously. + - Added `IterableConfig.authCallbackTimeoutMs` (default `1000`) to tune the + safety-net timeout for the auth callback latch. + - The auth callback gate is now event-driven: the native + `handleAuthSuccessCalled` / `handleAuthFailureCalled` events resolve the + latch immediately. The timer survives only as a fallback when no native + event arrives within the configured window, instead of being the primary + resolution mechanism. ## 3.0.1 diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index ce5c42967..2a1a6a36c 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -334,6 +334,8 @@ describe('Iterable', () => { expect(config.pushIntegrationName).toBe(undefined); expect(config.urlHandler).toBe(undefined); expect(config.useInMemoryStorageForInApps).toBe(false); + expect(config.androidWakeDelayMs).toBe(1000); + expect(config.authCallbackTimeoutMs).toBe(1000); const configDict = config.toDict(); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); @@ -350,6 +352,17 @@ describe('Iterable', () => { expect(configDict.pushIntegrationName).toBe(undefined); expect(configDict.urlHandlerPresent).toBe(false); expect(configDict.useInMemoryStorageForInApps).toBe(false); + expect(configDict.androidWakeDelayMs).toBe(1000); + expect(configDict.authCallbackTimeoutMs).toBe(1000); + }); + + it('should allow overriding androidWakeDelayMs and authCallbackTimeoutMs', () => { + const config = new IterableConfig(); + config.androidWakeDelayMs = 1500; + config.authCallbackTimeoutMs = 2500; + const configDict = config.toDict(); + expect(configDict.androidWakeDelayMs).toBe(1500); + expect(configDict.authCallbackTimeoutMs).toBe(2500); }); }); @@ -1607,6 +1620,88 @@ describe('Iterable', () => { expect(MockLinking.openURL).toBeCalledWith(expectedUrl); }); }); + + it('should honor a custom androidWakeDelayMs on Android', async () => { + // GIVEN Android platform + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + + // sets up config with a custom wake delay + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.androidWakeDelayMs = 300; + config.urlHandler = jest.fn(() => false); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // GIVEN the link can be opened + MockLinking.canOpenURL = jest.fn(async () => true); + MockLinking.openURL.mockReset(); + + const expectedUrl = 'https://somewhere.com'; + const dict = { + url: expectedUrl, + context: { + action: { type: 'openUrl' }, + source: IterableActionSource.inApp, + }, + }; + + // WHEN handleUrlCalled event is emitted + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN the handler is called after the custom delay, not the default + return await TestHelper.delayed(400, () => { + expect(config.urlHandler).toBeCalledWith(expectedUrl, dict.context); + expect(MockLinking.openURL).toBeCalledWith(expectedUrl); + }); + }); + + it('should dispatch synchronously on Android when androidWakeDelayMs is 0', async () => { + // GIVEN Android platform + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleUrlCalled); + + // sets up config with wake delay disabled + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.androidWakeDelayMs = 0; + config.urlHandler = jest.fn(() => false); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + MockLinking.canOpenURL = jest.fn(async () => true); + MockLinking.openURL.mockReset(); + + const expectedUrl = 'https://somewhere.com'; + const dict = { + url: expectedUrl, + context: { + action: { type: 'openUrl' }, + source: IterableActionSource.inApp, + }, + }; + + // WHEN handleUrlCalled event is emitted + nativeEmitter.emit(IterableEventName.handleUrlCalled, dict); + + // THEN the handler is invoked without a setTimeout delay + expect(config.urlHandler).toBeCalledWith(expectedUrl, dict.context); + }); }); describe('re-initialization', () => { @@ -1732,5 +1827,83 @@ describe('Iterable', () => { expect(failureCallback).not.toBeCalled(); }); }); + + it('should honor a custom authCallbackTimeoutMs for the safety-net timeout', async () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + // sets up config with a short custom auth callback timeout + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 200; + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + const authResponse = new IterableAuthResponse(); + authResponse.authToken = 'short-timeout-token'; + authResponse.successCallback = successCallback; + authResponse.failureCallback = failureCallback; + config.authHandler = jest.fn(() => Promise.resolve(authResponse)); + + // initialize Iterable object + Iterable.initialize('apiKey', config); + + // WHEN handleAuthCalled event is emitted but no success/failure event follows + nativeEmitter.emit(IterableEventName.handleAuthCalled); + + // THEN the safety-net timer fires at the custom interval, not the default + return await TestHelper.delayed(300, () => { + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( + 'short-timeout-token' + ); + expect(successCallback).not.toBeCalled(); + expect(failureCallback).not.toBeCalled(); + }); + }); + + it('should resolve the latch immediately when the native success event arrives before the safety-net timeout', async () => { + // sets up event emitter + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 2000; + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + const authResponse = new IterableAuthResponse(); + authResponse.authToken = 'fast-success-token'; + authResponse.successCallback = successCallback; + authResponse.failureCallback = failureCallback; + config.authHandler = jest.fn(() => Promise.resolve(authResponse)); + + Iterable.initialize('apiKey', config); + + // WHEN handleAuthCalled and handleAuthSuccessCalled both fire + nativeEmitter.emit(IterableEventName.handleAuthCalled); + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + + // THEN successCallback resolves on the microtask queue, well before the + // 2000ms safety-net timeout. + return await TestHelper.delayed(50, () => { + expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( + 'fast-success-token' + ); + expect(successCallback).toBeCalled(); + expect(failureCallback).not.toBeCalled(); + }); + }); }); }); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index d29a90c7f..194388df1 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -27,6 +27,20 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const defaultConfig = new IterableConfig(); +/** + * Fallback safety-net timeout for the auth callback latch when no native + * auth success/failure event arrives. Overridable via + * {@link IterableConfig.authCallbackTimeoutMs}. + */ +const AUTH_CALLBACK_TIMEOUT_DEFAULT_MS = 1000; + +/** + * Default delay (ms) the SDK waits on Android before invoking the URL handler + * so the host Activity can wake from the background. Overridable via + * {@link IterableConfig.androidWakeDelayMs}. + */ +const ANDROID_WAKE_DELAY_DEFAULT_MS = 1000; + /** * Checks if the response is an IterableAuthResponse */ @@ -1009,10 +1023,20 @@ export class Iterable { Iterable.wakeApp(); if (Platform.OS === 'android') { - //Give enough time for Activity to wake up. - setTimeout(() => { + // Give the host Activity time to wake from the background before + // dispatching the URL to the handler. Without this delay the + // handler can race the activity lifecycle and drop the link on + // cold start. Tunable via IterableConfig.androidWakeDelayMs. + const wakeDelayMs = + Iterable.savedConfig.androidWakeDelayMs ?? + ANDROID_WAKE_DELAY_DEFAULT_MS; + if (wakeDelayMs > 0) { + setTimeout(() => { + callUrlHandler(Iterable.savedConfig, url, context); + }, wakeDelayMs); + } else { callUrlHandler(Iterable.savedConfig, url, context); - }, 1000); + } } else { callUrlHandler(Iterable.savedConfig, url, context); } @@ -1043,37 +1067,90 @@ export class Iterable { } if (Iterable.savedConfig.authHandler) { - let authResponseCallback: IterableAuthResponseResult; + // Sentinel for the safety-net timeout path, distinct from the native + // SUCCESS / FAILURE results. + const AUTH_RESULT_NO_CALLBACK = 'NO_CALLBACK'; + type AuthLatchResult = + | IterableAuthResponseResult + | typeof AUTH_RESULT_NO_CALLBACK; + + // Event-driven auth latch. The native success/failure listeners + // resolve the latch when their event arrives; the safety-net timer + // resolves it only if no native event arrives within the configured + // window. The timer is a fallback — the latch resolves immediately + // when the native event fires. + // + // `pendingAuthResult` buffers a native result that arrives before the + // latch is created (e.g. the authHandler promise hasn't settled yet). + // It is consumed when the latch is created, so out-of-order events + // are not lost. + let authLatchResolver: + | ((result: IterableAuthResponseResult) => void) + | null = null; + let pendingAuthResult: IterableAuthResponseResult | null = null; + RNEventEmitter.addListener(IterableEventName.handleAuthCalled, () => { + // Reset per-invocation state so a stale buffered result from a + // previous invocation cannot bleed into this one. + authLatchResolver = null; + pendingAuthResult = null; + // MOB-10423: Check if we can use chain operator (?.) here instead // Asks frontend of the client/app to pass authToken Iterable.savedConfig.authHandler!() .then((promiseResult) => { // Promise result can be either just String OR of type AuthResponse. - // If type AuthReponse, authToken will be parsed looking for `authToken` within promised object. Two additional listeners will be registered for success and failure callbacks sent by native bridge layer. + // If type AuthResponse, authToken will be parsed looking for + // `authToken` within promised object. A latch is created and raced + // against a safety-net timeout: the native success/failure events + // resolve the latch immediately, while the timer only fires if no + // native event arrives within the configured window. // Else it will be looked for as a String. if (isIterableAuthResponse(promiseResult)) { Iterable.authManager.passAlongAuthToken(promiseResult.authToken); - setTimeout(() => { - if ( - authResponseCallback === IterableAuthResponseResult.SUCCESS - ) { - if (promiseResult.successCallback) { - promiseResult.successCallback?.(); + const nativeLatch = new Promise( + (resolve) => { + if (pendingAuthResult !== null) { + // A native event arrived before the latch was created; + // resolve immediately with the buffered result. + const buffered = pendingAuthResult; + pendingAuthResult = null; + resolve(buffered); + } else { + authLatchResolver = resolve; } - } else if ( - authResponseCallback === IterableAuthResponseResult.FAILURE - ) { - // We are currently only reporting JWT related errors. In - // the future, we should handle other types of errors as well. - if (promiseResult.failureCallback) { + } + ); + + const timeoutMs = + Iterable.savedConfig.authCallbackTimeoutMs ?? + AUTH_CALLBACK_TIMEOUT_DEFAULT_MS; + const timeoutLatch = new Promise((resolve) => { + setTimeout( + () => resolve(AUTH_RESULT_NO_CALLBACK), + timeoutMs + ); + }); + + Promise.race([nativeLatch, timeoutLatch]).then( + (result) => { + // Clear the resolver so a late native event after the timeout + // is a no-op. + authLatchResolver = null; + pendingAuthResult = null; + if (result === IterableAuthResponseResult.SUCCESS) { + promiseResult.successCallback?.(); + } else if (result === IterableAuthResponseResult.FAILURE) { + // We are currently only reporting JWT related errors. In + // the future, we should handle other types of errors as + // well. promiseResult.failureCallback?.(); + } else { + IterableLogger?.log('No callback received from native layer'); } - } else { - IterableLogger?.log('No callback received from native layer'); } - }, 1000); + ); } else if (typeof promiseResult === 'string') { // If promise only returns string Iterable.authManager.passAlongAuthToken(promiseResult); @@ -1093,16 +1170,33 @@ export class Iterable { RNEventEmitter.addListener( IterableEventName.handleAuthSuccessCalled, () => { - authResponseCallback = IterableAuthResponseResult.SUCCESS; + // Resolve the pending auth latch immediately; the timer becomes a + // no-op for this invocation. + if (authLatchResolver) { + const resolve = authLatchResolver; + authLatchResolver = null; + pendingAuthResult = null; + resolve(IterableAuthResponseResult.SUCCESS); + } else { + // Latch not created yet — buffer the result for the latch. + pendingAuthResult = IterableAuthResponseResult.SUCCESS; + } } ); RNEventEmitter.addListener( IterableEventName.handleAuthFailureCalled, (authFailureResponse: IterableAuthFailure) => { - // Mark the flag for above listener to indicate something failed. - // `catch(err)` will only indicate failure on high level. No actions - // should be taken inside `catch(err)`. - authResponseCallback = IterableAuthResponseResult.FAILURE; + // Resolve the pending auth latch immediately; the timer becomes a + // no-op for this invocation. + if (authLatchResolver) { + const resolve = authLatchResolver; + authLatchResolver = null; + pendingAuthResult = null; + resolve(IterableAuthResponseResult.FAILURE); + } else { + // Latch not created yet — buffer the result for the latch. + pendingAuthResult = IterableAuthResponseResult.FAILURE; + } // Call the actual JWT error with `authFailure` object. Iterable.savedConfig?.onJwtError?.(authFailureResponse); diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 34befbbc8..cf35e1f7e 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -329,6 +329,63 @@ export class IterableConfig { */ encryptionEnforced = false; + /** + * Delay (in milliseconds) the SDK waits on Android before invoking the URL + * handler after a deep-link event. + * + * On Android, the host `Activity` may still be waking from the background when + * a deep-link event is delivered. This delay gives the activity time to resume + * before the SDK dispatches the URL to `urlHandler`. Without it, the handler + * can race the activity lifecycle and drop or mishandle the link on cold + * start. + * + * The wake delay is applied only on Android; iOS dispatches immediately. + * + * @remarks + * Tune this value if your app observes dropped or duplicated deep links on + * Android. Increase it on slower devices or custom application classes; set + * it to `0` to dispatch synchronously (not recommended unless you have + * confirmed your activity lifecycle does not require the delay). + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.androidWakeDelayMs = 1500; // wait 1.5s on slow Android devices + * Iterable.initialize('', config); + * ``` + */ + androidWakeDelayMs = 1000; + + /** + * Maximum time (in milliseconds) the SDK waits for a native auth success or + * failure event before resolving the auth callback latch on its own. + * + * When `authHandler` returns an `IterableAuthResponse`, the SDK passes the + * `authToken` to the native layer and waits for either a + * `handleAuthSuccessCalled` or `handleAuthFailureCalled` event before + * invoking `successCallback` / `failureCallback`. This timeout is a + * **safety net**: if no native event arrives within the window, the SDK + * resolves the latch with a "no callback received" outcome and logs a + * warning rather than hanging the auth flow indefinitely. + * + * The timer is a fallback, not the primary resolution mechanism. The latch + * resolves immediately when the native event arrives. + * + * @remarks + * Increase this value on networks or devices where native auth round-trips + * are slow. Setting it too low can cause premature "no callback received" + * warnings. Setting it too high increases perceived auth latency only when + * the native layer fails to respond. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.authCallbackTimeoutMs = 2000; // wait up to 2s for native auth events + * Iterable.initialize('', config); + * ``` + */ + authCallbackTimeoutMs = 1000; + /** * Should the SDK enable and use embedded messaging? * @@ -440,6 +497,8 @@ export class IterableConfig { encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, enableEmbeddedMessaging: this.enableEmbeddedMessaging, + androidWakeDelayMs: this.androidWakeDelayMs, + authCallbackTimeoutMs: this.authCallbackTimeoutMs, }; } } From bbc33dc0355c3a7f4917c231f57d28ecf30f29c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Mon, 6 Jul 2026 16:10:36 +0100 Subject: [PATCH 2/3] fix(SDK-520): scope auth latch per-invocation and raise auth timeout default - Replace shared authLatchResolver/pendingAuthResult with a per-invocation FIFO queue so overlapping handleAuthCalled invocations no longer drop successCallback/failureCallback - Route native success/failure events to the oldest pending invocation and shift the queue synchronously on resolve - Raise IterableConfig.authCallbackTimeoutMs default 1000 -> 6000 with rationale in TSDoc - Remove dead AUTH_CALLBACK_TIMEOUT_DEFAULT_MS / ANDROID_WAKE_DELAY_DEFAULT_MS fallbacks; IterableConfig is the single source of truth - Update Iterable.test.ts default assertions (1000 -> 6000), rework safety-net test to use a custom short timeout - Add regression test: two back-to-back handleAuthCalled invocations assert both successCallbacks fire with no 'No callback received' warning - Update CHANGELOG default --- CHANGELOG.md | 6 +- src/core/classes/Iterable.test.ts | 101 +++++++++++++++++++- src/core/classes/Iterable.ts | 143 +++++++++++++++++------------ src/core/classes/IterableConfig.ts | 17 ++-- 4 files changed, 197 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f26f78f..cbbe19025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,10 @@ - Added `IterableConfig.androidWakeDelayMs` (default `1000`) to tune the Android deep-link wake delay before the SDK invokes `urlHandler`. Set to `0` to dispatch synchronously. - - Added `IterableConfig.authCallbackTimeoutMs` (default `1000`) to tune the - safety-net timeout for the auth callback latch. + - Added `IterableConfig.authCallbackTimeoutMs` (default `6000`) to tune the + safety-net timeout for the auth callback latch. The default is chosen to + comfortably exceed typical mobile auth round-trips while staying well + below the native 30s auth latch ceiling on both iOS and Android. - The auth callback gate is now event-driven: the native `handleAuthSuccessCalled` / `handleAuthFailureCalled` events resolve the latch immediately. The timer survives only as a fallback when no native diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 2a1a6a36c..376cb872f 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -24,6 +24,7 @@ import { IterableLogLevel, } from '../..'; import { TestHelper } from '../../__tests__/TestHelper'; +import { IterableLogger } from './IterableLogger'; describe('Iterable', () => { beforeEach(() => { @@ -335,7 +336,7 @@ describe('Iterable', () => { expect(config.urlHandler).toBe(undefined); expect(config.useInMemoryStorageForInApps).toBe(false); expect(config.androidWakeDelayMs).toBe(1000); - expect(config.authCallbackTimeoutMs).toBe(1000); + expect(config.authCallbackTimeoutMs).toBe(6000); const configDict = config.toDict(); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); @@ -353,7 +354,7 @@ describe('Iterable', () => { expect(configDict.urlHandlerPresent).toBe(false); expect(configDict.useInMemoryStorageForInApps).toBe(false); expect(configDict.androidWakeDelayMs).toBe(1000); - expect(configDict.authCallbackTimeoutMs).toBe(1000); + expect(configDict.authCallbackTimeoutMs).toBe(6000); }); it('should allow overriding androidWakeDelayMs and authCallbackTimeoutMs', () => { @@ -1801,9 +1802,12 @@ describe('Iterable', () => { IterableEventName.handleAuthFailureCalled ); - // sets up config with authHandler that returns an AuthResponse + // sets up config with authHandler that returns an AuthResponse and a + // short safety-net timeout (default is 6000ms; we use 200ms here so + // the safety-net fires within the test window). const config = new IterableConfig(); config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 200; const successCallback = jest.fn(); const failureCallback = jest.fn(); const authResponse = new IterableAuthResponse(); @@ -1819,7 +1823,7 @@ describe('Iterable', () => { nativeEmitter.emit(IterableEventName.handleAuthCalled); // THEN the token is forwarded and neither callback fires - return await TestHelper.delayed(1100, () => { + return await TestHelper.delayed(300, () => { expect(MockRNIterableAPI.passAlongAuthToken).toBeCalledWith( 'timeout-token' ); @@ -1905,5 +1909,94 @@ describe('Iterable', () => { expect(failureCallback).not.toBeCalled(); }); }); + + it('should resolve both callbacks when two handleAuthCalled invocations overlap (SDK-520 regression)', async () => { + // Regression for the shared-latch-state bug: when a second + // handleAuthCalled event arrives while the first invocation's race is + // still pending, the original code wiped the first invocation's + // resolver, so the first successCallback was silently dropped. With + // per-invocation latch state, both invocations must resolve. + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 2000; + + const successCallback1 = jest.fn(); + const failureCallback1 = jest.fn(); + const authResponse1 = new IterableAuthResponse(); + authResponse1.authToken = 'overlap-token-1'; + authResponse1.successCallback = successCallback1; + authResponse1.failureCallback = failureCallback1; + + const successCallback2 = jest.fn(); + const failureCallback2 = jest.fn(); + const authResponse2 = new IterableAuthResponse(); + authResponse2.authToken = 'overlap-token-2'; + authResponse2.successCallback = successCallback2; + authResponse2.failureCallback = failureCallback2; + + // Use controllable promises so we can wire both latches before any + // native success event arrives. This is the realistic overlap shape: + // the SDK is awaiting native auth round-trips for two back-to-back + // handleAuthCalled events. + let resolveAuth1: (value: IterableAuthResponse) => void = () => {}; + let resolveAuth2: (value: IterableAuthResponse) => void = () => {}; + let callCount = 0; + config.authHandler = jest.fn(() => { + callCount += 1; + return new Promise((resolve) => { + if (callCount === 1) { + resolveAuth1 = resolve; + } else { + resolveAuth2 = resolve; + } + }); + }); + + const logSpy = jest.spyOn(IterableLogger, 'log'); + + Iterable.initialize('apiKey', config); + + // WHEN two handleAuthCalled events fire back-to-back (both invocations + // are now pending and awaiting their authHandler promises). + nativeEmitter.emit(IterableEventName.handleAuthCalled); + nativeEmitter.emit(IterableEventName.handleAuthCalled); + + // Resolve both authHandler promises. After the microtask queue + // flushes, both invocations' latches are wired and racing their + // safety-net timers. We defer the native success emissions to the + // next tick so the latch executors have run. + resolveAuth1(authResponse1); + resolveAuth2(authResponse2); + + // Then the corresponding native success events arrive in FIFO order. + await new Promise((resolve) => setTimeout(resolve, 0)); + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + + // THEN both successCallbacks fire and no "No callback received" + // warning is logged. + return await TestHelper.delayed(100, () => { + expect(successCallback1).toBeCalled(); + expect(successCallback2).toBeCalled(); + expect(failureCallback1).not.toBeCalled(); + expect(failureCallback2).not.toBeCalled(); + const noCallbackCalls = logSpy.mock.calls.filter( + (args) => + typeof args[0] === 'string' && + args[0].includes('No callback received from native layer') + ); + expect(noCallbackCalls).toHaveLength(0); + logSpy.mockRestore(); + }); + }); }); }); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 194388df1..5420219ea 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -27,20 +27,6 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const defaultConfig = new IterableConfig(); -/** - * Fallback safety-net timeout for the auth callback latch when no native - * auth success/failure event arrives. Overridable via - * {@link IterableConfig.authCallbackTimeoutMs}. - */ -const AUTH_CALLBACK_TIMEOUT_DEFAULT_MS = 1000; - -/** - * Default delay (ms) the SDK waits on Android before invoking the URL handler - * so the host Activity can wake from the background. Overridable via - * {@link IterableConfig.androidWakeDelayMs}. - */ -const ANDROID_WAKE_DELAY_DEFAULT_MS = 1000; - /** * Checks if the response is an IterableAuthResponse */ @@ -1027,9 +1013,7 @@ export class Iterable { // dispatching the URL to the handler. Without this delay the // handler can race the activity lifecycle and drop the link on // cold start. Tunable via IterableConfig.androidWakeDelayMs. - const wakeDelayMs = - Iterable.savedConfig.androidWakeDelayMs ?? - ANDROID_WAKE_DELAY_DEFAULT_MS; + const wakeDelayMs = Iterable.savedConfig.androidWakeDelayMs; if (wakeDelayMs > 0) { setTimeout(() => { callUrlHandler(Iterable.savedConfig, url, context); @@ -1074,26 +1058,42 @@ export class Iterable { | IterableAuthResponseResult | typeof AUTH_RESULT_NO_CALLBACK; - // Event-driven auth latch. The native success/failure listeners - // resolve the latch when their event arrives; the safety-net timer - // resolves it only if no native event arrives within the configured - // window. The timer is a fallback — the latch resolves immediately - // when the native event fires. + // Per-invocation auth latch state. The native success/failure events + // resolve the latch when they arrive; the safety-net timer resolves it + // only if no native event arrives within the configured window. The + // timer is a fallback — the latch resolves immediately when the native + // event fires. // - // `pendingAuthResult` buffers a native result that arrives before the + // `pendingResult` buffers a native result that arrives before the // latch is created (e.g. the authHandler promise hasn't settled yet). // It is consumed when the latch is created, so out-of-order events // are not lost. - let authLatchResolver: - | ((result: IterableAuthResponseResult) => void) - | null = null; - let pendingAuthResult: IterableAuthResponseResult | null = null; + // + // State is scoped per `handleAuthCalled` invocation so overlapping + // invocations cannot wipe each other's resolver or buffered result + // (the bug with the original shared `authLatchResolver` / + // `pendingAuthResult` declarations). The bridge events carry no + // correlation id, so native success/failure events are routed to the + // oldest still-pending invocation in FIFO order — matching the + // original single-flight assumption that native processes auth + // requests in the order the SDK issues them. + type AuthInvocation = { + resolver: + | ((result: IterableAuthResponseResult) => void) + | null; + pendingResult: IterableAuthResponseResult | null; + }; + const pendingAuthInvocations: AuthInvocation[] = []; RNEventEmitter.addListener(IterableEventName.handleAuthCalled, () => { - // Reset per-invocation state so a stale buffered result from a - // previous invocation cannot bleed into this one. - authLatchResolver = null; - pendingAuthResult = null; + // Start a fresh per-invocation state object so a stale buffered + // result or resolver from a previous invocation cannot bleed into + // this one. + const invocation: AuthInvocation = { + resolver: null, + pendingResult: null, + }; + pendingAuthInvocations.push(invocation); // MOB-10423: Check if we can use chain operator (?.) here instead // Asks frontend of the client/app to pass authToken @@ -1109,23 +1109,27 @@ export class Iterable { if (isIterableAuthResponse(promiseResult)) { Iterable.authManager.passAlongAuthToken(promiseResult.authToken); + // If this invocation was already removed (e.g. a buffered + // native result already resolved it via another path), do not + // wire a latch for it. + if (!pendingAuthInvocations.includes(invocation)) { + return; + } + const nativeLatch = new Promise( (resolve) => { - if (pendingAuthResult !== null) { + if (invocation.pendingResult !== null) { // A native event arrived before the latch was created; // resolve immediately with the buffered result. - const buffered = pendingAuthResult; - pendingAuthResult = null; - resolve(buffered); + resolve(invocation.pendingResult); + invocation.pendingResult = null; } else { - authLatchResolver = resolve; + invocation.resolver = resolve; } } ); - const timeoutMs = - Iterable.savedConfig.authCallbackTimeoutMs ?? - AUTH_CALLBACK_TIMEOUT_DEFAULT_MS; + const timeoutMs = Iterable.savedConfig.authCallbackTimeoutMs; const timeoutLatch = new Promise((resolve) => { setTimeout( () => resolve(AUTH_RESULT_NO_CALLBACK), @@ -1135,10 +1139,19 @@ export class Iterable { Promise.race([nativeLatch, timeoutLatch]).then( (result) => { - // Clear the resolver so a late native event after the timeout - // is a no-op. - authLatchResolver = null; - pendingAuthResult = null; + // Clear this invocation's resolver so a late native event + // after the timeout is a no-op. Only touch this + // invocation's state — other invocations own their own. + invocation.resolver = null; + invocation.pendingResult = null; + // Defensive cleanup: if the native event did not remove + // this invocation from the queue (e.g. the safety-net + // timer won the race), remove it now so future native + // events route to the next pending invocation. + const index = pendingAuthInvocations.indexOf(invocation); + if (index !== -1) { + pendingAuthInvocations.splice(index, 1); + } if (result === IterableAuthResponseResult.SUCCESS) { promiseResult.successCallback?.(); } else if (result === IterableAuthResponseResult.FAILURE) { @@ -1170,32 +1183,46 @@ export class Iterable { RNEventEmitter.addListener( IterableEventName.handleAuthSuccessCalled, () => { + // Route to the oldest still-pending invocation (FIFO). The bridge + // events carry no correlation id; this matches the assumption + // that native processes auth requests in issuance order. + const invocation = pendingAuthInvocations[0]; + if (!invocation) { + return; + } // Resolve the pending auth latch immediately; the timer becomes a // no-op for this invocation. - if (authLatchResolver) { - const resolve = authLatchResolver; - authLatchResolver = null; - pendingAuthResult = null; + if (invocation.resolver) { + const resolve = invocation.resolver; + invocation.resolver = null; + invocation.pendingResult = null; + // Remove synchronously so the next native event routes to the + // following pending invocation, not back to this one. + pendingAuthInvocations.shift(); resolve(IterableAuthResponseResult.SUCCESS); } else { // Latch not created yet — buffer the result for the latch. - pendingAuthResult = IterableAuthResponseResult.SUCCESS; + invocation.pendingResult = IterableAuthResponseResult.SUCCESS; } } ); RNEventEmitter.addListener( IterableEventName.handleAuthFailureCalled, (authFailureResponse: IterableAuthFailure) => { - // Resolve the pending auth latch immediately; the timer becomes a - // no-op for this invocation. - if (authLatchResolver) { - const resolve = authLatchResolver; - authLatchResolver = null; - pendingAuthResult = null; - resolve(IterableAuthResponseResult.FAILURE); - } else { - // Latch not created yet — buffer the result for the latch. - pendingAuthResult = IterableAuthResponseResult.FAILURE; + const invocation = pendingAuthInvocations[0]; + if (invocation) { + // Resolve the pending auth latch immediately; the timer becomes a + // no-op for this invocation. + if (invocation.resolver) { + const resolve = invocation.resolver; + invocation.resolver = null; + invocation.pendingResult = null; + pendingAuthInvocations.shift(); + resolve(IterableAuthResponseResult.FAILURE); + } else { + // Latch not created yet — buffer the result for the latch. + invocation.pendingResult = IterableAuthResponseResult.FAILURE; + } } // Call the actual JWT error with `authFailure` object. diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index cf35e1f7e..8d0584ec6 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -372,19 +372,24 @@ export class IterableConfig { * resolves immediately when the native event arrives. * * @remarks - * Increase this value on networks or devices where native auth round-trips - * are slow. Setting it too low can cause premature "no callback received" - * warnings. Setting it too high increases perceived auth latency only when - * the native layer fails to respond. + * The default (`6000`) is chosen to comfortably exceed a typical mobile + * auth round-trip (the native layer performs `passAlongAuthToken` plus a + * full HTTP round-trip to the Iterable backend, which routinely exceeds + * 1 s on real networks) while staying well below the native auth latch + * ceiling of 30 s on both iOS and Android. Setting it below typical + * round-trip latency causes the safety net to win the race and drop the + * late native `successCallback` / `failureCallback`; setting it too high + * increases perceived auth latency only when the native layer fails to + * respond. * * @example * ```typescript * const config = new IterableConfig(); - * config.authCallbackTimeoutMs = 2000; // wait up to 2s for native auth events + * config.authCallbackTimeoutMs = 10000; // allow up to 10s for slow networks * Iterable.initialize('', config); * ``` */ - authCallbackTimeoutMs = 1000; + authCallbackTimeoutMs = 6000; /** * Should the SDK enable and use embedded messaging? From 36a1ee559bf844784404b44f1a82a7956ba58f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 9 Jul 2026 11:42:26 +0100 Subject: [PATCH 3/3] fix(SDK-520): drain auth FIFO queue on non-happy paths to prevent zombie invocations - Add removeInvocation() in handleAuthCalled; call it in the string, null/undefined, unexpected-type, and .catch branches so rejected/non-IterableAuthResponse auth handlers no longer leave a zombie at queue head - Prevents the next invocation's native success/failure event from routing to a dead invocation and dropping its callback - Add regression test: handleAuthCalled -> reject -> handleAuthCalled -> resolve -> native success asserts second successCallback fires with no 'No callback received' warning --- src/core/classes/Iterable.test.ts | 81 +++++++++++++++++++++++++++++++ src/core/classes/Iterable.ts | 20 +++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 376cb872f..baccc3e41 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -1998,5 +1998,86 @@ describe('Iterable', () => { logSpy.mockRestore(); }); }); + + it('should not block the next callback when authHandler rejects (SDK-520 follow-up regression)', async () => { + // Regression for the zombie-invocation bug: when an authHandler + // rejects, the .catch path previously left the invocation at the head + // of pendingAuthInvocations. The late native event for the rejected + // invocation buffered into the zombie, so the next real invocation's + // native success event routed to the zombie and its successCallback + // was silently dropped. With removeInvocation() in the .catch path, + // the rejected invocation is dropped from the queue before native's + // late event arrives, so the next invocation's native event routes + // correctly. + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleAuthCalled); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthSuccessCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleAuthFailureCalled + ); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.authCallbackTimeoutMs = 2000; + + const successCallback2 = jest.fn(); + const failureCallback2 = jest.fn(); + const authResponse2 = new IterableAuthResponse(); + authResponse2.authToken = 'retry-success-token'; + authResponse2.successCallback = successCallback2; + authResponse2.failureCallback = failureCallback2; + + // inv1 rejects (authHandler throws); inv2 resolves with an + // IterableAuthResponse. Controllable promises let us sequence the + // second resolve after the first rejection has settled. + let resolveAuth2: (value: IterableAuthResponse) => void = () => {}; + let callCount = 0; + config.authHandler = jest.fn(() => { + callCount += 1; + if (callCount === 1) { + return Promise.reject(new Error('Auth failed (inv1)')); + } + return new Promise((resolve) => { + resolveAuth2 = resolve; + }); + }); + + const logSpy = jest.spyOn(IterableLogger, 'log'); + + Iterable.initialize('apiKey', config); + + // WHEN the first handleAuthCalled fires and authHandler rejects. + // The .catch path must remove inv1 from the queue. + nativeEmitter.emit(IterableEventName.handleAuthCalled); + // Let the rejection settle (microtask queue flush). + await new Promise((resolve) => setTimeout(resolve, 0)); + + // WHEN a second handleAuthCalled fires and authHandler resolves with + // an IterableAuthResponse, wiring inv2's latch. + nativeEmitter.emit(IterableEventName.handleAuthCalled); + resolveAuth2(authResponse2); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // THEN the native success event for inv2 routes to inv2 (not to a + // zombie at queue head) and inv2's successCallback fires. + nativeEmitter.emit(IterableEventName.handleAuthSuccessCalled); + + return await TestHelper.delayed(50, () => { + expect(successCallback2).toBeCalled(); + expect(failureCallback2).not.toBeCalled(); + // No "No callback received" warning: if inv1 had stayed at queue + // head, inv2's native event would have routed to the zombie and + // inv2's safety-net timer would have fired NO_CALLBACK. + const noCallbackCalls = logSpy.mock.calls.filter( + (args) => + typeof args[0] === 'string' && + args[0].includes('No callback received from native layer') + ); + expect(noCallbackCalls).toHaveLength(0); + logSpy.mockRestore(); + }); + }); }); }); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 5420219ea..aaaa84073 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1095,6 +1095,18 @@ export class Iterable { }; pendingAuthInvocations.push(invocation); + // Drop this invocation from the FIFO queue. The bridge events carry + // no correlation id, so a late native event for this invocation may + // route to the new queue head — a pre-existing bridge limitation that + // is strictly better than leaving a zombie at head (which would + // deterministically block the next real callback). + const removeInvocation = () => { + const index = pendingAuthInvocations.indexOf(invocation); + if (index !== -1) { + pendingAuthInvocations.splice(index, 1); + } + }; + // MOB-10423: Check if we can use chain operator (?.) here instead // Asks frontend of the client/app to pass authToken Iterable.savedConfig.authHandler!() @@ -1167,17 +1179,23 @@ export class Iterable { } else if (typeof promiseResult === 'string') { // If promise only returns string Iterable.authManager.passAlongAuthToken(promiseResult); + removeInvocation(); } else if (promiseResult === null || promiseResult === undefined) { // Even though this will cause authentication to fail, we want to // allow for this for JWT handling. Iterable.authManager.passAlongAuthToken(promiseResult); + removeInvocation(); } else { IterableLogger?.log( 'Unexpected promise returned. Auth token expects promise of String, null, undefined, or AuthResponse type.' ); + removeInvocation(); } }) - .catch((e) => IterableLogger?.log(e)); + .catch((e) => { + IterableLogger?.log(e); + removeInvocation(); + }); }); RNEventEmitter.addListener(