From d03e737d090e9bb1b5df63f144612a4c27707e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 11:22:07 +0100 Subject: [PATCH 01/16] feat(SDK-554): expose IterableConfig.enableForegroundCriteriaFetch Wire through iOS and Android Serialization for UUA foreground criteria fetch. --- CHANGELOG.md | 6 ++++++ .../java/com/iterable/reactnative/Serialization.java | 4 ++++ ios/RNIterableAPI/Serialization.swift | 4 ++++ src/core/classes/Iterable.test.ts | 3 +++ src/core/classes/IterableConfig.ts | 9 +++++++++ 5 files changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 771a9fca4..3f04ac24a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Updates + +- Added `IterableConfig.enableForegroundCriteriaFetch` (default `true`) to control whether the SDK fetches unknown-user targeting criteria when the app enters the foreground (SDK-554). + ## 3.2.1 ### Fixes diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 97aa52f49..9467990b1 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -246,6 +246,10 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setDataRegion(iterableDataRegion); } + if (iterableContextJSON.has("enableForegroundCriteriaFetch")) { + configBuilder.setEnableForegroundCriteriaFetch(iterableContextJSON.optBoolean("enableForegroundCriteriaFetch")); + } + if (iterableContextJSON.has("enableEmbeddedMessaging")) { configBuilder.setEnableEmbeddedMessaging(iterableContextJSON.optBoolean("enableEmbeddedMessaging")); } diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index fee4c49b6..33bd2b5a5 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -83,6 +83,10 @@ extension IterableConfig { config.useInMemoryStorageForInApps = useInMemoryStorageForInApp } + if let enableForegroundCriteriaFetch = dict["enableForegroundCriteriaFetch"] as? Bool { + config.enableForegroundCriteriaFetch = enableForegroundCriteriaFetch + } + if let enableEmbeddedMessaging = dict["enableEmbeddedMessaging"] as? Bool { config.enableEmbeddedMessaging = enableEmbeddedMessaging } diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..f8b6d0295 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -325,6 +325,7 @@ describe('Iterable', () => { expect(config.checkForDeferredDeeplink).toBe(false); expect(config.customActionHandler).toBe(undefined); expect(config.dataRegion).toBe(IterableDataRegion.US); + expect(config.enableForegroundCriteriaFetch).toBe(true); expect(config.enableEmbeddedMessaging).toBe(false); expect(config.encryptionEnforced).toBe(false); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); @@ -344,6 +345,7 @@ describe('Iterable', () => { expect(configDict.autoPushRegistration).toBe(true); expect(configDict.customActionHandlerPresent).toBe(false); expect(configDict.dataRegion).toBe(IterableDataRegion.US); + expect(configDict.enableForegroundCriteriaFetch).toBe(true); expect(configDict.enableEmbeddedMessaging).toBe(false); expect(configDict.encryptionEnforced).toBe(false); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); @@ -1249,6 +1251,7 @@ describe('Iterable', () => { describe('embeddedManager', () => { it('should be disabled by default', () => { const config = new IterableConfig(); + expect(config.enableForegroundCriteriaFetch).toBe(true); expect(config.enableEmbeddedMessaging).toBe(false); expect(Iterable.embeddedManager.isEnabled).toBe(false); }); diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..5dcf64e04 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -391,6 +391,14 @@ export class IterableConfig { */ authCallbackTimeoutMs = 6000; + /** + * When `true` (the default), the SDK fetches unknown-user (anonymous) targeting + * criteria when the app enters the foreground. + * + * Set to `false` to disable that foreground fetch. + */ + enableForegroundCriteriaFetch = true; + /** * Should the SDK enable and use embedded messaging? * @@ -484,6 +492,7 @@ export class IterableConfig { pushPlatform: this.pushPlatform, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, + enableForegroundCriteriaFetch: this.enableForegroundCriteriaFetch, enableEmbeddedMessaging: this.enableEmbeddedMessaging, androidWakeDelayMs: this.androidWakeDelayMs, authCallbackTimeoutMs: this.authCallbackTimeoutMs, From 75272c61765852bed7306e8adb9fce84de013dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 11:38:58 +0100 Subject: [PATCH 02/16] test(SDK-554): tighten IterableConfig serialization coverage Cover toDict() when enableForegroundCriteriaFetch is false. Remove duplicate default assert from embeddedManager tests. --- src/core/classes/Iterable.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index f8b6d0295..12ff2b787 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -367,6 +367,13 @@ describe('Iterable', () => { expect(configDict.androidWakeDelayMs).toBe(1500); expect(configDict.authCallbackTimeoutMs).toBe(2500); }); + + it('should include enableForegroundCriteriaFetch in toDict() when set to false', () => { + const config = new IterableConfig(); + config.enableForegroundCriteriaFetch = false; + const configDict = config.toDict(); + expect(configDict.enableForegroundCriteriaFetch).toBe(false); + }); }); describe('urlHandler', () => { @@ -1251,7 +1258,6 @@ describe('Iterable', () => { describe('embeddedManager', () => { it('should be disabled by default', () => { const config = new IterableConfig(); - expect(config.enableForegroundCriteriaFetch).toBe(true); expect(config.enableEmbeddedMessaging).toBe(false); expect(Iterable.embeddedManager.isEnabled).toBe(false); }); From 523be9b55c5ac4fd5c75834172bfb1bc2a5a38c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 11:53:50 +0100 Subject: [PATCH 03/16] feat(SDK-556): expose IterableConfig.identityResolution Add IterableIdentityResolution type and wire defaults through iOS/Android Serialization. --- CHANGELOG.md | 6 +++ .../iterable/reactnative/Serialization.java | 12 +++++ ios/RNIterableAPI/Serialization.swift | 10 ++++ src/core/classes/Iterable.test.ts | 51 +++++++++++++++++++ src/core/classes/IterableConfig.ts | 19 +++++++ src/core/types/IterableIdentityResolution.ts | 20 ++++++++ src/core/types/index.ts | 1 + src/index.tsx | 1 + 8 files changed, 120 insertions(+) create mode 100644 src/core/types/IterableIdentityResolution.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 771a9fca4..ce27548ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Updates + +- Added `IterableConfig.identityResolution` and the `IterableIdentityResolution` type so RN apps can configure default visitor replay and unknown-to-known merge behavior (SDK-556). + ## 3.2.1 ### Fixes diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 97aa52f49..8306e5feb 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -22,6 +22,7 @@ import com.iterable.iterableapi.IterableInAppHandler; import com.iterable.iterableapi.IterableInAppLocation; import com.iterable.iterableapi.IterableInAppMessage; +import com.iterable.iterableapi.IterableIdentityResolution; import com.iterable.iterableapi.IterableInboxSession; import com.iterable.iterableapi.IterableLogger; import com.iterable.iterableapi.RetryPolicy; @@ -262,6 +263,17 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setAuthRetryPolicy(new RetryPolicy(maxRetry, retryInterval, retryPolicyType)); } + if (iterableContextJSON.has("identityResolution")) { + JSONObject identityResolutionJson = + iterableContextJSON.getJSONObject("identityResolution"); + boolean replayOnVisitorToKnown = + identityResolutionJson.optBoolean("replayOnVisitorToKnown", true); + boolean mergeOnUnknownToKnown = + identityResolutionJson.optBoolean("mergeOnUnknownToKnown", true); + configBuilder.setIdentityResolution( + new IterableIdentityResolution(replayOnVisitorToKnown, mergeOnUnknownToKnown)); + } + return configBuilder; } catch (JSONException e) { e.printStackTrace(); diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index fee4c49b6..0e53aa23a 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -110,6 +110,16 @@ extension IterableConfig { } } + if let identityResolutionDict = dict["identityResolution"] as? [AnyHashable: Any] { + let replayOnVisitorToKnown = + identityResolutionDict["replayOnVisitorToKnown"] as? Bool ?? true + let mergeOnUnknownToKnown = + identityResolutionDict["mergeOnUnknownToKnown"] as? Bool ?? true + config.identityResolution = IterableIdentityResolution( + replayOnVisitorToKnown: replayOnVisitorToKnown, + mergeOnUnknownToKnown: mergeOnUnknownToKnown) + } + return config } diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..51001e1f7 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -337,6 +337,10 @@ describe('Iterable', () => { expect(config.useInMemoryStorageForInApps).toBe(false); expect(config.androidWakeDelayMs).toBe(1000); expect(config.authCallbackTimeoutMs).toBe(6000); + expect(config.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); const configDict = config.toDict(); expect(configDict.allowedProtocols).toEqual([]); expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); @@ -355,6 +359,10 @@ describe('Iterable', () => { expect(configDict.useInMemoryStorageForInApps).toBe(false); expect(configDict.androidWakeDelayMs).toBe(1000); expect(configDict.authCallbackTimeoutMs).toBe(6000); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); }); it('should allow overriding androidWakeDelayMs and authCallbackTimeoutMs', () => { @@ -365,6 +373,49 @@ describe('Iterable', () => { expect(configDict.androidWakeDelayMs).toBe(1500); expect(configDict.authCallbackTimeoutMs).toBe(2500); }); + + it('should include identityResolution in toDict() with overridden values', () => { + const config = new IterableConfig(); + config.identityResolution = { + replayOnVisitorToKnown: false, + mergeOnUnknownToKnown: false, + }; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: false, + mergeOnUnknownToKnown: false, + }); + }); + + it('should default missing identityResolution keys to true in toDict()', () => { + const config = new IterableConfig(); + config.identityResolution = { mergeOnUnknownToKnown: false }; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: false, + }); + }); + + it('should default replayOnVisitorToKnown to true when only merge is set', () => { + const config = new IterableConfig(); + config.identityResolution = { replayOnVisitorToKnown: false }; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: false, + mergeOnUnknownToKnown: true, + }); + }); + + it('should default both identityResolution keys to true for an empty object', () => { + const config = new IterableConfig(); + config.identityResolution = {}; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); + }); }); describe('urlHandler', () => { diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..0085cf5b5 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -4,6 +4,7 @@ import { IterableDataRegion } from '../enums/IterableDataRegion'; import { IterableLogLevel } from '../enums/IterableLogLevel'; import { IterablePushPlatform } from '../enums/IterablePushPlatform'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import type { IterableIdentityResolution } from '../types/IterableIdentityResolution'; import type { IterableRetryPolicy } from '../types/IterableRetryPolicy'; import { IterableAction } from './IterableAction'; import type { IterableActionContext } from './IterableActionContext'; @@ -391,6 +392,18 @@ export class IterableConfig { */ authCallbackTimeoutMs = 6000; + /** + * Default identity-resolution behavior when identifying a user with + * `Iterable.setEmail` or `Iterable.setUserId`. + * + * Applies when unknown-user activation and visitor tracking are enabled on the + * native SDK. Omitted keys in a partial object default to `true`. + */ + identityResolution: IterableIdentityResolution = { + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }; + /** * Should the SDK enable and use embedded messaging? * @@ -484,6 +497,12 @@ export class IterableConfig { pushPlatform: this.pushPlatform, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, + identityResolution: { + replayOnVisitorToKnown: + this.identityResolution.replayOnVisitorToKnown ?? true, + mergeOnUnknownToKnown: + this.identityResolution.mergeOnUnknownToKnown ?? true, + }, enableEmbeddedMessaging: this.enableEmbeddedMessaging, androidWakeDelayMs: this.androidWakeDelayMs, authCallbackTimeoutMs: this.authCallbackTimeoutMs, diff --git a/src/core/types/IterableIdentityResolution.ts b/src/core/types/IterableIdentityResolution.ts new file mode 100644 index 000000000..e6b45639c --- /dev/null +++ b/src/core/types/IterableIdentityResolution.ts @@ -0,0 +1,20 @@ +/** + * Controls how the SDK resolves anonymous (visitor / unknown-user) data when a + * user is identified with `setEmail` or `setUserId`. + * + * Values set on `IterableConfig.identityResolution` are defaults; native + * `setEmail` / `setUserId` can override them per call (not exposed in RN yet). + */ +export interface IterableIdentityResolution { + /** + * When `true`, locally stored visitor events are replayed to the known user + * profile on identification. + */ + replayOnVisitorToKnown?: boolean; + + /** + * When `true`, the generated unknown-user profile is merged into the known + * user profile on identification. + */ + mergeOnUnknownToKnown?: boolean; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 7659a76e4..f133be55e 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -1,3 +1,4 @@ export * from './IterableAuthFailure'; export * from './IterableEdgeInsetDetails'; +export * from './IterableIdentityResolution'; export * from './IterableRetryPolicy'; diff --git a/src/index.tsx b/src/index.tsx index 13cb6915d..92a262a3a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,6 +30,7 @@ export { export type { IterableAuthFailure, IterableEdgeInsetDetails, + IterableIdentityResolution, IterableRetryPolicy, } from './core/types'; export { From 0889a67f37bfd55ee7fe1509b590b6fa939d0143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 11:58:10 +0100 Subject: [PATCH 04/16] test(SDK-556): fix partial identityResolution test title Name the case that sets only replayOnVisitorToKnown. --- src/core/classes/Iterable.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 51001e1f7..420bff79e 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -397,7 +397,7 @@ describe('Iterable', () => { }); }); - it('should default replayOnVisitorToKnown to true when only merge is set', () => { + it('should default mergeOnUnknownToKnown to true when only replayOnVisitorToKnown is set', () => { const config = new IterableConfig(); config.identityResolution = { replayOnVisitorToKnown: false }; const configDict = config.toDict(); From 323f147f8715fa91550119cab7b382c8f416483d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 12:02:45 +0100 Subject: [PATCH 05/16] feat(SDK-553): expose IterableConfig.eventThresholdLimit Wire the unknown-user local event cap through iOS and Android Serialization. --- CHANGELOG.md | 6 ++++++ .../java/com/iterable/reactnative/Serialization.java | 5 +++++ ios/RNIterableAPI/Serialization.swift | 4 ++++ src/core/classes/Iterable.test.ts | 9 +++++++++ src/core/classes/IterableConfig.ts | 10 ++++++++++ 5 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 771a9fca4..cb98f9c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Updates + +- Added `IterableConfig.eventThresholdLimit` (default `100`) to cap locally stored unknown-user events before identification (SDK-553). Applies when unknown-user activation is enabled on the native SDK. + ## 3.2.1 ### Fixes diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 97aa52f49..51c5d3368 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -201,6 +201,11 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setExpiringAuthTokenRefreshPeriod(iterableContextJSON.optLong("expiringAuthTokenRefreshPeriod")); } + if (iterableContextJSON.has("eventThresholdLimit")) { + configBuilder.setEventThresholdLimit( + iterableContextJSON.optInt("eventThresholdLimit", 100)); + } + if (iterableContextJSON.has("useInMemoryStorageForInApps") || iterableContextJSON.has("androidSdkUseInMemoryStorageForInApps")) { configBuilder.setUseInMemoryStorageForInApps(iterableContextJSON.optBoolean("useInMemoryStorageForInApps")); } diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index fee4c49b6..b0b1bda2c 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -75,6 +75,10 @@ extension IterableConfig { config.expiringAuthTokenRefreshPeriod = expiringAuthTokenRefreshPeriod } + if let eventThresholdLimit = dict["eventThresholdLimit"] as? Int { + config.eventThresholdLimit = eventThresholdLimit + } + if let logLevelNumber = dict["logLevel"] as? NSNumber { config.logDelegate = createLogDelegate(logLevelNumber: logLevelNumber) } diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..53e8dd69a 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -328,6 +328,7 @@ describe('Iterable', () => { expect(config.enableEmbeddedMessaging).toBe(false); expect(config.encryptionEnforced).toBe(false); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); + expect(config.eventThresholdLimit).toBe(100); expect(config.inAppDisplayInterval).toBe(30.0); expect(config.inAppHandler).toBe(undefined); expect(config.logLevel).toBe(IterableLogLevel.debug); @@ -347,6 +348,7 @@ describe('Iterable', () => { expect(configDict.enableEmbeddedMessaging).toBe(false); expect(configDict.encryptionEnforced).toBe(false); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); + expect(configDict.eventThresholdLimit).toBe(100); expect(configDict.inAppDisplayInterval).toBe(30.0); expect(configDict.inAppHandlerPresent).toBe(false); expect(configDict.logLevel).toBe(IterableLogLevel.debug); @@ -365,6 +367,13 @@ describe('Iterable', () => { expect(configDict.androidWakeDelayMs).toBe(1500); expect(configDict.authCallbackTimeoutMs).toBe(2500); }); + + it('should include eventThresholdLimit in toDict() when overridden', () => { + const config = new IterableConfig(); + config.eventThresholdLimit = 50; + const configDict = config.toDict(); + expect(configDict.eventThresholdLimit).toBe(50); + }); }); describe('urlHandler', () => { diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..a262c2613 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -262,6 +262,15 @@ export class IterableConfig { */ expiringAuthTokenRefreshPeriod = 60.0; + /** + * Maximum number of unknown-user (anonymous) events stored locally before + * identification. The native SDK drops the oldest events when the count + * exceeds this limit. + * + * Applies when unknown-user activation is enabled on the native SDK. + */ + eventThresholdLimit = 100; + /** * Use this array to declare the specific URL protocols that the SDK can * expect to see on incoming links from Iterable, so it knows that it can @@ -476,6 +485,7 @@ export class IterableConfig { /** The log level for the SDK. */ logLevel: this.logLevel, expiringAuthTokenRefreshPeriod: this.expiringAuthTokenRefreshPeriod, + eventThresholdLimit: this.eventThresholdLimit, allowedProtocols: this.allowedProtocols, androidSdkUseInMemoryStorageForInApps: this.androidSdkUseInMemoryStorageForInApps, From c5143d6c0c57b693ef7b5037526b414ba6ba2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 12:12:59 +0100 Subject: [PATCH 06/16] docs(SDK-553): note eventThresholdLimit is inert until UUA is exposed Clarify changelog and TSDoc: RN has no enableUnknownUserActivation yet. --- CHANGELOG.md | 2 +- src/core/classes/IterableConfig.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb98f9c5f..d2e73b9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Updates -- Added `IterableConfig.eventThresholdLimit` (default `100`) to cap locally stored unknown-user events before identification (SDK-553). Applies when unknown-user activation is enabled on the native SDK. +- Added `IterableConfig.eventThresholdLimit` (default `100`) to cap locally stored unknown-user events before identification (SDK-553). The value is forwarded to the native SDK; it has no runtime effect in React Native until `enableUnknownUserActivation` is exposed in a future release (that switch is not on the RN config surface yet). ## 3.2.1 diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index a262c2613..e30639bbd 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -267,7 +267,10 @@ export class IterableConfig { * identification. The native SDK drops the oldest events when the count * exceeds this limit. * - * Applies when unknown-user activation is enabled on the native SDK. + * The value is sent to the native SDK at init. React Native does not expose + * `enableUnknownUserActivation` yet, so this limit has no observable effect + * until that option is added; when it is, the limit applies only while + * unknown-user activation is enabled. */ eventThresholdLimit = 100; From a62987ff2cfcdbd168ac6b2913f8dc79a60eee53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 12:28:13 +0100 Subject: [PATCH 07/16] feat(SDK-561): expose visitor usage consent on Iterable Add setVisitorUsageTracked and getVisitorUsageTracked across the TurboModule bridge. Guard calls until initialize resolves to avoid Android pre-init crashes. --- CHANGELOG.md | 6 ++ .../reactnative/RNIterableAPIModuleImpl.java | 9 ++ .../newarch/java/com/RNIterableAPIModule.java | 10 ++ .../oldarch/java/com/RNIterableAPIModule.java | 10 ++ ios/RNIterableAPI/RNIterableAPI.mm | 18 ++++ ios/RNIterableAPI/ReactIterableAPI.swift | 15 +++ src/__mocks__/MockRNIterableAPI.ts | 15 +++ src/api/NativeRNIterableAPI.ts | 2 + src/core/classes/Iterable.test.ts | 95 +++++++++++++++++++ src/core/classes/Iterable.ts | 53 ++++++++++- src/core/classes/IterableApi.test.ts | 19 ++++ src/core/classes/IterableApi.ts | 10 ++ 12 files changed, 261 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 771a9fca4..729eb38e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Updates + +- Added `Iterable.setVisitorUsageTracked` and `Iterable.getVisitorUsageTracked` to record and read visitor consent for Unknown User Activation (SDK-561). Consent is persisted by the native SDK; unknown-user activation is not enabled from React Native config yet. + ## 3.2.1 ### Fixes diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index c3396295d..b998b468a 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -249,6 +249,15 @@ public void getUserId(Promise promise) { promise.resolve(RNIterableInternal.getUserId()); } + public void setVisitorUsageTracked(boolean tracked) { + IterableLogger.d(TAG, "setVisitorUsageTracked: " + tracked); + IterableApi.getInstance().setVisitorUsageTracked(tracked); + } + + public void getVisitorUsageTracked(Promise promise) { + promise.resolve(IterableApi.getInstance().getVisitorUsageTracked()); + } + public void trackEvent(String name, @Nullable ReadableMap dataFields) { IterableLogger.v(TAG, "trackEvent"); IterableApi.getInstance().track(name, optSerializedDataFields(dataFields)); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index fb71142b8..0cd0d9533 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -72,6 +72,16 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @Override + public void setVisitorUsageTracked(boolean tracked) { + moduleImpl.setVisitorUsageTracked(tracked); + } + + @Override + public void getVisitorUsageTracked(Promise promise) { + moduleImpl.getVisitorUsageTracked(promise); + } + @Override public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index c2f9b3161..9efe250ce 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -73,6 +73,16 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @ReactMethod + public void setVisitorUsageTracked(boolean tracked) { + moduleImpl.setVisitorUsageTracked(tracked); + } + + @ReactMethod + public void getVisitorUsageTracked(Promise promise) { + moduleImpl.getVisitorUsageTracked(promise); + } + @ReactMethod public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 965b2d81e..745307dfd 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -112,6 +112,15 @@ - (void)getUserId:(RCTPromiseResolveBlock)resolve [_swiftAPI getUserId:resolve rejecter:reject]; } +- (void)setVisitorUsageTracked:(BOOL)tracked { + [_swiftAPI setVisitorUsageTracked:tracked]; +} + +- (void)getVisitorUsageTracked:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_swiftAPI getVisitorUsageTracked:resolve rejecter:reject]; +} + - (void)setInAppShowResponse:(double)inAppShowResponse { [_swiftAPI setInAppShowResponse:inAppShowResponse]; } @@ -403,6 +412,15 @@ - (void)wakeApp { [_swiftAPI getUserId:resolve rejecter:reject]; } +RCT_EXPORT_METHOD(setVisitorUsageTracked : (BOOL)tracked) { + [_swiftAPI setVisitorUsageTracked:tracked]; +} + +RCT_EXPORT_METHOD(getVisitorUsageTracked : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { + [_swiftAPI getVisitorUsageTracked:resolve rejecter:reject]; +} + RCT_EXPORT_METHOD(setInAppShowResponse : (double)inAppShowResponse) { [_swiftAPI setInAppShowResponse:inAppShowResponse]; } diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index a216a0e69..475edca3e 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -129,6 +129,21 @@ import React resolver(IterableAPI.userId) } + @objc(setVisitorUsageTracked:) + public func setVisitorUsageTracked(tracked: Bool) { + ITBInfo() + IterableAPI.setVisitorUsageTracked(isVisitorUsageTracked: tracked) + } + + @objc(getVisitorUsageTracked:rejecter:) + public func getVisitorUsageTracked( + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + ITBInfo() + resolver(IterableAPI.getVisitorUsageTracked()) + } + // MARK: - Iterable API Request Functions @objc(setInAppShowResponse:) diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 60013bdbb..407e093a1 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -9,6 +9,8 @@ export class MockRNIterableAPI { static attributionInfo?: IterableAttributionInfo; static messages?: IterableInAppMessage[]; static clickedUrl?: string; + static visitorUsageTracked = false; + static unknownVisitorEvents: string[] = []; static async getEmail(): Promise { return await new Promise((resolve) => { @@ -32,6 +34,19 @@ export class MockRNIterableAPI { MockRNIterableAPI.token = authToken; }); + static setVisitorUsageTracked = jest.fn((tracked: boolean): void => { + MockRNIterableAPI.visitorUsageTracked = tracked; + MockRNIterableAPI.unknownVisitorEvents = []; + }); + + static getVisitorUsageTracked = jest.fn(async (): Promise => { + return await Promise.resolve(MockRNIterableAPI.visitorUsageTracked); + }); + + static pushUnknownVisitorEventForTests(eventId: string): void { + MockRNIterableAPI.unknownVisitorEvents.push(eventId); + } + static disableDeviceForCurrentUser = jest.fn(); static registerDeviceToken = jest.fn((token: string): void => { diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index 7e96bc649..6c27f8d6d 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -85,6 +85,8 @@ export interface Spec extends TurboModule { getEmail(): Promise; setUserId(userId?: string | null, authToken?: string | null): void; getUserId(): Promise; + setVisitorUsageTracked(tracked: boolean): void; + getVisitorUsageTracked(): Promise; // In-app messaging setInAppShowResponse(number: number): void; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..44806ae88 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -81,6 +81,101 @@ describe('Iterable', () => { }); }); + describe('visitorUsageTracked', () => { + beforeEach(() => { + MockRNIterableAPI.visitorUsageTracked = false; + MockRNIterableAPI.unknownVisitorEvents = []; + Iterable.resetSdkInitializationStateForTests(); + }); + + it('should expose setVisitorUsageTracked and getVisitorUsageTracked on Iterable', () => { + expect(typeof Iterable.setVisitorUsageTracked).toBe('function'); + expect(typeof Iterable.getVisitorUsageTracked).toBe('function'); + }); + + it('should forward true and false to RNIterableAPI after initialize', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(true); + + Iterable.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(false); + }); + + it('should resolve getVisitorUsageTracked to the value stored in the mock', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + MockRNIterableAPI.visitorUsageTracked = true; + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(true); + + MockRNIterableAPI.visitorUsageTracked = false; + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should resolve to false when the integrator never calls the setter', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should return the persisted mock value after initialize runs again', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + Iterable.setVisitorUsageTracked(true); + + Iterable.resetSdkInitializationStateForTests(); + await Iterable.initialize('test-key', config); + + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(true); + }); + + it('should clear unknown-visitor events in the mock on each setVisitorUsageTracked call', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + MockRNIterableAPI.pushUnknownVisitorEventForTests('event-1'); + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.unknownVisitorEvents).toEqual([]); + + MockRNIterableAPI.pushUnknownVisitorEventForTests('event-2'); + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.unknownVisitorEvents).toEqual([]); + }); + + it('should not forward setVisitorUsageTracked before initialize resolves', async () => { + Iterable.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).not.toHaveBeenCalled(); + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(false); + }); + + it('should not call trackEvent, setEmail, or setUserId from visitor usage methods', async () => { + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + await Iterable.initialize('test-key', config); + + Iterable.setVisitorUsageTracked(true); + await Iterable.getVisitorUsageTracked(); + + expect(MockRNIterableAPI.trackEvent).not.toHaveBeenCalled(); + expect(MockRNIterableAPI.setEmail).not.toHaveBeenCalled(); + expect(MockRNIterableAPI.setUserId).not.toHaveBeenCalled(); + }); + }); + describe('logout', () => { it('should call setEmail with null', () => { // GIVEN no parameters diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index aaaa84073..3bdd1e56c 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -68,6 +68,15 @@ export class Iterable { */ static savedConfig: IterableConfig = defaultConfig; + private static sdkInitialized = false; + + /** + * @internal + */ + static resetSdkInitializationStateForTests(): void { + Iterable.sdkInitialized = false; + } + /** * In-app message manager for the current user. * @@ -157,12 +166,18 @@ export class Iterable { apiKey: string, config: IterableConfig = new IterableConfig() ): Promise { + Iterable.sdkInitialized = false; Iterable.savedConfig = config; this.setupIterable(config); const version = this.getVersionFromPackageJson(); - return IterableApi.initializeWithApiKey(apiKey, { config, version }); + return IterableApi.initializeWithApiKey(apiKey, { config, version }).then( + (initialized) => { + Iterable.sdkInitialized = true; + return initialized; + } + ); } /** @@ -176,6 +191,7 @@ export class Iterable { config: IterableConfig = new IterableConfig(), apiEndPoint: string ): Promise { + Iterable.sdkInitialized = false; this.setupIterable(config); const version = this.getVersionFromPackageJson(); @@ -184,6 +200,9 @@ export class Iterable { config, version, apiEndPoint, + }).then((initialized) => { + Iterable.sdkInitialized = true; + return initialized; }); } @@ -333,6 +352,38 @@ export class Iterable { return IterableApi.getUserId(); } + /** + * Record whether the end user has consented to visitor / unknown-user tracking. + * + * The value is persisted by the native SDK across app restarts. Each call + * clears locally stored unknown-visitor events on the native side. + * + * Unknown User Activation is not enabled from React Native config yet, so + * this only stores and returns the consent flag until + * `enableUnknownUserActivation` is exposed. + * + * No-op before `Iterable.initialize` has resolved (does not forward to native). + */ + static setVisitorUsageTracked(tracked: boolean): void { + if (!Iterable.sdkInitialized) { + return; + } + IterableApi.setVisitorUsageTracked(tracked); + } + + /** + * Returns whether visitor / unknown-user tracking consent has been recorded. + * + * Resolves to `false` before `Iterable.initialize` has resolved or if the + * integrator has never called `setVisitorUsageTracked`. + */ + static getVisitorUsageTracked(): Promise { + if (!Iterable.sdkInitialized) { + return Promise.resolve(false); + } + return IterableApi.getVisitorUsageTracked(); + } + /** * Disable the device's token for the current user. This will disable push notifications for the current user. * diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index d639d79ed..0e99e9a49 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -271,6 +271,25 @@ describe('IterableApi', () => { }); }); + describe('visitorUsageTracked', () => { + beforeEach(() => { + MockRNIterableAPI.visitorUsageTracked = false; + }); + + it('should call RNIterableAPI.setVisitorUsageTracked with the tracked flag', () => { + IterableApi.setVisitorUsageTracked(true); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(true); + + IterableApi.setVisitorUsageTracked(false); + expect(MockRNIterableAPI.setVisitorUsageTracked).toHaveBeenCalledWith(false); + }); + + it('should return visitor usage tracked from RNIterableAPI', async () => { + MockRNIterableAPI.visitorUsageTracked = true; + await expect(IterableApi.getVisitorUsageTracked()).resolves.toBe(true); + }); + }); + describe('disableDeviceForCurrentUser', () => { it('should call RNIterableAPI.disableDeviceForCurrentUser', () => { // GIVEN no parameters diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 2fad862a7..65377914f 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -129,6 +129,16 @@ export class IterableApi { return RNIterableAPI.getUserId(); } + static setVisitorUsageTracked(tracked: boolean) { + IterableLogger.log('setVisitorUsageTracked: ', tracked); + return RNIterableAPI.setVisitorUsageTracked(tracked); + } + + static getVisitorUsageTracked() { + IterableLogger.log('getVisitorUsageTracked'); + return RNIterableAPI.getVisitorUsageTracked(); + } + /** * Disable the device for the current user. */ From 37863f023651e572a700ee65a064525f51d2aee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Sat, 10 Oct 2026 13:57:02 +0100 Subject: [PATCH 08/16] docs(SDK-561): fix visitor consent setter TSDoc Describe storage on setVisitorUsageTracked and reads on getVisitorUsageTracked. --- src/core/classes/Iterable.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 3bdd1e56c..d7ab9944d 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -359,8 +359,8 @@ export class Iterable { * clears locally stored unknown-visitor events on the native side. * * Unknown User Activation is not enabled from React Native config yet, so - * this only stores and returns the consent flag until - * `enableUnknownUserActivation` is exposed. + * this only stores the consent flag until `enableUnknownUserActivation` is + * exposed. Use {@link Iterable.getVisitorUsageTracked} to read the stored value. * * No-op before `Iterable.initialize` has resolved (does not forward to native). */ @@ -374,8 +374,13 @@ export class Iterable { /** * Returns whether visitor / unknown-user tracking consent has been recorded. * + * Reads the value persisted by the native SDK (including across app restarts + * after a prior `setVisitorUsageTracked` call). + * * Resolves to `false` before `Iterable.initialize` has resolved or if the - * integrator has never called `setVisitorUsageTracked`. + * integrator has never called `setVisitorUsageTracked`. Until + * `enableUnknownUserActivation` is exposed on `IterableConfig`, the flag is + * stored only and does not enable unknown-user flows by itself. */ static getVisitorUsageTracked(): Promise { if (!Iterable.sdkInitialized) { From 31112279ba744e0f98d4f8cd35bf23e8663069dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Tue, 13 Oct 2026 06:53:17 +0100 Subject: [PATCH 09/16] feat(SDK-794): expose IterableConfig.enableUnknownUserActivation Wire the UUA opt-in flag through iOS and Android Serialization with default false. --- CHANGELOG.md | 6 ++++++ .../java/com/iterable/reactnative/Serialization.java | 5 +++++ ios/RNIterableAPI/Serialization.swift | 4 ++++ src/core/classes/Iterable.test.ts | 9 +++++++++ src/core/classes/IterableConfig.ts | 10 ++++++++++ 5 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0673d32c..3888d1535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Updates + +- Added `IterableConfig.enableUnknownUserActivation` (default `false`) to opt in to Unknown User Activation on the native SDK (SDK-794). Visitor consent via `Iterable.setVisitorUsageTracked` is also required before the native SDK stores unknown-user events. + ## 3.2.1 ### Fixes diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 97aa52f49..10a4e3080 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -250,6 +250,11 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setEnableEmbeddedMessaging(iterableContextJSON.optBoolean("enableEmbeddedMessaging")); } + if (iterableContextJSON.has("enableUnknownUserActivation")) { + configBuilder.setEnableUnknownUserActivation( + iterableContextJSON.optBoolean("enableUnknownUserActivation")); + } + if (iterableContextJSON.has("retryPolicy")) { JSONObject retryPolicyJson = iterableContextJSON.getJSONObject("retryPolicy"); int maxRetry = retryPolicyJson.getInt("maxRetry"); diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index fee4c49b6..5b5f7293d 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -87,6 +87,10 @@ extension IterableConfig { config.enableEmbeddedMessaging = enableEmbeddedMessaging } + if let enableUnknownUserActivation = dict["enableUnknownUserActivation"] as? Bool { + config.enableUnknownUserActivation = enableUnknownUserActivation + } + if let dataRegion = dict["dataRegion"] as? NSNumber { switch dataRegion { case 0: diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..f3cd0c844 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -326,6 +326,7 @@ describe('Iterable', () => { expect(config.customActionHandler).toBe(undefined); expect(config.dataRegion).toBe(IterableDataRegion.US); expect(config.enableEmbeddedMessaging).toBe(false); + expect(config.enableUnknownUserActivation).toBe(false); expect(config.encryptionEnforced).toBe(false); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(config.inAppDisplayInterval).toBe(30.0); @@ -345,6 +346,7 @@ describe('Iterable', () => { expect(configDict.customActionHandlerPresent).toBe(false); expect(configDict.dataRegion).toBe(IterableDataRegion.US); expect(configDict.enableEmbeddedMessaging).toBe(false); + expect(configDict.enableUnknownUserActivation).toBe(false); expect(configDict.encryptionEnforced).toBe(false); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(configDict.inAppDisplayInterval).toBe(30.0); @@ -365,6 +367,13 @@ describe('Iterable', () => { expect(configDict.androidWakeDelayMs).toBe(1500); expect(configDict.authCallbackTimeoutMs).toBe(2500); }); + + it('should include enableUnknownUserActivation in toDict() when set to true', () => { + const config = new IterableConfig(); + config.enableUnknownUserActivation = true; + const configDict = config.toDict(); + expect(configDict.enableUnknownUserActivation).toBe(true); + }); }); describe('urlHandler', () => { diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..d115213df 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -391,6 +391,15 @@ export class IterableConfig { */ authCallbackTimeoutMs = 6000; + /** + * When `true`, the native SDK may store and process events for unidentified + * (unknown) users. Defaults to `false`. + * + * Visitor consent via `Iterable.setVisitorUsageTracked` is also required + * before the native SDK stores unknown-user events or runs unknown-user flows. + */ + enableUnknownUserActivation = false; + /** * Should the SDK enable and use embedded messaging? * @@ -484,6 +493,7 @@ export class IterableConfig { pushPlatform: this.pushPlatform, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, + enableUnknownUserActivation: this.enableUnknownUserActivation, enableEmbeddedMessaging: this.enableEmbeddedMessaging, androidWakeDelayMs: this.androidWakeDelayMs, authCallbackTimeoutMs: this.authCallbackTimeoutMs, From 60bca2ec06be3dd9f9a985784d4e84a9bcbbf6cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Wed, 14 Oct 2026 11:50:41 +0100 Subject: [PATCH 10/16] docs(SDK-561): align visitor consent TSDoc with enableUnknownUserActivation Consent APIs only store and return the flag; UUA also requires IterableConfig at init. --- src/core/classes/Iterable.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index d7ab9944d..f6102ef69 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -358,9 +358,11 @@ export class Iterable { * The value is persisted by the native SDK across app restarts. Each call * clears locally stored unknown-visitor events on the native side. * - * Unknown User Activation is not enabled from React Native config yet, so - * this only stores the consent flag until `enableUnknownUserActivation` is - * exposed. Use {@link Iterable.getVisitorUsageTracked} to read the stored value. + * This method only records consent; it does not opt the app into Unknown User + * Activation by itself. Unknown-user flows also require + * `IterableConfig.enableUnknownUserActivation` (defaults to `false`) at + * `Iterable.initialize`. Use {@link Iterable.getVisitorUsageTracked} to read + * the stored value. * * No-op before `Iterable.initialize` has resolved (does not forward to native). */ @@ -378,9 +380,9 @@ export class Iterable { * after a prior `setVisitorUsageTracked` call). * * Resolves to `false` before `Iterable.initialize` has resolved or if the - * integrator has never called `setVisitorUsageTracked`. Until - * `enableUnknownUserActivation` is exposed on `IterableConfig`, the flag is - * stored only and does not enable unknown-user flows by itself. + * integrator has never called `setVisitorUsageTracked`. Visitor consent alone + * does not enable unknown-user flows; those also require + * `IterableConfig.enableUnknownUserActivation` (defaults to `false`) at init. */ static getVisitorUsageTracked(): Promise { if (!Iterable.sdkInitialized) { From 18afed9d7a3f8a6c199875c31419ee206ed6b475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Mon, 19 Oct 2026 20:17:23 +0100 Subject: [PATCH 11/16] fix(SDK-556): guard identityResolution in IterableConfig.toDict() Use optional chaining so null/undefined identityResolution defaults both flags to true instead of throwing during bridge serialization. --- src/core/classes/Iterable.test.ts | 10 ++++++++++ src/core/classes/IterableConfig.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 570e3e97c..6d1dc0e4c 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -443,6 +443,16 @@ describe('Iterable', () => { mergeOnUnknownToKnown: true, }); }); + + it('should default both identityResolution keys to true when identityResolution is undefined', () => { + const config = new IterableConfig(); + config.identityResolution = undefined as unknown as IterableConfig['identityResolution']; + const configDict = config.toDict(); + expect(configDict.identityResolution).toEqual({ + replayOnVisitorToKnown: true, + mergeOnUnknownToKnown: true, + }); + }); }); describe('urlHandler', () => { diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index f3285b7dc..3c6e9d4cc 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -530,9 +530,9 @@ export class IterableConfig { enableForegroundCriteriaFetch: this.enableForegroundCriteriaFetch, identityResolution: { replayOnVisitorToKnown: - this.identityResolution.replayOnVisitorToKnown ?? true, + this.identityResolution?.replayOnVisitorToKnown ?? true, mergeOnUnknownToKnown: - this.identityResolution.mergeOnUnknownToKnown ?? true, + this.identityResolution?.mergeOnUnknownToKnown ?? true, }, enableEmbeddedMessaging: this.enableEmbeddedMessaging, androidWakeDelayMs: this.androidWakeDelayMs, From d95fa2cb9143d45120a48c6a1b1a63d025cc3ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Mon, 19 Oct 2026 20:34:50 +0100 Subject: [PATCH 12/16] refactor(SDK-561): move init reset helper off Iterable class Export resetSdkInitializationStateForTests from Iterable.ts for tests only so it is not on the public Iterable static API surface. --- src/core/classes/Iterable.test.ts | 5 +++-- src/core/classes/Iterable.ts | 28 +++++++++++++--------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 3c5fcb9be..c98cf065c 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -23,6 +23,7 @@ import { IterableInAppTriggerType, IterableLogLevel, } from '../..'; +import { resetSdkInitializationStateForTests } from './Iterable'; import { TestHelper } from '../../__tests__/TestHelper'; import { IterableLogger } from './IterableLogger'; @@ -85,7 +86,7 @@ describe('Iterable', () => { beforeEach(() => { MockRNIterableAPI.visitorUsageTracked = false; MockRNIterableAPI.unknownVisitorEvents = []; - Iterable.resetSdkInitializationStateForTests(); + resetSdkInitializationStateForTests(); }); it('should expose setVisitorUsageTracked and getVisitorUsageTracked on Iterable', () => { @@ -131,7 +132,7 @@ describe('Iterable', () => { await Iterable.initialize('test-key', config); Iterable.setVisitorUsageTracked(true); - Iterable.resetSdkInitializationStateForTests(); + resetSdkInitializationStateForTests(); await Iterable.initialize('test-key', config); await expect(Iterable.getVisitorUsageTracked()).resolves.toBe(true); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index f6102ef69..89ae3c811 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -27,6 +27,13 @@ const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const defaultConfig = new IterableConfig(); +let sdkInitialized = false; + +/** Resets init gating for unit tests only; not part of the public package API. */ +export function resetSdkInitializationStateForTests(): void { + sdkInitialized = false; +} + /** * Checks if the response is an IterableAuthResponse */ @@ -68,15 +75,6 @@ export class Iterable { */ static savedConfig: IterableConfig = defaultConfig; - private static sdkInitialized = false; - - /** - * @internal - */ - static resetSdkInitializationStateForTests(): void { - Iterable.sdkInitialized = false; - } - /** * In-app message manager for the current user. * @@ -166,7 +164,7 @@ export class Iterable { apiKey: string, config: IterableConfig = new IterableConfig() ): Promise { - Iterable.sdkInitialized = false; + sdkInitialized = false; Iterable.savedConfig = config; this.setupIterable(config); @@ -174,7 +172,7 @@ export class Iterable { return IterableApi.initializeWithApiKey(apiKey, { config, version }).then( (initialized) => { - Iterable.sdkInitialized = true; + sdkInitialized = true; return initialized; } ); @@ -191,7 +189,7 @@ export class Iterable { config: IterableConfig = new IterableConfig(), apiEndPoint: string ): Promise { - Iterable.sdkInitialized = false; + sdkInitialized = false; this.setupIterable(config); const version = this.getVersionFromPackageJson(); @@ -201,7 +199,7 @@ export class Iterable { version, apiEndPoint, }).then((initialized) => { - Iterable.sdkInitialized = true; + sdkInitialized = true; return initialized; }); } @@ -367,7 +365,7 @@ export class Iterable { * No-op before `Iterable.initialize` has resolved (does not forward to native). */ static setVisitorUsageTracked(tracked: boolean): void { - if (!Iterable.sdkInitialized) { + if (!sdkInitialized) { return; } IterableApi.setVisitorUsageTracked(tracked); @@ -385,7 +383,7 @@ export class Iterable { * `IterableConfig.enableUnknownUserActivation` (defaults to `false`) at init. */ static getVisitorUsageTracked(): Promise { - if (!Iterable.sdkInitialized) { + if (!sdkInitialized) { return Promise.resolve(false); } return IterableApi.getVisitorUsageTracked(); From 407ccbbbb32bf53923a9cbdc767a90287866890a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 22 Oct 2026 07:58:37 +0100 Subject: [PATCH 13/16] feat(SDK-804): expose IterableConfig.unknownUserHandler for JWT UUA Emit handleUnknownUserCreatedCalled so apps store the generated id before authHandler. --- CHANGELOG.md | 1 + .../reactnative/RNIterableAPIModuleImpl.java | 24 ++++++- ios/RNIterableAPI/ReactIterableAPI.swift | 19 +++++ src/core/classes/Iterable.test.ts | 71 +++++++++++++++++++ src/core/classes/Iterable.ts | 22 ++++++ src/core/classes/IterableConfig.ts | 33 +++++++++ src/core/enums/IterableEventName.ts | 2 + 7 files changed, 171 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56547ffb5..7abbd0bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added `IterableConfig.eventThresholdLimit` (default `100`) to cap locally stored unknown-user events before identification (SDK-553). The value is forwarded to the native SDK and applies when unknown-user activation is enabled. - Added `Iterable.setVisitorUsageTracked` and `Iterable.getVisitorUsageTracked` to record and read visitor consent for Unknown User Activation (SDK-561). Consent is persisted by the native SDK. - Added `IterableConfig.identityResolution` and the `IterableIdentityResolution` type so RN apps can configure default visitor replay and unknown-to-known merge behavior (SDK-556). +- Added `IterableConfig.unknownUserHandler` so JWT-enabled apps can receive the generated unknown user id before native requests a token (SDK-804). Store the id synchronously in the handler and mint in `authHandler` for that id. ## 3.2.1 diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index b998b468a..1e89933f6 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -39,6 +39,7 @@ import com.iterable.iterableapi.IterableInAppMessage; import com.iterable.iterableapi.IterableInboxSession; import com.iterable.iterableapi.IterableLogger; +import com.iterable.iterableapi.IterableUnknownUserHandler; import com.iterable.iterableapi.IterableUrlHandler; import com.iterable.iterableapi.RNIterableInternal; @@ -53,7 +54,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { +public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableUnknownUserHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { public static final String NAME = "RNIterableAPI"; private static String TAG = "RNIterableAPIModule"; @@ -93,6 +94,10 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("unknownUserHandlerPresent") && configReadableMap.getBoolean("unknownUserHandlerPresent") == true) { + configBuilder.setUnknownUserHandler(this); + } + // Check if embedded messaging is enabled before building config boolean enableEmbeddedMessaging = configReadableMap.hasKey("enableEmbeddedMessaging") && configReadableMap.getBoolean("enableEmbeddedMessaging"); @@ -161,6 +166,10 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("unknownUserHandlerPresent") && configReadableMap.getBoolean("unknownUserHandlerPresent") == true) { + configBuilder.setUnknownUserHandler(this); + } + // NOTE: There does not seem to be a way to set the API endpoint // override in the Android SDK. Check with @Ayyanchira and @evantk91 to // see what the best approach is. @@ -750,6 +759,18 @@ public void onEmbeddedMessagingDisabled() { sendEvent(EventName.handleEmbeddedMessagingDisabledCalled.name(), null); } + @Override + public void onUnknownUserCreated(@NonNull String userId) { + JSONObject messageJson = new JSONObject(); + try { + messageJson.put("userId", userId); + WritableMap eventData = Serialization.convertJsonToMap(messageJson); + sendEvent(EventName.handleUnknownUserCreatedCalled.name(), eventData); + } catch (JSONException e) { + IterableLogger.e(TAG, "Failed to emit unknown user created event"); + } + } + @Override public void onEmbeddedMessagingSyncSucceeded() { IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); @@ -850,6 +871,7 @@ enum EventName { handleCustomActionCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, + handleUnknownUserCreatedCalled, handleInAppCalled, handleUrlCalled, receivedIterableEmbeddedMessagesChanged, diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index 475edca3e..c0cbccd35 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -35,6 +35,7 @@ import React case handleAuthFailureCalled case handleEmbeddedMessageUpdateCalled case handleEmbeddedMessagingDisabledCalled + case handleUnknownUserCreatedCalled } @objc public static var supportedEvents: [String] { @@ -677,6 +678,12 @@ import React iterableConfig.authDelegate = self } + if let unknownUserHandlerPresent = configDict["unknownUserHandlerPresent"] as? Bool, + unknownUserHandlerPresent == true + { + iterableConfig.unknownUserHandler = self + } + // connect new inbox in-app payloads to the RN SDK NotificationCenter.default.addObserver( self, selector: #selector(receivedIterableInboxChanged), @@ -875,6 +882,18 @@ extension ReactIterableAPI: IterableAuthDelegate { } } +extension ReactIterableAPI: IterableUnknownUserHandler { + public func onUnknownUserCreated(userId: String) { + ITBInfo() + guard shouldEmit else { + return + } + delegate?.sendEvent( + withName: EventName.handleUnknownUserCreatedCalled.rawValue, + body: ["userId": userId]) + } +} + extension ReactIterableAPI: IterableEmbeddedUpdateDelegate { public func onMessagesUpdated() { ITBInfo() diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index c98cf065c..65e396b7c 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -49,6 +49,9 @@ describe('Iterable', () => { nativeEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); // Clear any pending timers jest.clearAllTimers(); @@ -1445,6 +1448,74 @@ describe('Iterable', () => { }); }); + describe('unknownUserHandler', () => { + it('should call unknownUserHandler when handleUnknownUserCreatedCalled is emitted with userId', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.unknownUserHandler = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit(IterableEventName.handleUnknownUserCreatedCalled, { + userId: 'generated-unknown-user-id', + }); + expect(config.unknownUserHandler).toHaveBeenCalledWith( + 'generated-unknown-user-id' + ); + }); + + it('should not set up a listener when unknownUserHandler is omitted', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', config); + expect(() => { + nativeEmitter.emit(IterableEventName.handleUnknownUserCreatedCalled, { + userId: 'generated-unknown-user-id', + }); + }).not.toThrow(); + }); + + it('should include unknownUserHandlerPresent in toDict() when the handler is set', () => { + const config = new IterableConfig(); + config.unknownUserHandler = jest.fn(); + expect(config.toDict().unknownUserHandlerPresent).toBe(true); + }); + + it('should set unknownUserHandlerPresent to false when the handler is omitted', () => { + const config = new IterableConfig(); + expect(config.toDict().unknownUserHandlerPresent).toBe(false); + }); + + it('should log without the userId when unknownUserHandler throws', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); + const secretUserId = 'secret-unknown-user-id-for-log-test'; + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.unknownUserHandler = jest.fn(() => { + throw new Error(`handler failed for ${secretUserId}`); + }); + const logSpy = jest.spyOn(IterableLogger, 'log'); + Iterable.initialize('apiKey', config); + nativeEmitter.emit(IterableEventName.handleUnknownUserCreatedCalled, { + userId: secretUserId, + }); + const loggedText = logSpy.mock.calls + .map((args) => String(args[0])) + .join('\n'); + expect(loggedText).not.toContain(secretUserId); + logSpy.mockRestore(); + }); + }); + describe('embedded messaging callbacks', () => { describe('onEmbeddedMessageUpdate', () => { it('should call onEmbeddedMessageUpdate when handleEmbeddedMessageUpdateCalled event is emitted', () => { diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 89ae3c811..4c5b65c53 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1032,6 +1032,9 @@ export class Iterable { RNEventEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleUnknownUserCreatedCalled + ); } /** @@ -1305,6 +1308,25 @@ export class Iterable { ); } + if (Iterable.savedConfig.unknownUserHandler) { + RNEventEmitter.addListener( + IterableEventName.handleUnknownUserCreatedCalled, + (dict: { userId?: string }) => { + const userId = dict?.userId; + if (userId === undefined || userId === null) { + return; + } + try { + Iterable.savedConfig.unknownUserHandler!(userId); + } catch { + IterableLogger?.log( + 'IterableConfig.unknownUserHandler threw an error' + ); + } + } + ); + } + if (Iterable.savedConfig.enableEmbeddedMessaging) { if (Iterable.savedConfig.onEmbeddedMessageUpdate) { RNEventEmitter.addListener( diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 3c6e9d4cc..6dc79bb26 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -432,6 +432,35 @@ export class IterableConfig { mergeOnUnknownToKnown: true, }; + /** + * Called when the native SDK creates a generated user id after unknown-user + * activation criteria are met. + * + * JWT-enabled API keys: native logs in as that id immediately after this + * callback and invokes `authHandler` without passing the id. Store the + * `userId` argument **synchronously** here so `authHandler` can mint a token + * for that id in the same turn. Do not use `Iterable.getUserId()` in this + * callback; native has not switched identity yet. + * + * @param userId - The generated unknown user id from Iterable. + * + * @example + * ```typescript + * let pendingUnknownUserId: string | null = null; + * const config = new IterableConfig(); + * config.enableUnknownUserActivation = true; + * config.unknownUserHandler = (userId) => { + * pendingUnknownUserId = userId; + * }; + * config.authHandler = async () => { + * const id = pendingUnknownUserId ?? (await Iterable.getUserId()); + * return fetchJwtForUser(id); + * }; + * Iterable.initialize('', config); + * ``` + */ + unknownUserHandler?: (userId: string) => void; + /** * Should the SDK enable and use embedded messaging? * @@ -504,6 +533,10 @@ export class IterableConfig { * A boolean indicating if an authentication handler is present. */ authHandlerPresent: this.authHandler !== undefined, + /** + * A boolean indicating if an unknown-user handler is present. + */ + unknownUserHandlerPresent: this.unknownUserHandler !== undefined, /** * A boolean indicating if an embedded message update callback is present. */ diff --git a/src/core/enums/IterableEventName.ts b/src/core/enums/IterableEventName.ts index 6ea79c754..a7332ad49 100644 --- a/src/core/enums/IterableEventName.ts +++ b/src/core/enums/IterableEventName.ts @@ -23,4 +23,6 @@ export enum IterableEventName { handleEmbeddedMessageUpdateCalled = 'handleEmbeddedMessageUpdateCalled', /** Event that fires when embedded messaging is disabled */ handleEmbeddedMessagingDisabledCalled = 'handleEmbeddedMessagingDisabledCalled', + /** Event that fires when the native SDK creates an unknown user id */ + handleUnknownUserCreatedCalled = 'handleUnknownUserCreatedCalled', } From 4303f0a91a99e6988a0279140a351b4ec1c44e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 22 Oct 2026 08:11:09 +0100 Subject: [PATCH 14/16] test(SDK-804): assert no unknown-user listener when handler omitted Use shared mock EventEmitter listenerCount instead of emit-not-throwing. --- src/__mocks__/jest.setup.ts | 3 +++ src/core/classes/Iterable.test.ts | 21 +++++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index 96c17ca52..18a12e8fe 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -56,3 +56,6 @@ jest.doMock('react-native', () => { ReactNative ); }); + +/** Shared backing store for all NativeEventEmitter instances in tests. */ +export const testNativeEventEmitter = mockNativeEventEmitter; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index 65e396b7c..a3444c413 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -2,6 +2,7 @@ import { NativeEventEmitter, Platform } from 'react-native'; import { MockLinking } from '../../__mocks__/MockLinking'; import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; +import { testNativeEventEmitter } from '../../__mocks__/jest.setup'; // import from the same location that consumers import from import { Iterable, @@ -1458,6 +1459,11 @@ describe('Iterable', () => { config.logReactNativeSdkCalls = false; config.unknownUserHandler = jest.fn(); Iterable.initialize('apiKey', config); + expect( + testNativeEventEmitter.listenerCount( + IterableEventName.handleUnknownUserCreatedCalled + ) + ).toBe(1); nativeEmitter.emit(IterableEventName.handleUnknownUserCreatedCalled, { userId: 'generated-unknown-user-id', }); @@ -1466,19 +1472,18 @@ describe('Iterable', () => { ); }); - it('should not set up a listener when unknownUserHandler is omitted', () => { - const nativeEmitter = new NativeEventEmitter(); - nativeEmitter.removeAllListeners( + it('should not register a handleUnknownUserCreatedCalled listener when unknownUserHandler is omitted', () => { + testNativeEventEmitter.removeAllListeners( IterableEventName.handleUnknownUserCreatedCalled ); const config = new IterableConfig(); config.logReactNativeSdkCalls = false; Iterable.initialize('apiKey', config); - expect(() => { - nativeEmitter.emit(IterableEventName.handleUnknownUserCreatedCalled, { - userId: 'generated-unknown-user-id', - }); - }).not.toThrow(); + expect( + testNativeEventEmitter.listenerCount( + IterableEventName.handleUnknownUserCreatedCalled + ) + ).toBe(0); }); it('should include unknownUserHandlerPresent in toDict() when the handler is set', () => { From 550a60cd71cf22b7b5a963980e2f0575ec792496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 22 Oct 2026 18:39:38 +0100 Subject: [PATCH 15/16] docs(SDK-804): clear pending unknown user id in authHandler example Consume the cached id on each auth call so later JWT minting uses getUserId(). --- src/core/classes/IterableConfig.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 6dc79bb26..8f1d283e8 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -440,7 +440,9 @@ export class IterableConfig { * callback and invokes `authHandler` without passing the id. Store the * `userId` argument **synchronously** here so `authHandler` can mint a token * for that id in the same turn. Do not use `Iterable.getUserId()` in this - * callback; native has not switched identity yet. + * callback; native has not switched identity yet. In `authHandler`, consume + * and clear that pending id on each call so later known-user logins and + * token refreshes use `Iterable.getUserId()` instead of a stale cached id. * * @param userId - The generated unknown user id from Iterable. * @@ -453,7 +455,9 @@ export class IterableConfig { * pendingUnknownUserId = userId; * }; * config.authHandler = async () => { - * const id = pendingUnknownUserId ?? (await Iterable.getUserId()); + * const unknownUserId = pendingUnknownUserId; + * pendingUnknownUserId = null; + * const id = unknownUserId ?? (await Iterable.getUserId()); * return fetchJwtForUser(id); * }; * Iterable.initialize('', config); From b98442bf73f9e80dd896f2e07736af7f5b80c21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferr=C3=A3o?= Date: Thu, 29 Oct 2026 11:39:33 +0000 Subject: [PATCH 16/16] fix(SDK-804): forward-declare IterableUnknownUserHandler in RN bridge Unblocks iOS example and CodeQL Swift builds after unknownUserHandler conformance. --- ios/RNIterableAPI/RNIterableAPI.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 745307dfd..4dac36d7d 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -12,6 +12,7 @@ @protocol IterableInAppDelegate; @protocol IterableCustomActionDelegate; @protocol IterableAuthDelegate; +@protocol IterableUnknownUserHandler; @protocol IterableURLDelegate; @protocol IterableEmbeddedUpdateDelegate; typedef NS_ENUM(NSInteger, InAppShowResponse) {