Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## 3.0.0
## Unreleased

### Fixes
- Fixed the React Native bridge contract for `Iterable.initialize` on iOS.
- The iOS bridge now calls the synchronous native initializer (`IterableAPI.initialize`) and resolves the JS promise immediately, matching the Android bridge which has always behaved this way.
- Previously, the iOS bridge wired the JS promise to the `initialize2(callback:)` overload, whose callback fires when the first in-app messages fetch settles, not when the SDK is ready to use. That callback is an `InAppManager.start()` signal and has nothing to do with whether `IterableAPI.initialize` succeeded.
- On iOS, `IterableAPI.initialize(...)` is synchronous, non-failable, and returns `Void`. The native SDK is fully usable the moment it returns. There is no init-error channel to await on the native side, and JS callers should not treat the promise as one. `await Iterable.initialize(...)` is supported for API symmetry but does not gate SDK readiness on any async work.
- The user-visible symptom of the old contract was a multi-second to multi-minute hang on the JS promise under JWT or network friction, surfaced as an init failure even though the SDK had already initialized successfully. The error originated in the in-app messages fetch retry budget, not in initialization.

## 3.0.0
### Updates
- Added embedded messaging functionality. This includes the ability to:
- Manually sync messages with `Iterable.embeddedManager.syncMessages()`
Expand Down
34 changes: 25 additions & 9 deletions ios/RNIterableAPI/ReactIterableAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -645,24 +645,40 @@ import React
name: Notification.Name.iterableInboxChanged, object: nil)

DispatchQueue.main.async {
IterableAPI.initialize2(
apiKey: apiKey,
launchOptions: launchOptions,
config: iterableConfig,
apiEndPointOverride: apiEndPointOverride
) { result in
resolver(result)
// The native iOS SDK is fully usable the moment IterableAPI.initialize
// returns. The legacy initialize2(callback:) overload fires its callback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can tell, we're not waiting for Iterable.initialize2 to return, though. It looks like we're bypassing any return and always resolving as true. Would this not silence any errors that may occur?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, but the callback was never an init-error signal. Two pieces of evidence from the iOS SDK source.

1. IterableAPI.initialize(...) and initialize2(...) run the same code, neither is failable.

swift-sdk/SDK/IterableAPI.swift#L102-L108:

public static func initialize(apiKey: String,
                              launchOptions: [UIApplication.LaunchOptionsKey: Any]?,
                              config: IterableConfig = IterableConfig()) {
    initialize2(apiKey: apiKey,
                launchOptions: launchOptions,
                config: config)
}

The public sync initialize(...) is just initialize2(...) with no callback. Both return Void, no throws, no Result. There is no init-error channel on the native API.

2. The Bool the old bridge was awaiting represented the first in-app fetch, not init.

swift-sdk/SDK/IterableAPI.swift#L113-L128:

public static func initialize2(apiKey: String,
                               launchOptions: [UIApplication.LaunchOptionsKey: Any]?,
                               config: IterableConfig = IterableConfig(),
                               apiEndPointOverride: String? = nil,
                               callback: ((Bool) -> Void)? = nil) {
    AppExtensionHelper.initialize()
    implementation = InternalIterableAPI(apiKey: apiKey, ...)
    _ = implementation?.start().onSuccess { _ in
        callback?(true)
    }.onError { _ in
        callback?(false)
    }
    ...
}

start() is InAppManager.start(), which does return scheduleSync(), the first in-app messages fetch. So the old false only ever meant "first in-app fetch failed", which is exactly the signal SDK-392 wants us to stop blocking on.

3. Android is already on this contract.

RNIterableAPIModuleImpl.java#L139-L141:

// MOB-10421: Figure out what the error cases are and handle them appropriately
// This is just here to match the TS types and let the JS thread know when we are done initializing
promise.resolve(true);

Android resolves true immediately after sync IterableApi.initialize(...) and the comment explicitly acknowledges there are no error cases to surface. This PR brings iOS to that same contract.

Net: nothing real is being silenced. If a downstream caller cares about the in-app fetch result (the only signal the old callback carried), it is still observable via inAppManager listeners.

// only after inAppManager.start() resolves the first in-app messages
// fetch, which can take 60s+ under any auth or network friction and
// blocks the JS promise on a signal that does not actually represent
// "SDK ready". Android's RNIterableAPIModuleImpl.initializeWithApiKey
// resolves promise.resolve(true) immediately after sync init - this
// brings iOS to parity. See SDK-478.
if let apiEndPointOverride = apiEndPointOverride {
IterableAPI.initialize2(
apiKey: apiKey,
launchOptions: launchOptions,
config: iterableConfig,
apiEndPointOverride: apiEndPointOverride
)
} else {
IterableAPI.initialize(
apiKey: apiKey,
launchOptions: launchOptions,
config: iterableConfig
)
}

IterableAPI.setDeviceAttribute(name: "reactNativeSDKVersion", value: version)

// Add embedded update listener if any callback is present
let onEmbeddedMessageUpdatePresent = configDict["onEmbeddedMessageUpdatePresent"] as? Bool ?? false
let onEmbeddedMessagingDisabledPresent = configDict["onEmbeddedMessagingDisabledPresent"] as? Bool ?? false

if onEmbeddedMessageUpdatePresent || onEmbeddedMessagingDisabledPresent {
IterableAPI.embeddedManager.addUpdateListener(self)
}

resolver(true)
}
}

Expand Down
34 changes: 34 additions & 0 deletions src/core/classes/IterableApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ describe('IterableApi', () => {
);
expect(result).toBe(true);
});

// SDK-478: the bridge contract is "resolve true immediately after calling
// sync IterableAPI.initialize on the native side". The JS layer is a pure
// passthrough. This pins that the JS code does not add an await on any
// additional async work between the native call and the returned promise.
// Regression guard for the 2021 contract drift that gated the JS promise
// on the first in-app messages fetch (commit 4c357126).
it('resolves promptly without awaiting any additional async work', async () => {
MockRNIterableAPI.initializeWithApiKey.mockResolvedValueOnce(true);
const startedAt = Date.now();
const result = await IterableApi.initializeWithApiKey('test-api-key', {
config: new IterableConfig(),
version: '1.0.0',
});
const elapsedMs = Date.now() - startedAt;
expect(result).toBe(true);
expect(elapsedMs).toBeLessThan(250);
});
});

describe('initialize2WithApiKey', () => {
Expand Down Expand Up @@ -119,6 +137,22 @@ describe('IterableApi', () => {
);
expect(result).toBe(true);
});

// SDK-478: same contract as initializeWithApiKey above. initialize2 is only
// used for staging/test endpoint overrides; its JS-side behavior is still
// "resolve immediately with whatever native returns" - no additional waits.
it('resolves promptly without awaiting any additional async work', async () => {
MockRNIterableAPI.initialize2WithApiKey.mockResolvedValueOnce(true);
const startedAt = Date.now();
const result = await IterableApi.initialize2WithApiKey('test-api-key', {
config: new IterableConfig(),
version: '1.0.0',
apiEndPoint: 'https://api.staging.iterable.com',
});
const elapsedMs = Date.now() - startedAt;
expect(result).toBe(true);
expect(elapsedMs).toBeLessThan(250);
});
});

// ====================================================== //
Expand Down
Loading