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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
## 3.0.0
## Unreleased

### Fixes
- Fix iOS-side `Iterable.initialize` promise hang.
- The iOS bridge now resolves the promise immediately after calling the native SDK's sync initializer, matching the Android bridge's behavior.
- Previously the promise could wait on the first in-app messages fetch (and any associated auth retry budget) before resolving, leading to multi-second to multi-minute hangs under certain configurations.
- The native iOS SDK is fully usable the moment `Iterable.initialize` is called; nothing about JS-side correctness requires waiting on the promise.

## 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