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
Binary file added .yarn/install-state.gz
Binary file not shown.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,11 @@ The plugin automatically configures push notifications for both iOS and Android
- Sets up notification service extension
- Configures required entitlements
- Handles notification permissions
- Routes notification delegate callbacks by payload so Iterable can coexist with other push providers (e.g. `expo-notifications`, Firebase, OneSignal)

**Multiple push providers:** iOS allows only one `UNUserNotificationCenter` delegate. The plugin installs a `NotificationDelegateRouter` that forwards non-Iterable pushes (identified by the absence of an `itbl` key in the payload) to whichever delegate was registered before Iterable (typically Expo). Iterable pushes are handled by the Iterable SDK as before.

**Known limitation:** Router installation is deferred to the next main-queue turn so Expo can register its delegate first. If another plugin uses the same deferred-install pattern, delegate ordering is undefined.

#### Android

Expand Down
Binary file added example/.yarn/install-state.gz
Binary file not shown.
7 changes: 7 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ npx expo run:android
- Check that `google-services.json` is properly placed (Android)
- Verify certificates and provisioning profiles (iOS)

4. **iOS build fails on `fmt` / `FMT_STRING` / `library 'fmt' not found` (Xcode 26.4+)**
- This is a known React Native + Xcode 26.4 compatibility issue
- `@iterable/expo-plugin` injects a Podfile workaround automatically during prebuild
- Rebuild native code: `npx expo prebuild --clean`, then `cd ios && pod install && cd ..`
- If you still see the error, confirm your generated `ios/Podfile` contains the
`@iterable/expo-plugin: fmt workaround for Xcode 26.4` comment inside `post_install`

### Development Tips

- Use `yarn start` to start the Metro bundler
Expand Down
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"open:android": "open -a \"Android Studio\" android"
},
"dependencies": {
"@iterable/react-native-sdk": "^2.0.2",
"@iterable/react-native-sdk": "^3.0.0",
"@react-navigation/native": "^7.0.19",
"@react-navigation/stack": "^7.2.3",
"expo": "^53.0.19",
Expand Down
11 changes: 5 additions & 6 deletions example/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1481,20 +1481,19 @@ __metadata:
languageName: node
linkType: hard

"@iterable/react-native-sdk@npm:^2.0.2":
version: 2.0.2
resolution: "@iterable/react-native-sdk@npm:2.0.2"
"@iterable/react-native-sdk@npm:^3.0.0":
version: 3.0.0
resolution: "@iterable/react-native-sdk@npm:3.0.0"
peerDependencies:
"@react-navigation/native": "*"
react: "*"
react-native: "*"
react-native-safe-area-context: "*"
react-native-vector-icons: "*"
react-native-webview: "*"
peerDependenciesMeta:
expo:
optional: true
checksum: 10c0/d1d6485dd3b982642df9569a4793aae0ff71191f8f6faffadbb580594db8e9cf2089f8a9b668d09c7b53e93ecbcd9d054833a8b82e0bb2a1d0d9a68a269d5967
checksum: 10c0/986244429e63646cd641a7f7be1a7c93be05f12000d80d1fa5cebce29bc4d87566e72766d1a3f57fe7f5c0a579a8c7d4b5b238cc815c8be161aef0a022314cfc
languageName: node
linkType: hard

Expand Down Expand Up @@ -3309,7 +3308,7 @@ __metadata:
resolution: "expo-plugin-example@workspace:."
dependencies:
"@babel/core": "npm:^7.25.2"
"@iterable/react-native-sdk": "npm:^2.0.2"
"@iterable/react-native-sdk": "npm:^3.0.0"
"@react-navigation/native": "npm:^7.0.19"
"@react-navigation/stack": "npm:^7.2.3"
"@types/react": "npm:~19.0.10"
Expand Down
28 changes: 8 additions & 20 deletions ios/ExpoAdapterIterable/IterableAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ public class IterableAppDelegate: ExpoAppDelegateSubscriber, UIApplicationDelega
) -> Bool {
ITBInfo()

UNUserNotificationCenter.current().delegate = self
// Defer install so Expo (e.g. expo-notifications) assigns its delegate first.
DispatchQueue.main.async {
NotificationDelegateRouter.shared.install()
}

/**
* Request permissions for push notifications if the flag is not set to false.
Expand All @@ -34,6 +37,10 @@ public class IterableAppDelegate: ExpoAppDelegateSubscriber, UIApplicationDelega
_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard userInfo["itbl"] is [AnyHashable: Any] else {
completionHandler(.noData)
return
}

IterableAppIntegration.application(
application, didReceiveRemoteNotification: userInfo,
Expand Down Expand Up @@ -95,22 +102,3 @@ public class IterableAppDelegate: ExpoAppDelegateSubscriber, UIApplicationDelega
}
}
}

/// * Handle incoming push notifications and enable push notification tracking.
/// * @see Step 3.5.5 of https://support.iterable.com/hc/en-us/articles/360045714132-Installing-Iterable-s-React-Native-SDK#step-3-5-set-up-support-for-push-notifications
extension IterableAppDelegate: UNUserNotificationCenterDelegate {
public func userNotificationCenter(
_: UNUserNotificationCenter, willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.badge, .banner, .list, .sound])
}

public func userNotificationCenter(
_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
IterableAppIntegration.userNotificationCenter(
center, didReceive: response, withCompletionHandler: completionHandler)
}
}
58 changes: 58 additions & 0 deletions ios/ExpoAdapterIterable/NotificationDelegateRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import IterableSDK
import UserNotifications

final class NotificationDelegateRouter: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegateRouter()
private weak var forwardTo: UNUserNotificationCenterDelegate?

func install() {
let center = UNUserNotificationCenter.current()
forwardTo = center.delegate
center.delegate = self

if forwardTo == nil {
ITBInfo(
"NotificationDelegateRouter: no prior delegate captured β€” non-Iterable pushes won't be forwarded"
)
}
}

private func isIterablePush(_ userInfo: [AnyHashable: Any]) -> Bool {
userInfo["itbl"] is [AnyHashable: Any]
}

// MARK: - willPresent (foreground)

func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
if isIterablePush(notification.request.content.userInfo) {
completionHandler([.badge, .banner, .list, .sound])
} else if let forwardTo {
forwardTo.userNotificationCenter?(
center, willPresent: notification, withCompletionHandler: completionHandler)
} else {
completionHandler([])
}
}

// MARK: - didReceive (tap / action)

func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
if isIterablePush(response.notification.request.content.userInfo) {
IterableAppIntegration.userNotificationCenter(
center, didReceive: response, withCompletionHandler: completionHandler)
} else if let forwardTo {
forwardTo.userNotificationCenter?(
center, didReceive: response, withCompletionHandler: completionHandler)
} else {
completionHandler()
}
}
}
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
"main": "build/withIterable.js",
"types": "build/withIterable.d.ts",
"scripts": {
"build": "expo-module build",
"build": "expo-module build && yarn build:plugin",
"build:plugin": "tsc -p plugin",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"test:coverage": "jest test --coverage --config jest.config.js",
"prepare": "expo-module prepare",
"prepare": "expo-module prepare && yarn build:plugin",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module",
"open:ios": "xed example/ios",
Expand All @@ -36,7 +37,7 @@
"devDependencies": {
"@commitlint/config-conventional": "^19.6.0",
"@evilmartians/lefthook": "^1.5.0",
"@iterable/react-native-sdk": "^2.0.2",
"@iterable/react-native-sdk": "^3.0.0",
"@react-native/eslint-config": "^0.79.5",
"@release-it/conventional-changelog": "^9.0.2",
"@types/jest": "^29.5.14",
Expand Down
55 changes: 55 additions & 0 deletions plugin/__tests__/withIosFmtWorkaround.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
createMockPodfileConfig,
createTestConfig,
type WithIterableResult,
} from '../__mocks__';
import { FMT_WORKAROUND_MARKER } from '../src/withIosFmtWorkaround';
import withIterable from '../src/withIterable';

const EXPO_PODFILE_SNIPPET = `
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
end
`;

describe('withIosFmtWorkaround', () => {
it('injects the fmt workaround after react_native_post_install', async () => {
const result = withIterable(createTestConfig(), {}) as WithIterableResult;
const modifiedPodfile = await result.mods.ios.podfile(
createMockPodfileConfig({ contents: EXPO_PODFILE_SNIPPET })
);

expect(modifiedPodfile.modResults.contents).toContain(
FMT_WORKAROUND_MARKER
);
expect(modifiedPodfile.modResults.contents).toContain(
"config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17'"
);
});

it('does not inject the workaround twice', async () => {
const result = withIterable(createTestConfig(), {}) as WithIterableResult;
const firstPass = await result.mods.ios.podfile(
createMockPodfileConfig({ contents: EXPO_PODFILE_SNIPPET })
);
const secondPass = await result.mods.ios.podfile(
createMockPodfileConfig({ contents: firstPass.modResults.contents })
);

const markerCount = (
secondPass.modResults.contents.match(
new RegExp(
FMT_WORKAROUND_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'g'
)
) || []
).length;

expect(markerCount).toBe(1);
});
});
55 changes: 55 additions & 0 deletions plugin/src/withIosFmtWorkaround.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ConfigPlugin, withPodfile } from 'expo/config-plugins';

/** Marker comment used to avoid injecting the workaround more than once. */
export const FMT_WORKAROUND_MARKER =
'# @iterable/expo-plugin: fmt workaround for Xcode 26.4';

/**
* Compiles the fmt pod in C++17 mode so FMT_USE_CONSTEVAL stays disabled.
* Required for Xcode 26.4+ when building React Native 0.79+.
*
* @see https://github.com/Iterable/react-native-sdk/blob/main/example/ios/Podfile
*/
const FMT_WORKAROUND_RUBY = `
${FMT_WORKAROUND_MARKER}
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
# fmt/base.h unconditionally redefines FMT_USE_CONSTEVAL based on __cplusplus,
# so preprocessor defines are overwritten. The reliable fix is to compile fmt
# in C++17 mode: FMT_CPLUSPLUS (201703L) < 201709L β†’ FMT_USE_CONSTEVAL = 0.
# All other pods stay in C++20 (React-perflogger needs std::unordered_map::contains).
if target.name == 'fmt'
config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17'
end
end
end`;

export const withIosFmtWorkaround: ConfigPlugin = (config) => {
return withPodfile(config, (newConfig) => {
const { contents } = newConfig.modResults;

if (contents.includes(FMT_WORKAROUND_MARKER)) {
return newConfig;
}

const postInstallMatch = contents.match(
/(react_native_post_install\([\s\S]*?\n\s*\))/
);

if (!postInstallMatch) {
console.warn(
'@iterable/expo-plugin: Could not inject fmt workaround into Podfile β€” react_native_post_install block not found'
);
return newConfig;
}

newConfig.modResults.contents = contents.replace(
postInstallMatch[0],
`${postInstallMatch[0]}${FMT_WORKAROUND_RUBY}`
);

return newConfig;
});
};

export default withIosFmtWorkaround;
2 changes: 2 additions & 0 deletions plugin/src/withIterable.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ConfigPlugin, withPlugins } from 'expo/config-plugins';

import { withDeepLinks } from './withDeepLinks';
import { withIosFmtWorkaround } from './withIosFmtWorkaround';
import {
type ConfigPluginProps,
type ConfigPluginPropsWithDefaults,
Expand All @@ -24,6 +25,7 @@ const withIterable: ConfigPlugin<ConfigPluginProps> = (config, props = {}) => {
[withStoreConfigValues, propsWithDefaults],
[withPushNotifications, propsWithDefaults],
[withDeepLinks, propsWithDefaults],
withIosFmtWorkaround,
]);
};

Expand Down
11 changes: 5 additions & 6 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2669,7 +2669,7 @@ __metadata:
dependencies:
"@commitlint/config-conventional": "npm:^19.6.0"
"@evilmartians/lefthook": "npm:^1.5.0"
"@iterable/react-native-sdk": "npm:^2.0.2"
"@iterable/react-native-sdk": "npm:^3.0.0"
"@react-native/eslint-config": "npm:^0.79.5"
"@release-it/conventional-changelog": "npm:^9.0.2"
"@types/jest": "npm:^29.5.14"
Expand Down Expand Up @@ -2702,20 +2702,19 @@ __metadata:
languageName: unknown
linkType: soft

"@iterable/react-native-sdk@npm:^2.0.2":
version: 2.0.2
resolution: "@iterable/react-native-sdk@npm:2.0.2"
"@iterable/react-native-sdk@npm:^3.0.0":
version: 3.0.0
resolution: "@iterable/react-native-sdk@npm:3.0.0"
peerDependencies:
"@react-navigation/native": "*"
react: "*"
react-native: "*"
react-native-safe-area-context: "*"
react-native-vector-icons: "*"
react-native-webview: "*"
peerDependenciesMeta:
expo:
optional: true
checksum: 10c0/d1d6485dd3b982642df9569a4793aae0ff71191f8f6faffadbb580594db8e9cf2089f8a9b668d09c7b53e93ecbcd9d054833a8b82e0bb2a1d0d9a68a269d5967
checksum: 10c0/986244429e63646cd641a7f7be1a7c93be05f12000d80d1fa5cebce29bc4d87566e72766d1a3f57fe7f5c0a579a8c7d4b5b238cc815c8be161aef0a022314cfc
languageName: node
linkType: hard

Expand Down
Loading