From 4a62048b1613ebae922551ded80d624ab385bee5 Mon Sep 17 00:00:00 2001 From: Trent Rand Date: Tue, 17 Feb 2026 15:41:25 -0700 Subject: [PATCH 1/4] Fix package exclusion with sibling targets and xcconfig flag stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the App Clip target was generated nested inside the main app target in the Podfile. This caused it to inherit all pods from the parent, rendering `excludedPackages` completely ineffective. This change moves the App Clip target to the top level as a sibling of the main target, preventing implicit dependency inheritance. However, even with the target un-nested, CocoaPods still emits linker flags for all resolved pods into the generated `.xcconfig` files. This is especially problematic under new architecture + static frameworks, where Codegen headers are expected to exist for any linked package — causing "file not found" build failures. To address this, a `post_install` hook is injected that: 1. Removes excluded packages from the App Clip target's dependency graph 2. Strips all corresponding linker flags (`-l"Pkg"`, `-framework "Pkg"`) directly from the generated xcconfig files via `Xcodeproj::Config` This two-pronged approach ensures excluded packages are absent from both the build graph and the linker invocation, regardless of project settings. Fixes #81, #79 --- plugin/src/withPodfile.ts | 169 +++++++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 46 deletions(-) diff --git a/plugin/src/withPodfile.ts b/plugin/src/withPodfile.ts index db38328..3e23165 100644 --- a/plugin/src/withPodfile.ts +++ b/plugin/src/withPodfile.ts @@ -7,8 +7,6 @@ export const withPodfile: ConfigPlugin<{ targetName: string; excludedPackages?: string[]; }> = (config, { targetName, excludedPackages }) => { - // return config; - return withDangerousMod(config, [ "ios", (config) => { @@ -20,67 +18,146 @@ export const withPodfile: ConfigPlugin<{ const useExpoModules = excludedPackages && excludedPackages.length > 0 - ? `exclude = ["${excludedPackages.join(`", "`)}"]\n use_expo_modules!(exclude: exclude)` + ? `exclude = ["${excludedPackages.join(`", "`)}"]\n use_expo_modules!(exclude: exclude)` : "use_expo_modules!"; const appClipTarget = ` - target '${targetName}' do - ${useExpoModules} - - if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' - config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; - else - config_command = [ - 'npx', - 'expo-modules-autolinking', - 'react-native-config', - '--json', - '--platform', - 'ios' - ] - end +target '${targetName}' do + ${useExpoModules} - # Running the command in the same manner as \`use_react_native\` then running that result through our cliPlugin - json, message, status = Pod::Executable.capture_command(config_command[0], config_command[1..], capture: :both) - if not status.success? - Pod::UI.warn "The command: '#{config_command.join(" ").bold.yellow}' returned a status code of #{status.exitstatus.to_s.bold.red}, #{message}", [ - "App Clip autolinking failed. Please ensure autolinking works correctly for the main app target and try again.", - ] - exit(status.exitstatus) - end + if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' + config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; + else + config_command = [ + 'npx', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios' + ] + end - # \`react-native-app-clip\` resolves to react-native-app-clip/build/index.js - clip_command = [ - 'node', - '--no-warnings', - '--eval', - 'require(require.resolve(\\'react-native-app-clip\\')+\\'/../../plugin/build/cliPlugin.js\\').run(' + json + ', [${(excludedPackages ?? []).map((packageName) => `"${packageName}"`).join(", ")}])' + # Running the command in the same manner as \`use_react_native\` then running that result through our cliPlugin + json, message, status = Pod::Executable.capture_command(config_command[0], config_command[1..], capture: :both) + if not status.success? + Pod::UI.warn "The command: '#{config_command.join(" ").bold.yellow}' returned a status code of #{status.exitstatus.to_s.bold.red}, #{message}", [ + "App Clip autolinking failed. Please ensure autolinking works correctly for the main app target and try again.", ] + exit(status.exitstatus) + end - config = use_native_modules!(clip_command) + # \`react-native-app-clip\` resolves to react-native-app-clip/build/index.js + clip_command = [ + 'node', + '--no-warnings', + '--eval', + 'require(require.resolve(\\'react-native-app-clip\\')+\\'/../../plugin/build/cliPlugin.js\\').run(' + json + ', [])' + ] - use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] - use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] + config = use_native_modules!(clip_command) - use_react_native!( - :path => config[:reactNativePath], - :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes', - # An absolute path to your application root. - :app_path => "#{Pod::Config.instance.installation_root}/..", - :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', - ) - end - `; + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] + use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] + + use_react_native!( + :path => config[:reactNativePath], + :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes', + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/..", + :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', + ) +end`; + + // Inject content at the top-level (sibling to the main target) + const anchor = "prepare_react_native_project!"; + const offset = 1; + + if (!podfileContent.includes(anchor)) { + throw new Error( + "[react-native-app-clip] Could not find 'prepare_react_native_project!' in Podfile. " + + "This plugin requires Expo SDK 53+ project structure." + ); + } podfileContent = mergeContents({ tag: "Generated by react-native-app-clip", src: podfileContent, newSrc: appClipTarget, - anchor: "use_expo_modules!", - offset: 0, + anchor: anchor, + offset: offset, comment: "#", }).contents; + // Inject Post-Install hook for explicit exclusion (Target Deps + Linker Flags) + const postInstallPatch = ` + # [react-native-app-clip] Post-install cleanup for App Clip exclusions + excluded_from_clip = ${JSON.stringify(excludedPackages || [])} + + installer.pods_project.targets.each do |target| + if target.name == 'Pods-${targetName}' + # 1. Remove explicit dependencies (Target Dependencies) + target.dependencies.delete_if do |dep| + excluded_from_clip.any? { |pkg| dep.name.include?(pkg) } + end + + # 2. Remove Linked Libraries from xcconfig (OTHER_LDFLAGS) + target.build_configurations.each do |config| + if config.base_configuration_reference + xcconfig_path = config.base_configuration_reference.real_path + if File.exist?(xcconfig_path) + xcconfig = Xcodeproj::Config.new(xcconfig_path) + ld_flags = xcconfig.attributes['OTHER_LDFLAGS'] + if ld_flags + flags_modified = false + excluded_from_clip.each do |pkg| + # Flag patterns to remove: -l"Pkg", -lPkg, -framework "Pkg", -framework Pkg + patterns = [/-l"#{pkg}"/, /-l#{pkg}\\b/, /-framework "#{pkg}"/, /-framework #{pkg}\\b/] + patterns.each do |pattern| + if ld_flags.match(pattern) + ld_flags.gsub!(pattern, '') + flags_modified = true + end + end + end + if flags_modified + ld_flags.gsub!(/\\s+/, ' ') # Clean up spacing + xcconfig.attributes['OTHER_LDFLAGS'] = ld_flags + xcconfig.save_as(xcconfig_path) + end + end + end + end + end + end + end + `; + + // Check for existence of post_install block and either append or inject + if (podfileContent.includes("post_install do |installer|")) { + // Idempotent injection: check if our patch is already there + if (!podfileContent.includes("# [react-native-app-clip] Post-install cleanup")) { + podfileContent = podfileContent.replace( + /post_install\s+do\s+\|installer\|/, + `post_install do |installer|\n${postInstallPatch}` + ); + } else { + // Update the excluded packages list dynamically + const newExclusions = JSON.stringify(excludedPackages || []); + podfileContent = podfileContent.replace( + /excluded_from_clip\s*=\s*\[.*?\]/g, + `excluded_from_clip = ${newExclusions}` + ); + } + } else { + // Create new post_install block if missing + podfileContent += ` + post_install do |installer| + ${postInstallPatch} + end + `; + } + fs.writeFileSync(podFilePath, podfileContent); return config; From 43715ec745ca10845b86b54647e974597d4333d0 Mon Sep 17 00:00:00 2001 From: Trent Rand Date: Tue, 17 Feb 2026 16:36:49 -0700 Subject: [PATCH 2/4] Add documentation about `excludedPackages` option --- README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cfac889..2b628df 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,9 @@ NOTE: You can find the simulator device UUID by running `xcrun simctl list`. The - **requestLocationConfirmation** (boolean): Allow App Clip access to location data (see [Apple Developer Docs](https://developer.apple.com/documentation/app_clips/confirming_the_user_s_physical_location)) - **appleSignin** (boolean): Enable "Sign in with Apple" for the App Clip - **applePayMerchantIds** (string[]): Enable Apple Pay capability with provided merchant IDs. -- **excludedPackages** (string[]): Packages to exclude from autolinking for the App Clip to reduce bundle size (see below). - **pushNotifications** (boolean): Enable push notification compatibility for the App Clip - **enableCompression** (boolean): Enables gzip compression of the App Clip's JavaScript bundle to reduce its size. Please note: This may increase the final binary size in some cases (see [App Clip Size Limits](#app-clip-size-limits)). - +- **excludedPackages** (string[]): Packages to exclude from the App Clip to reduce binary size (see [Excluding packages](#excluding-packages)). ## App Clip Size Limits To ensure a fast launch experience, App Clips must be small. The size limits depend on the iOS deployment target: @@ -76,7 +75,28 @@ For iOS 17+, the 100 MB limit has additional requirements: - Requires reliable internet connection usage scenarios - Does not support iOS 16 and earlier -You can exclude packages (via `excludedPackages` parameter) and use compression (via `enableCompression` parameter) to help stay within these limits. However, since the App Clip binary itself is compressed by Apple, pre-compressing the JS bundle with `enableCompression` might sometimes be counterproductive. Always verify the final size in TestFlight or the App Store Connect dashboard. +You can exclude packages (via `excludedPackages`) and use compression (via `enableCompression`) to help stay within these limits. However, since the App Clip binary itself is compressed by Apple, pre-compressing the JS bundle with `enableCompression` might sometimes be counterproductive. Always verify the final size in TestFlight or the App Store Connect dashboard. + +## Excluding packages + +`excludedPackages` accepts both **npm package names** and **CocoaPods pod names**. Two entries are often needed for the same package because each serves a different purpose: + +- **npm name** (e.g. `"expo-notifications"`) — passed to `use_expo_modules!` to remove the package from Expo's autolinking for the App Clip target +- **pod name** (e.g. `"EXNotifications"`) — used in a `post_install` hook to strip the package's linker flags from the generated `.xcconfig` files + +For most Expo packages the pod name differs from the npm name, so both must be listed. For React Native community packages (e.g. `react-native-nfc-manager`) the pod name typically matches the npm name, so one entry is sufficient. + +```json +"excludedPackages": [ + "expo-notifications", + "EXNotifications", + "expo-web-browser", + "ExpoWebBrowser", + "react-native-nfc-manager" +] +``` + +> **Note:** Under New Architecture, removing a package from the autolinking config prevents Codegen from generating its spec headers — but the pod's own source files still import those headers, causing compile errors. For this reason, packages are kept in the autolinking config and excluded only at the linker stage via xcconfig flag stripping. ## Native capabilities From c7b44cc6181b433862a83a8d89e90169a1773c8f Mon Sep 17 00:00:00 2001 From: Trent Rand Date: Tue, 17 Feb 2026 17:33:51 -0700 Subject: [PATCH 3/4] Remove post_install xcconfig stripping (unnecessary after target un-nesting) Once the App Clip target is a sibling rather than nested inside the main target, `use_expo_modules!(exclude:)` fully prevents excluded packages from being installed or linked. The `post_install` hook that manually stripped OTHER_LDFLAGS was compensating for inherited linker flags that no longer exist. --- README.md | 17 ++-------- plugin/src/withPodfile.ts | 69 --------------------------------------- 2 files changed, 3 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 2b628df..11d40b2 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ NOTE: You can find the simulator device UUID by running `xcrun simctl list`. The - **applePayMerchantIds** (string[]): Enable Apple Pay capability with provided merchant IDs. - **pushNotifications** (boolean): Enable push notification compatibility for the App Clip - **enableCompression** (boolean): Enables gzip compression of the App Clip's JavaScript bundle to reduce its size. Please note: This may increase the final binary size in some cases (see [App Clip Size Limits](#app-clip-size-limits)). -- **excludedPackages** (string[]): Packages to exclude from the App Clip to reduce binary size (see [Excluding packages](#excluding-packages)). +- **excludedPackages** (string[]): node module names to exclude from autolinking for the App Clip to reduce binary size (see [App Clip Size Limits](#app-clip-size-limits)). + ## App Clip Size Limits To ensure a fast launch experience, App Clips must be small. The size limits depend on the iOS deployment target: @@ -77,27 +78,15 @@ For iOS 17+, the 100 MB limit has additional requirements: You can exclude packages (via `excludedPackages`) and use compression (via `enableCompression`) to help stay within these limits. However, since the App Clip binary itself is compressed by Apple, pre-compressing the JS bundle with `enableCompression` might sometimes be counterproductive. Always verify the final size in TestFlight or the App Store Connect dashboard. -## Excluding packages - -`excludedPackages` accepts both **npm package names** and **CocoaPods pod names**. Two entries are often needed for the same package because each serves a different purpose: - -- **npm name** (e.g. `"expo-notifications"`) — passed to `use_expo_modules!` to remove the package from Expo's autolinking for the App Clip target -- **pod name** (e.g. `"EXNotifications"`) — used in a `post_install` hook to strip the package's linker flags from the generated `.xcconfig` files - -For most Expo packages the pod name differs from the npm name, so both must be listed. For React Native community packages (e.g. `react-native-nfc-manager`) the pod name typically matches the npm name, so one entry is sufficient. +Excluded packages are removed from Expo's autolinking for the App Clip target via `use_expo_modules!`. Use node module package names: ```json "excludedPackages": [ "expo-notifications", - "EXNotifications", - "expo-web-browser", - "ExpoWebBrowser", "react-native-nfc-manager" ] ``` -> **Note:** Under New Architecture, removing a package from the autolinking config prevents Codegen from generating its spec headers — but the pod's own source files still import those headers, causing compile errors. For this reason, packages are kept in the autolinking config and excluded only at the linker stage via xcconfig flag stripping. - ## Native capabilities ```typescript diff --git a/plugin/src/withPodfile.ts b/plugin/src/withPodfile.ts index 3e23165..03e0218 100644 --- a/plugin/src/withPodfile.ts +++ b/plugin/src/withPodfile.ts @@ -89,75 +89,6 @@ end`; comment: "#", }).contents; - // Inject Post-Install hook for explicit exclusion (Target Deps + Linker Flags) - const postInstallPatch = ` - # [react-native-app-clip] Post-install cleanup for App Clip exclusions - excluded_from_clip = ${JSON.stringify(excludedPackages || [])} - - installer.pods_project.targets.each do |target| - if target.name == 'Pods-${targetName}' - # 1. Remove explicit dependencies (Target Dependencies) - target.dependencies.delete_if do |dep| - excluded_from_clip.any? { |pkg| dep.name.include?(pkg) } - end - - # 2. Remove Linked Libraries from xcconfig (OTHER_LDFLAGS) - target.build_configurations.each do |config| - if config.base_configuration_reference - xcconfig_path = config.base_configuration_reference.real_path - if File.exist?(xcconfig_path) - xcconfig = Xcodeproj::Config.new(xcconfig_path) - ld_flags = xcconfig.attributes['OTHER_LDFLAGS'] - if ld_flags - flags_modified = false - excluded_from_clip.each do |pkg| - # Flag patterns to remove: -l"Pkg", -lPkg, -framework "Pkg", -framework Pkg - patterns = [/-l"#{pkg}"/, /-l#{pkg}\\b/, /-framework "#{pkg}"/, /-framework #{pkg}\\b/] - patterns.each do |pattern| - if ld_flags.match(pattern) - ld_flags.gsub!(pattern, '') - flags_modified = true - end - end - end - if flags_modified - ld_flags.gsub!(/\\s+/, ' ') # Clean up spacing - xcconfig.attributes['OTHER_LDFLAGS'] = ld_flags - xcconfig.save_as(xcconfig_path) - end - end - end - end - end - end - end - `; - - // Check for existence of post_install block and either append or inject - if (podfileContent.includes("post_install do |installer|")) { - // Idempotent injection: check if our patch is already there - if (!podfileContent.includes("# [react-native-app-clip] Post-install cleanup")) { - podfileContent = podfileContent.replace( - /post_install\s+do\s+\|installer\|/, - `post_install do |installer|\n${postInstallPatch}` - ); - } else { - // Update the excluded packages list dynamically - const newExclusions = JSON.stringify(excludedPackages || []); - podfileContent = podfileContent.replace( - /excluded_from_clip\s*=\s*\[.*?\]/g, - `excluded_from_clip = ${newExclusions}` - ); - } - } else { - // Create new post_install block if missing - podfileContent += ` - post_install do |installer| - ${postInstallPatch} - end - `; - } - fs.writeFileSync(podFilePath, podfileContent); return config; From 11ccf636f93f439b87767c5df98be517cfdd7f7d Mon Sep 17 00:00:00 2001 From: Trent Rand Date: Fri, 27 Feb 2026 09:55:01 -0700 Subject: [PATCH 4/4] Append target to end of Podfile without anchor Rather than relying on an anchor that may change with later versions of Expo, we now just append the App Clip target to the end of the Podfile. This also removes dependency on Expo 53+. --- README.md | 3 --- plugin/src/withPodfile.ts | 31 ++++++++++--------------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 11d40b2..1224871 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # react-native-app-clip -> **Warning** -> Starting with version 0.6.0, react-native-app-clip requires **Expo SDK 53** and **React Native 0.79**. Downgrade to 0.5.1 if you wish to use **Expo SDK 52** and **React Native 0.76**. - Expo Config Plugin that generates an App Clip for iOS apps built with Expo. ## Installation diff --git a/plugin/src/withPodfile.ts b/plugin/src/withPodfile.ts index 03e0218..1da12a9 100644 --- a/plugin/src/withPodfile.ts +++ b/plugin/src/withPodfile.ts @@ -1,4 +1,3 @@ -import { mergeContents } from "@expo/config-plugins/build/utils/generateCode"; import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins"; import fs from "node:fs"; import path from "node:path"; @@ -22,6 +21,7 @@ export const withPodfile: ConfigPlugin<{ : "use_expo_modules!"; const appClipTarget = ` +# @generated begin react-native-app-clip target '${targetName}' do ${useExpoModules} @@ -67,27 +67,16 @@ target '${targetName}' do :app_path => "#{Pod::Config.instance.installation_root}/..", :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', ) -end`; +end +# @generated end react-native-app-clip`; - // Inject content at the top-level (sibling to the main target) - const anchor = "prepare_react_native_project!"; - const offset = 1; - - if (!podfileContent.includes(anchor)) { - throw new Error( - "[react-native-app-clip] Could not find 'prepare_react_native_project!' in Podfile. " + - "This plugin requires Expo SDK 53+ project structure." - ); - } - - podfileContent = mergeContents({ - tag: "Generated by react-native-app-clip", - src: podfileContent, - newSrc: appClipTarget, - anchor: anchor, - offset: offset, - comment: "#", - }).contents; + // Strip any existing block then re-append at end of file (idempotent) + const blockRegex = new RegExp( + `\\n*# @generated begin react-native-app-clip[\\s\\S]*?# @generated end react-native-app-clip`, + "g", + ); + podfileContent = podfileContent.replace(blockRegex, "").trimEnd(); + podfileContent += `\n${appClipTarget}\n`; fs.writeFileSync(podFilePath, podfileContent);