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
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -58,9 +55,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[]): 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

Expand All @@ -76,7 +73,16 @@ 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.

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",
"react-native-nfc-manager"
]
```

## Native capabilities

Expand Down
103 changes: 50 additions & 53 deletions plugin/src/withPodfile.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,8 +6,6 @@ export const withPodfile: ConfigPlugin<{
targetName: string;
excludedPackages?: string[];
}> = (config, { targetName, excludedPackages }) => {
// return config;

return withDangerousMod(config, [
"ios",
(config) => {
Expand All @@ -20,66 +17,66 @@ 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
# @generated begin react-native-app-clip
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 + ', [])'
]
Comment on lines +50 to +56
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

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

clip_command now calls cliPlugin.run(..., []), which prevents cliPlugin.ts from deleting excluded packages from the autolinking config (it deletes keys based on the exclude array). This looks like a regression: excludedPackages will no longer be excluded from use_native_modules! resolution for the App Clip target. Pass the actual excluded package list through to the CLI plugin again (and ensure it stays in sync with excludedPackages).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

@trentrand trentrand Feb 17, 2026

Choose a reason for hiding this comment

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

Passing the exclude list to cliPlugin is what originally caused #79 — when excluded from use_native_modules!, Codegen does not generate spec headers for those packages, but the pod source files still #import them (e.g. NativeNfcManagerSpec.h), causing a compile failure under New Architecture. The [] is intentional: we keep packages in the autolinking config so Codegen generates their specs, then strip them from the linker in post_install. This is the two-pronged approach described in the PR.


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']

podfileContent = mergeContents({
tag: "Generated by react-native-app-clip",
src: podfileContent,
newSrc: appClipTarget,
anchor: "use_expo_modules!",
offset: 0,
comment: "#",
}).contents;
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
# @generated end react-native-app-clip`;

// 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);

Expand Down