From a474e2389fc696f69428184933be63e8d8b841ab Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Wed, 10 Jun 2026 21:53:55 +0400 Subject: [PATCH 1/3] feat(example): showcase optional Customer.io inclusion via single build flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates how a host that ships separate per-customer builds can include Customer.io for some builds and fully exclude it for others — no compiled CIO code, none of the SDK's JS bundled, and zero runtime footprint — all driven by one build-time flag, CIO_ENABLED. The sample app now imports CIO only through a `@cio` facade, never from 'customerio-reactnative' directly: - JS: metro.config.js resolveRequest swaps `@cio` between src/cio/index.real.ts and src/cio/index.noop.ts on CIO_ENABLED; tsconfig maps `@cio` -> real for types. The noop redefines enums and stubs the API surface without importing the package, so the SDK's getEnforcing TurboModules are never evaluated. - Native: react-native.config.js disables autolinking on both platforms when off; the Podfile skips the customerio-reactnative + rich-push pods; and AppDelegate.swift guards the CIO app-delegate wrapper and push init behind `#if canImport(CioMessagingPush…)` so they compile out when the pods are absent. All four levers read the same flag, so the JS and native layers can never disagree (native-off + JS-real would crash via getEnforcing at launch). Verified locally for both flag states: tsc clean; Metro bundle swaps real/noop (disabled bundle has no CIO getEnforcing and excludes the SDK code); `react-native config` links/excludes CIO on both platforms; and full Android assembleDebug builds succeed both ways (PackageList.java registers CustomerIOReactNativePackage only when enabled; disabled APK is ~3.5 MB smaller). Co-Authored-By: Claude Opus 4.8 (1M context) --- example/README.md | 27 ++++++ example/ios/Podfile | 15 +++- example/ios/SampleApp/AppDelegate.swift | 48 ++++++++--- example/metro.config.js | 32 ++++++- example/react-native.config.js | 26 ++++-- example/src/App.tsx | 2 +- example/src/cio/index.noop.ts | 107 ++++++++++++++++++++++++ example/src/cio/index.real.ts | 11 +++ example/src/navigation/context.ts | 2 +- example/src/navigation/props.ts | 2 +- example/src/screens/home.tsx | 2 +- example/src/screens/inbox-messages.tsx | 2 +- example/src/screens/inline-examples.tsx | 2 +- example/src/screens/location.tsx | 2 +- example/src/screens/settings.tsx | 2 +- example/src/services/storage.ts | 2 +- example/tsconfig.json | 1 + 17 files changed, 253 insertions(+), 32 deletions(-) create mode 100644 example/src/cio/index.noop.ts create mode 100644 example/src/cio/index.real.ts diff --git a/example/README.md b/example/README.md index d82a70f1..0b0b73f5 100644 --- a/example/README.md +++ b/example/README.md @@ -10,6 +10,33 @@ All `CustomerIO.*` calls are located in [`src/App.tsx`](src/App.tsx) for easy re --- +## 🧩 Conditional Customer.io inclusion (`CIO_ENABLED`) + +This app demonstrates how a host that ships **separate builds per customer** can include Customer.io for some builds and **fully exclude** it for others — no compiled CIO code, none of the SDK's JS bundled, and zero runtime footprint — all driven by a single build-time flag. + +Set `CIO_ENABLED=0` at build time to exclude Customer.io; omit it (or set anything else) to include it (the default). + +| Layer | File | What the flag does | +| --- | --- | --- | +| JS bundle | [`metro.config.js`](metro.config.js) | Resolves the `@cio` facade to [`src/cio/index.noop.ts`](src/cio/index.noop.ts) (inert stub) instead of [`index.real.ts`](src/cio/index.real.ts), so `customerio-reactnative` is never bundled. | +| Native linking | [`react-native.config.js`](react-native.config.js) | Disables autolinking (`platforms: { ios: null, android: null }`) so the native module isn't compiled in and its manifest entries/pods don't merge. | +| iOS pods | [`ios/Podfile`](ios/Podfile) | Skips the `customerio-reactnative` + rich-push pods. | +| iOS native code | [`ios/SampleApp/AppDelegate.swift`](ios/SampleApp/AppDelegate.swift) | `#if canImport(CioMessagingPush…)` guards compile out the CIO app-delegate wrapper and push init automatically when the pods are absent. | + +> **All four must read the same flag.** The app imports CIO only through the `@cio` facade, never from `customerio-reactnative` directly. Every native bridge in the SDK uses `TurboModuleRegistry.getEnforcing(...)`, which throws at launch if the JS is loaded while the native module is unlinked — so a lazy/conditional `import()` is **not** a safe substitute for the facade swap. + +```bash +# Include Customer.io (default) +npm run ios +npm run android + +# Exclude Customer.io entirely +CIO_ENABLED=0 npm run pods && CIO_ENABLED=0 npm run ios +CIO_ENABLED=0 npm run android +``` + +--- + ## 📱 iOS SDK Integration Key files involved in the native iOS setup: diff --git a/example/ios/Podfile b/example/ios/Podfile index b13b40c5..5faf8531 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -29,6 +29,13 @@ setup_permissions([ push_provider = (ENV["PUSH_PROVIDER"] || "apn").downcase +# Single build-time flag — must agree with example/metro.config.js and +# example/react-native.config.js. CIO_ENABLED=0 excludes the Customer.io pods +# from the build entirely (no compiled CIO code in the app or the NSE target). +# The matching #if canImport(...) guards in AppDelegate.swift then compile out +# the native push setup automatically, since those modules won't be importable. +cio_enabled = (ENV["CIO_ENABLED"] != "0") + app_target_name = "SampleApp" nse_target_name = "NotificationServiceExtension" installation_root = Pod::Config.instance.installation_root @@ -60,7 +67,9 @@ target app_target_name do :path => config[:reactNativePath], :app_path => "#{installation_root}/..", ) - pod "customerio-reactnative", :path => cio_package_path, :subspecs => [push_provider, "location"] + if cio_enabled + pod "customerio-reactnative", :path => cio_package_path, :subspecs => [push_provider, "location"] + end # install_non_production_ios_sdk_local_path(local_path: '~/code/customerio-ios/', is_app_extension: false, push_service: push_provider) # install_non_production_ios_sdk_git_branch(branch_name: 'feature/wrappers-inline-support', is_app_extension: false, push_service: push_provider) @@ -79,7 +88,9 @@ target nse_target_name do inherit! :none # Ideally, installing non-production SDK to main target should be enough # We should not need to install non-production SDK to app extension separately - pod "customerio-reactnative-richpush/#{push_provider}", :path => cio_package_path + if cio_enabled + pod "customerio-reactnative-richpush/#{push_provider}", :path => cio_package_path + end # install_non_production_ios_sdk_local_path(local_path: '~/code/customerio-ios/', is_app_extension: true, push_service: push_provider) # install_non_production_ios_sdk_git_branch(branch_name: 'BRANCH-NAME-HERE', is_app_extension: true, push_service: push_provider) end diff --git a/example/ios/SampleApp/AppDelegate.swift b/example/ios/SampleApp/AppDelegate.swift index 48b2cfe9..f2a60c5e 100644 --- a/example/ios/SampleApp/AppDelegate.swift +++ b/example/ios/SampleApp/AppDelegate.swift @@ -6,25 +6,41 @@ import ReactAppDependencyProvider import UserNotifications #if USE_FCM +#if canImport(FirebaseCore) import FirebaseMessaging import FirebaseCore +#endif +#if canImport(CioMessagingPushFCM) import CioMessagingPushFCM import CioFirebaseWrapper typealias CioMessagingPushHandler = MessagingPushFCM +#endif let UNIVERSAL_LINK_URL = URL(string: "http://www.amiapp-reactnative-fcm.com")! #else +#if canImport(CioMessagingPushAPN) import CioMessagingPushAPN typealias CioMessagingPushHandler = MessagingPushAPN +#endif let UNIVERSAL_LINK_URL = URL(string: "http://www.amiapp-reactnative-apns.com")! #endif +// Customer.io availability is decided at build time by CIO_ENABLED (see the +// Podfile): when CIO is excluded, none of its pods are installed, so the +// modules below aren't importable and the app uses a plain app delegate with +// zero Customer.io code. When CIO is included, the app delegate is wrapped by +// CioAppDelegateWrapper so the SDK can observe lifecycle/push callbacks. +#if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN) @main class AppDelegateWithCioIntegration: CioAppDelegateWrapper {} +#else +@main +class AppDelegateMain: AppDelegate {} +#endif class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? @@ -44,8 +60,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { reactNativeFactory = factory window = UIWindow(frame: UIScreen.main.bounds) - - + + let remotePush = launchOptions?[UIApplication.LaunchOptionsKey.remoteNotification] as? [String: [String: [String: String]]] if let link = remotePush?["CIO"]?["push"]?["link"], let url = URL(string:link) { var launchOptions = launchOptions ?? [:] @@ -53,31 +69,35 @@ class AppDelegate: UIResponder, UIApplicationDelegate { launchOptions[UIApplication.LaunchOptionsKey.url] = url } } - + let appName = Bundle.main.displayName - + factory.startReactNative( withModuleName: appName, in: window, initialProperties: ["appName": appName], launchOptions: launchOptions ) - + #if USE_FCM + #if canImport(FirebaseCore) FirebaseApp.configure() Messaging.messaging().delegate = self #endif - - + #endif + + + #if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN) CioMessagingPushHandler.initialize( withConfig: MessagingPushConfigBuilder() .appGroupId("group.io.customer.ami.cio") .build() ) - + #endif + return true } - + } // MARK: Deep linking @@ -85,7 +105,7 @@ extension AppDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { return RCTLinkingManager.application(app, open: url, options: options) } - + func application( _ application: UIApplication, continue userActivity: NSUserActivity, @@ -97,9 +117,9 @@ extension AppDelegate { restorationHandler: restorationHandler ) } - + return false - + } } @@ -107,12 +127,14 @@ extension AppDelegate { #if USE_FCM +#if canImport(FirebaseMessaging) extension AppDelegate: MessagingDelegate { func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { // Not needed when CioAppDelegateWrapper is used // MessagingPush.shared.messaging(messaging, didReceiveRegistrationToken: fcmToken) } } +#endif #else extension AppDelegate { @@ -120,7 +142,7 @@ extension AppDelegate { // Not needed when CioAppDelegateWrapper is used // MessagingPush.shared.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) } - + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { // Not needed when CioAppDelegateWrapper is used // MessagingPush.shared.application(application, didFailToRegisterForRemoteNotificationsWithError: error) diff --git a/example/metro.config.js b/example/metro.config.js index 88df01a3..27ad5449 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -5,7 +5,35 @@ const { getDefaultConfig } = require('@react-native/metro-config'); * Metro configuration * https://facebook.github.io/metro/docs/configuration * + * Conditional Customer.io inclusion: + * `@cio` is the single facade specifier the sample app imports CIO through. + * The build-time flag CIO_ENABLED decides which file it resolves to: + * - CIO_ENABLED=0 -> src/cio/index.noop.ts (inert stub, CIO not bundled) + * - otherwise -> src/cio/index.real.ts (real SDK) + * This MUST agree with example/react-native.config.js and the iOS Podfile, + * which gate native linking on the same flag. If they disagree (native off but + * JS real), the SDK's getEnforcing TurboModules throw at launch. + * * @type {import('metro-config').MetroConfig} */ -const defaultConfig = getDefaultConfig(__dirname); -module.exports = defaultConfig; +const cioEnabled = process.env.CIO_ENABLED !== '0'; +const cioFacade = path.resolve( + __dirname, + cioEnabled ? 'src/cio/index.real.ts' : 'src/cio/index.noop.ts' +); + +const config = getDefaultConfig(__dirname); + +const upstreamResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName === '@cio') { + return { type: 'sourceFile', filePath: cioFacade }; + } + return (upstreamResolveRequest ?? context.resolveRequest)( + context, + moduleName, + platform + ); +}; + +module.exports = config; diff --git a/example/react-native.config.js b/example/react-native.config.js index 4dc1af64..89f99000 100644 --- a/example/react-native.config.js +++ b/example/react-native.config.js @@ -1,18 +1,32 @@ const path = require('path'); const pkg = require('../package.json'); +// Single build-time flag — must agree with example/metro.config.js and the iOS +// Podfile. CIO_ENABLED=0 disables autolinking for customerio-reactnative on +// both platforms, so the native module is not compiled into the app: no native +// code, and none of the library's merged AndroidManifest entries (e.g. the +// POST_NOTIFICATIONS permission) or iOS pods land in the build artifact. +const cioEnabled = process.env.CIO_ENABLED !== '0'; + /** @type import("@react-native-community/cli-types").Config */ module.exports = { dependencies: { [pkg.name]: { root: path.join(__dirname, '..'), - platforms: { - // Codegen script incorrectly fails without this - // So we explicitly specify the platforms with empty object - ios: {}, - android: {}, - }, + platforms: cioEnabled + ? { + // Codegen script incorrectly fails without this + // So we explicitly specify the platforms with empty object + ios: {}, + android: {}, + } + : { + // Exclude CIO: null disables autolinking on each platform so + // use_native_modules! / the Android settings plugin skip it. + ios: null, + android: null, + }, }, }, }; diff --git a/example/src/App.tsx b/example/src/App.tsx index 2a950efb..4f7e2570 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -6,7 +6,7 @@ import { NavigationContainer } from '@react-navigation/native'; import { ContentNavigator } from '@screens'; import { Storage } from '@services'; import { appTheme } from '@utils'; -import { CioConfig, CioPushPermissionStatus, CustomerIO, InAppMessageEvent, InAppMessageEventType } from 'customerio-reactnative'; +import { CioConfig, CioPushPermissionStatus, CustomerIO, InAppMessageEvent, InAppMessageEventType } from '@cio'; import FlashMessage from 'react-native-flash-message'; import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { enableFreeze, enableScreens } from 'react-native-screens'; diff --git a/example/src/cio/index.noop.ts b/example/src/cio/index.noop.ts new file mode 100644 index 00000000..9a57ea51 --- /dev/null +++ b/example/src/cio/index.noop.ts @@ -0,0 +1,107 @@ +/** + * No-op stand-in for `customerio-reactnative`. + * + * Metro resolves `@cio` to this file when the build flag CIO_ENABLED === '0' + * (see example/metro.config.js). It lets the sample app build, bundle, and run + * with Customer.io completely excluded — no native module linked, none of the + * SDK's JS bundled, and zero runtime footprint. + * + * Why a hand-written stub instead of a lazy/conditional `import()` of the real + * package: every native bridge in customerio-reactnative is registered with + * `TurboModuleRegistry.getEnforcing(...)`, which THROWS at module-evaluation + * time when the native module isn't linked. With CIO_ENABLED=0 the native + * module is intentionally not linked (see example/react-native.config.js), so + * the real JS must never be imported. Hence this file imports no runtime value + * from 'customerio-reactnative'. Type-only re-exports below are erased by the + * compiler, so they cost nothing at runtime yet keep every call site fully + * typed against the genuine SDK surface. + */ + +// --- Types: erased at build time, safe to re-export from the real package. --- +export type { + CioConfig, + InAppMessage, + InAppMessageEvent, + InboxMessage, +} from 'customerio-reactnative'; + +// --- Enums: redefined verbatim. They cannot be imported from the package --- +// --- root, because evaluating that module triggers the getEnforcing specs. --- +export enum CioPushPermissionStatus { + Granted = 'GRANTED', + Denied = 'DENIED', + NotDetermined = 'NOTDETERMINED', +} +export enum CioLogLevel { + None = 'none', + Error = 'error', + Info = 'info', + Debug = 'debug', +} +export enum CioRegion { + US = 'US', + EU = 'EU', +} +export enum CioLocationTrackingMode { + Off = 'OFF', + Manual = 'MANUAL', + OnAppStart = 'ON_APP_START', +} +export enum InAppMessageEventType { + errorWithMessage = 'errorWithMessage', + messageActionTaken = 'messageActionTaken', + messageDismissed = 'messageDismissed', + messageShown = 'messageShown', +} + +// --- Inert runtime stubs. Never throw, never touch NativeModules. --- +const noop = () => {}; +const asyncNoop = async () => {}; +const subscription = { remove: () => {} }; + +const inbox = { + subscribeToMessages: (_opts?: unknown) => subscription, + getMessages: async () => [], + markMessageOpened: noop, + markMessageUnopened: noop, + markMessageDeleted: noop, + trackMessageClicked: noop, +}; + +const inAppMessaging = { + registerEventsListener: (_listener: unknown) => subscription, + dismissMessage: noop, + inbox: () => inbox, +}; + +const location = { + setLastKnownLocation: noop, + requestLocationUpdate: noop, +}; + +const pushMessaging = { + showPromptForPushNotifications: async () => + CioPushPermissionStatus.NotDetermined, +}; + +const CustomerIONoop = { + initialize: noop, + identify: noop, + clearIdentify: asyncNoop, + track: noop, + screen: noop, + setProfileAttributes: noop, + setDeviceAttributes: noop, + inAppMessaging, + location, + pushMessaging, +}; + +// Cast the inert objects to the real SDK types so call sites are still checked +// against the genuine interface (TypeScript resolves @cio -> index.real). +export const CustomerIO = + CustomerIONoop as unknown as typeof import('customerio-reactnative').CustomerIO; + +export const InlineInAppMessageView = ((_props: unknown) => + null) as unknown as typeof import('customerio-reactnative').InlineInAppMessageView; + diff --git a/example/src/cio/index.real.ts b/example/src/cio/index.real.ts new file mode 100644 index 00000000..39169807 --- /dev/null +++ b/example/src/cio/index.real.ts @@ -0,0 +1,11 @@ +/** + * Real Customer.io integration. + * + * Metro resolves `@cio` to this file when the build flag CIO_ENABLED !== '0' + * (the default), and TypeScript always resolves `@cio` here for types (see the + * `paths` entry in example/tsconfig.json). App code imports CIO only through + * `@cio`, never from 'customerio-reactnative' directly, so a single Metro + * resolver decision swaps the entire SDK for a no-op stand-in. + */ +export * from 'customerio-reactnative'; + diff --git a/example/src/navigation/context.ts b/example/src/navigation/context.ts index 9d04cb0e..e89ee11f 100644 --- a/example/src/navigation/context.ts +++ b/example/src/navigation/context.ts @@ -1,4 +1,4 @@ -import { CioPushPermissionStatus } from 'customerio-reactnative'; +import { CioPushPermissionStatus } from '@cio'; import { createContext } from 'react'; import { ContentNavigatorCallbacks } from './props'; diff --git a/example/src/navigation/props.ts b/example/src/navigation/props.ts index f7e24907..d7923045 100644 --- a/example/src/navigation/props.ts +++ b/example/src/navigation/props.ts @@ -1,7 +1,7 @@ import { NavigationProp } from '@react-navigation/native'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { TrackEvent, User } from '@utils'; -import { CioConfig, CioPushPermissionStatus } from 'customerio-reactnative'; +import { CioConfig, CioPushPermissionStatus } from '@cio'; export const SettingsScreenName = 'Settings' as const; export const HomeScreenName = 'Customer.io' as const; diff --git a/example/src/screens/home.tsx b/example/src/screens/home.tsx index 390be428..cbe28962 100644 --- a/example/src/screens/home.tsx +++ b/example/src/screens/home.tsx @@ -9,7 +9,7 @@ import { NavigationScreenProps, } from '@navigation'; import { Storage } from '@services'; -import { CioPushPermissionStatus } from 'customerio-reactnative'; +import { CioPushPermissionStatus } from '@cio'; import React, { useContext, useState } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { showMessage } from 'react-native-flash-message'; diff --git a/example/src/screens/inbox-messages.tsx b/example/src/screens/inbox-messages.tsx index 57f035ab..adf94ade 100644 --- a/example/src/screens/inbox-messages.tsx +++ b/example/src/screens/inbox-messages.tsx @@ -1,5 +1,5 @@ import { NavigationScreenProps } from '@navigation'; -import { CustomerIO, InboxMessage } from 'customerio-reactnative'; +import { CustomerIO, InboxMessage } from '@cio'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, diff --git a/example/src/screens/inline-examples.tsx b/example/src/screens/inline-examples.tsx index 81a4d659..5dc89c46 100644 --- a/example/src/screens/inline-examples.tsx +++ b/example/src/screens/inline-examples.tsx @@ -1,6 +1,6 @@ import { NavigationCallbackContext } from '@navigation'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { InlineInAppMessageView, type InAppMessage } from 'customerio-reactnative'; +import { InlineInAppMessageView, type InAppMessage } from '@cio'; import React, { useContext } from 'react'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; diff --git a/example/src/screens/location.tsx b/example/src/screens/location.tsx index 621f9a09..ffe11a28 100644 --- a/example/src/screens/location.tsx +++ b/example/src/screens/location.tsx @@ -5,7 +5,7 @@ import { ButtonExperience, } from '@components'; import { Colors } from '@colors'; -import { CustomerIO } from 'customerio-reactnative'; +import { CustomerIO } from '@cio'; import React, { useState } from 'react'; import { Alert, diff --git a/example/src/screens/settings.tsx b/example/src/screens/settings.tsx index ce26b8eb..f3a6ee38 100644 --- a/example/src/screens/settings.tsx +++ b/example/src/screens/settings.tsx @@ -13,7 +13,7 @@ import { CioLogLevel, CioRegion, CustomerIO, -} from 'customerio-reactnative'; +} from '@cio'; import React, { useState } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { showMessage } from 'react-native-flash-message'; diff --git a/example/src/services/storage.ts b/example/src/services/storage.ts index cadc943b..5c49a832 100644 --- a/example/src/services/storage.ts +++ b/example/src/services/storage.ts @@ -1,6 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { User } from '@utils'; -import { CioConfig, CioLocationTrackingMode, CioLogLevel, CioRegion } from 'customerio-reactnative'; +import { CioConfig, CioLocationTrackingMode, CioLogLevel, CioRegion } from '@cio'; import { Env } from '../env'; const USER_STORAGE_KEY = 'user'; diff --git a/example/tsconfig.json b/example/tsconfig.json index e31d35db..e39c236b 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -7,6 +7,7 @@ "rootDir": ".", "types": ["jest"], "paths": { + "@cio": ["./src/cio/index.real"], "@assets/*": ["./src/assets/*"], "@components": ["./src/components"], "@screens": ["./src/screens"], From e9ae43e0b58bdaed064d58ea859a0a943a12cf02 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Wed, 10 Jun 2026 22:33:15 +0400 Subject: [PATCH 2/3] ci(example): build sample apps with CIO_ENABLED=0 to validate the disabled path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Showcase-only: forces CI sample-app builds to exclude Customer.io so the off path is exercised in CI. Not for merge — normal builds leave the flag unset (defaults to enabled). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-sample-app.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build-sample-app.yml b/.github/workflows/build-sample-app.yml index bc2628b8..4e9f2306 100644 --- a/.github/workflows/build-sample-app.yml +++ b/.github/workflows/build-sample-app.yml @@ -48,6 +48,10 @@ on: env: RUBY_VERSION: '3.2' NODE_VERSION: '20' + # SHOWCASE ONLY (showcase/optional-cio-inclusion): force the disabled path so + # CI validates that the sample apps build with Customer.io fully excluded. + # Do not merge — normal builds must leave this unset (defaults to enabled). + CIO_ENABLED: '0' jobs: build-sample-app: From a7746e520af05c6fdcd5e3336295eec07121948f Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Wed, 10 Jun 2026 22:56:47 +0400 Subject: [PATCH 3/3] fix(example): gate Notification Service Extension behind canImport for the off path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NSE source imported CioMessagingPushFCM/APN unconditionally, so with CIO_ENABLED=0 (rich-push pod excluded) the extension failed to compile. Guard the CIO push code with #if canImport(...) — same approach as AppDelegate.swift — and fall back to delivering the notification unmodified when Customer.io is excluded, so the NSE is coherent in both flag states. Co-Authored-By: Claude Opus 4.8 (1M context) --- example/README.md | 2 +- .../NotificationService.swift | 22 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/example/README.md b/example/README.md index 0b0b73f5..9757a566 100644 --- a/example/README.md +++ b/example/README.md @@ -21,7 +21,7 @@ Set `CIO_ENABLED=0` at build time to exclude Customer.io; omit it (or set anythi | JS bundle | [`metro.config.js`](metro.config.js) | Resolves the `@cio` facade to [`src/cio/index.noop.ts`](src/cio/index.noop.ts) (inert stub) instead of [`index.real.ts`](src/cio/index.real.ts), so `customerio-reactnative` is never bundled. | | Native linking | [`react-native.config.js`](react-native.config.js) | Disables autolinking (`platforms: { ios: null, android: null }`) so the native module isn't compiled in and its manifest entries/pods don't merge. | | iOS pods | [`ios/Podfile`](ios/Podfile) | Skips the `customerio-reactnative` + rich-push pods. | -| iOS native code | [`ios/SampleApp/AppDelegate.swift`](ios/SampleApp/AppDelegate.swift) | `#if canImport(CioMessagingPush…)` guards compile out the CIO app-delegate wrapper and push init automatically when the pods are absent. | +| iOS native code | [`ios/SampleApp/AppDelegate.swift`](ios/SampleApp/AppDelegate.swift), [`ios/NotificationServiceExtension/NotificationService.swift`](ios/NotificationServiceExtension/NotificationService.swift) | `#if canImport(CioMessagingPush…)` guards compile out the CIO app-delegate wrapper, push init, and the rich-push Notification Service Extension logic automatically when the pods are absent (the NSE falls back to delivering the notification unmodified). | > **All four must read the same flag.** The app imports CIO only through the `@cio` facade, never from `customerio-reactnative` directly. Every native bridge in the SDK uses `TurboModuleRegistry.getEnforcing(...)`, which throws at launch if the JS is loaded while the native module is unlinked — so a lazy/conditional `import()` is **not** a safe substitute for the facade swap. diff --git a/example/ios/NotificationServiceExtension/NotificationService.swift b/example/ios/NotificationServiceExtension/NotificationService.swift index 3fce6e21..14537c8b 100644 --- a/example/ios/NotificationServiceExtension/NotificationService.swift +++ b/example/ios/NotificationServiceExtension/NotificationService.swift @@ -1,12 +1,16 @@ import UserNotifications + #if USE_FCM +#if canImport(CioMessagingPushFCM) import CioMessagingPushFCM typealias PushInitializer = MessagingPushFCM +#endif #else - +#if canImport(CioMessagingPushAPN) import CioMessagingPushAPN typealias PushInitializer = MessagingPushAPN #endif +#endif class NotificationService: UNNotificationServiceExtension { @@ -15,9 +19,10 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { +#if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN) + // Customer.io rich push is included in this build (CIO_ENABLED != 0). // Rename the file NotificationServiceExtension/Env.swift.sample to NotificationServiceExtension/Env.swift // and set the CDP_API_KEY value to your CDP API Key - PushInitializer.initializeForExtension( withConfig: MessagingPushConfigBuilder(cdpApiKey: Env.CDP_API_KEY) .logLevel(.debug) @@ -26,9 +31,22 @@ class NotificationService: UNNotificationServiceExtension { ) MessagingPush.shared.didReceive(request, withContentHandler: contentHandler) +#else + // Customer.io excluded (CIO_ENABLED=0): no rich-push processing — deliver + // the notification unmodified so the extension is inert but valid. + self.contentHandler = contentHandler + self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + contentHandler(bestAttemptContent ?? request.content) +#endif } override func serviceExtensionTimeWillExpire() { +#if canImport(CioMessagingPushFCM) || canImport(CioMessagingPushAPN) MessagingPush.shared.serviceExtensionTimeWillExpire() +#else + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } +#endif } }