Skip to content

feat(messaging, ios): add a notification service extension from the Expo config plugin - #9401

Open
simondaigre wants to merge 3 commits into
invertase:mainfrom
simondaigre:feat/messaging-expo-notification-service-extension
Open

simondaigre wants to merge 3 commits into
invertase:mainfrom
simondaigre:feat/messaging-expo-notification-service-extension

Conversation

@simondaigre

@simondaigre simondaigre commented Oct 26, 2026 •

Copy link
Copy Markdown

Description

Displaying the image of an FCM notification on iOS takes a Notification Service Extension that calls Messaging.serviceExtension().populateNotificationContent. The docs explain how to add one in Xcode, but an Expo project regenerates ios/ on each prebuild, so it needs another config plugin or a hand-written one. The CocoaPods recipe of the docs also no longer applies with Firebase Apple SDK 13+, which is SPM-only.

This adds an ios.notificationServiceExtension option to the messaging config plugin:

["@react-native-firebase/messaging", { "ios": { "notificationServiceExtension": true } }]

On prebuild, the plugin:

  • writes ios/NotificationServiceExtension/: the Swift service calling populateNotificationContent, and an Info.plist with the app's version and build number;
  • adds an app extension target embedded in the app, with the bundle identifier <ios.bundleIdentifier>.NotificationServiceExtension and the app's deployment target and device family;
  • links FirebaseMessaging to the extension through Swift Package Manager at the exact Firebase Apple SDK version of @react-native-firebase/app (sdkVersions.ios.firebase), reusing the firebase-ios-sdk package reference that pod install links FirebaseCore into the app with: the app and the extension share one package, at one version. The CocoaPods hooks leave the extension alone;
  • declares the extension to EAS Build (extra.eas.build.experimental.ios.appExtensions), so that EAS provisions and signs it.

{ "targetName": "…" } instead of true names the target differently (also its folder and the last component of its bundle identifier, e.g. to keep the identifier of an extension added before). A new prebuild updates the existing target rather than adding another one.

The Expo section of ios-notification-images.mdx documents it. test-expo enables it, and test-expo-ios-link.sh now checks that the built app embeds NotificationServiceExtension.appex and that the extension links FirebaseMessaging, so the iOS CI builds it.

Related issues

Picks up the idea of #8191 (closed as stale), which added the target through CocoaPods, without tests or docs.

Release Summary

feat(messaging, ios): the Expo config plugin can add a Notification Service Extension that displays the image of FCM notifications (ios.notificationServiceExtension).

Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
    • Yes
  • My change supports the following platforms;
    • Android
    • iOS
    • Other (macOS, web)
  • My change includes tests;
    • e2e tests added or updated in packages/\*\*/e2e
    • jest tests added or updated in packages/\*\*/__tests__
  • I have updated TypeScript types that are affected by my change.
  • This is a breaking change;
    • Yes
    • No

Test Plan

  • notificationServiceExtension.test.ts (18 new tests): the files written, the target and its build settings, the embedding in the app, the SPM package reference reused (or added when missing) and the FirebaseMessaging product, targetName, the EAS declaration, and a second prebuild updating the target instead of duplicating it. The messaging plugin's 46 tests pass, and so does yarn lint:js.
  • yarn test-expo:ios:link on macOS: the prebuild generates the target, the build succeeds, and the existing linkage checks plus the new one pass ("NotificationServiceExtension.appex is embedded and links FirebaseMessaging", confirmed with nm on the extension's binary). The app launches on an iOS 27 simulator.

…xpo config plugin

`ios.notificationServiceExtension` adds a Notification Service Extension target that displays the image of
FCM notifications. The extension links FirebaseMessaging through Swift Package Manager at the exact Firebase
Apple SDK version of @react-native-firebase/app, reusing the firebase-ios-sdk package reference that
pod install links FirebaseCore into the app with, since the CocoaPods recipe of the docs no longer works
with Firebase Apple SDK 13+. The extension is declared to EAS Build so that it is provisioned and signed.

test-expo enables it, and its iOS link check asserts the extension is embedded and links FirebaseMessaging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Oct 26, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@simondaigre

Copy link
Copy Markdown
Author

@CLAassistant doesnt work image

@simondaigre
simondaigre force-pushed the feat/messaging-expo-notification-service-extension branch from 317fb7f to d3926e4 Compare October 27, 2026 12:21
@Mastersam07

Copy link
Copy Markdown
Contributor

Hey @simondaigre, thanks for this, and for the tests, docs and test-expo coverage that came with it. An opt-in way to get notification images under CNG is something Expo users have been missing since #8191 went stale.

Before a full review, two things need to be sorted out:

  1. CLA: the CLA check still shows as not signed. Could you try the recheck link once more? If it still fails, let me know what you see and we'll get it sorted on our side.
  2. CI: workflows from forks need maintainer approval, so nothing has run yet beyond the basic check. I'll approve the runs. Test Expo iOS Link is the one we most want to see green here.

Some early questions from a first read, so you can look at them while CI runs:

  • Turning the option off: a later prebuild without --clean leaves the target in place. Is that intended, or should the plugin remove the target it added?
  • Versions: the extension's Info.plist gets the app's version/buildNumber written in as fixed values. With EAS remote versioning (autoIncrement), does EAS also bump the extension's plist? If it doesn't, the app and the extension will end up with different versions.
  • Small one: ExpoConfig already declares ios.appleTeamId, so the cast in withNotificationServiceExtensionIos can go.

I'll follow up with a full review once CI has run.

@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.13514% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.80%. Comparing base (67f9838) to head (ac2262f).
⚠️ Report is 21 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #9401      +/-   ##
============================================
- Coverage     75.11%   71.80%   -3.30%     
+ Complexity     2299     2298       -1     
============================================
  Files           620      445     -175     
  Lines         41232    25691   -15541     
  Branches       5242     4393     -849     
============================================
- Hits          30966    18445   -12521     
+ Misses         8689     5950    -2739     
+ Partials       1577     1296     -281     
Flag Coverage Δ
android-native 70.69% <ø> (-<0.01%) ⬇️
e2e-ts-android 53.57% <ø> (-0.05%) ⬇️
e2e-ts-ios ?
e2e-ts-macos 49.31% <ø> (ø)
ios-native ?
ios-ruby ?
jest 52.25% <85.14%> (+0.43%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…n off; drop the appleTeamId cast

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simondaigre

Copy link
Copy Markdown
Author

Thanks for the review and for approving the runs!

CLA: I tried the recheck link again, but the CLA assistant page loads without anything on it: no agreement to sign and no button, as in the screenshot from my earlier comment. Is there another way I can sign it?

Turning the option off: that's intended, as prebuild without --clean is additive for config plugins in general; --clean regenerates ios/ without the target. Removing a target by name could also delete one the user added themselves. I added a note to the docs saying to run prebuild --clean after turning it off.

Versions: yes. The plugin declares the extension in extra.eas.build.experimental.ios.appExtensions, so EAS provisions it, and EAS Build's version step (updateVersionsAsync in build-tools/src/ios/configure.ts) writes CFBundleVersion/CFBundleShortVersionString into the Info.plist of every provisioned target, the extension's included. Without EAS, both plists get the same values from the config at prebuild.

Cast: right, removed.

}

// An `app_extension` target is embedded in the first target, the app target of an Expo project.
const target = project.addTarget(targetName, 'app_extension', targetName, bundleIdentifier);

@Mastersam07 Mastersam07 Oct 29, 2026 •

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.

addTarget(…, 'app_extension', …) (from the xcode npm package @expo/config-plugins uses) adds the .appex to the first phase named "Copy Files" anywhere in the project, not the one it just created: it looks the phase up by the new target's uuid, which has no phases yet. Reproduced with that package directly: if the app already has a Copy Files phase with another destination (e.g. Frameworks), the .appex goes there instead of PlugIns/. It needs another plugin's phase to hit, so not a blocker, but could the plugin make sure the .appex ends up in a dstSubfolderSpec = 13 phase on the app target it already finds?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, thanks. Fixed in 5a16d8e: after addTarget, the plugin moves the .appex into the app target's dstSubfolderSpec = 13 phase (adding one if the app has none) and removes it from every other Copy Files phase. It runs on each prebuild, so a project generated before the fix gets corrected too.

log "ERROR: NotificationServiceExtension does not link FirebaseMessaging (no FIRMessaging class reference)"
exit 1
fi
log "PASS: NotificationServiceExtension.appex is embedded and links FirebaseMessaging"

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.

This is a good build-time check. The CI log shows Firebase's resource bundles being copied into the extension, so FirebaseMessaging is linked statically and the extension is self-contained. Nothing exercises the extension with a notification, though. Could you confirm once, by hand, that an image actually shows? Either a push to a device, or xcrun simctl push on a simulator with "mutable-content": 1 in aps and an image URL (fcm_options.image in the FCM payload). No need to automate it here.

@simondaigre simondaigre Oct 29, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, confirmed by hand on a real app: with the extension added by the plugin, an FCM notification with an image (fcm_options.image) shows the image. It takes a built app receiving a real push: xcrun simctl push doesn't trigger notification service extensions.

}
```

On `npx expo prebuild`, the plugin adds a `NotificationServiceExtension` target embedded in the app, with its source and `Info.plist` in `ios/NotificationServiceExtension/`. The extension links `FirebaseMessaging` through Swift Package Manager at the exact Firebase Apple SDK version of `@react-native-firebase/app`, the same package reference `pod install` links `FirebaseCore` into the app with (see [iOS SPM Support](/ios-spm)). Its bundle identifier is `<ios.bundleIdentifier>.NotificationServiceExtension`, and the plugin declares it to EAS Build (`extra.eas.build.experimental.ios.appExtensions`) so that EAS provisions and signs it.

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.

Could you add a sentence for apps that already get a notification service extension from another plugin (OneSignal's, for example)? Enabling this option as well gives them two extensions, so the docs should tell people not to combine them, or what to expect if they do.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in 5a16d8e: iOS hands an app's notifications to a single notification service extension, so the docs now say not to enable this option when another plugin (OneSignal's, for example) already adds one, and to use one or the other.

extension: NotificationServiceExtension,
): ExpoConfig {
const eas = config.extra?.eas ?? {};
const appExtensions: { targetName: string }[] = eas.build?.experimental?.ios?.appExtensions ?? [];

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.

Nit: no change needed: Expo still labels extra.eas.build.experimental.ios.appExtensions as experimental, so if it gets renamed we'll need to follow. I don't see a better hook today.

});

describe('addNotificationServiceExtension', () => {
it('adds an app extension target embedded in the app target', () => {

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.

To go with the addTarget comment: could you add a case where the app target already has a Copy Files phase with another destination (e.g. project.addBuildPhase([], 'PBXCopyFilesBuildPhase', 'Copy Files', appUuid, 'frameworks') on the existing fixture before calling addNotificationServiceExtension), and assert that the .appex is in a dstSubfolderSpec = 13 phase on the app target and in no other phase?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in 5a16d8e: the fixture gets a Copy Files phase to Frameworks on the app target first, and the test asserts the .appex is in exactly one phase, a dstSubfolderSpec = 13 one on the app target, and that the app has only one such phase. It failed before the fix (the product landed in the Frameworks phase, dstSubfolderSpec = 10). The second-prebuild test also checks the app embeds the product once.

…app's PlugIns phase

`addTarget` puts the product in the project's first "Copy Files" phase, whatever its target and destination.
Also document not combining it with another plugin's notification service extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants