From d67c9f4877a46e91330eb0622534ecff75dafebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Tue, 8 Sep 2026 23:10:02 +0200 Subject: [PATCH 1/2] fix(ios): narrow the podspec's public headers `use_frameworks!` builds the pod as a framework, and Xcode flat-copies every public header into one `Headers/` directory. With no `public_header_files` every header in `source_files` is public, and 21 basenames occur more than once, so the build fails at planning with `Multiple commands produce .../Headers/Types.h`. Restrict the public set to the Objective-C entry points; the C++ headers are already reached through HEADER_SEARCH_PATHS. Fixes discussion #203. Co-authored-by: Bartosz Hanc --- .cspell-wordlist.txt | 1 + .../__tests__/README.md | 2 +- .../api/podspecPublicHeaders.test.ts | 132 ++++++++++++++++++ .../react-native-executorch.podspec | 28 ++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-executorch/__tests__/api/podspecPublicHeaders.test.ts diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 390a598e0b..e2939b9faa 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -384,3 +384,4 @@ libtokenizers pretokenizer repoint repoints +basenames diff --git a/packages/react-native-executorch/__tests__/README.md b/packages/react-native-executorch/__tests__/README.md index 259bd31a95..1eda99b2eb 100644 --- a/packages/react-native-executorch/__tests__/README.md +++ b/packages/react-native-executorch/__tests__/README.md @@ -79,7 +79,7 @@ because a factory that throws part-way now releases what it had allocated | `tasks/` | One suite per task pipeline, plus the shared construction-failure behavior. `remainingTasks.ts` holds the pipelines that only get schema acceptance and disposal | | `hooks/` | `useModel`, `useResourceDownload`, and the task hooks end to end | | `extensions/` | The pure-TypeScript helpers: box/point scaling, seeded generators | -| `api/` | Export snapshot, model registry rules, label constants, source-level conventions | +| `api/` | Export snapshot, model registry rules, label constants, source-level conventions, the podspec's public headers | | `support/` | The fake runtime, the mocks, and the fixtures | ## What is deliberately not covered diff --git a/packages/react-native-executorch/__tests__/api/podspecPublicHeaders.test.ts b/packages/react-native-executorch/__tests__/api/podspecPublicHeaders.test.ts new file mode 100644 index 0000000000..a706cf3239 --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/podspecPublicHeaders.test.ts @@ -0,0 +1,132 @@ +/** + * The public headers the iOS podspec exposes. + * + * An app that sets `use_frameworks!` - directly, as Firebase requires, or + * through expo-build-properties' `useFrameworks: "static"` - makes CocoaPods + * build this pod as a framework instead of a static library. Xcode's Headers + * build phase then copies every *public* header into one flat + * `react_native_executorch.framework/Headers/`, so two public headers sharing + * a basename become two build commands writing the same file and the build + * dies while it is still being planned: + * + * error: Multiple commands produce '.../Headers/Types.h' + * + * The source tree has 21 such basenames - `Types.h` sits in eleven task + * directories, `constants.h` in thirteen phonemis ones - so leaving every + * header public is not survivable. The podspec narrows the public set to the + * Objective-C entry points and reaches the C++ headers through + * HEADER_SEARCH_PATHS instead. See discussion #203. + * + * Nothing else notices when that narrowing is dropped or widened: the default + * setup builds the pod as a static library, where public headers are not + * copied into a framework at all, so every example app stays green while the + * release is broken for `use_frameworks!` users. The invariant is checked here + * instead, by reading the podspec. + */ +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const PACKAGE_ROOT = join(__dirname, '..', '..'); +const PODSPEC = join(PACKAGE_ROOT, 'react-native-executorch.podspec'); + +/** + * The globs assigned to `public_header_files`, in declaration order. + * + * The podspec builds the list over several statements (a base list plus the + * legacy entry point), so every `public_header_files = [...]` and + * `public_header_files += [...]` contributes. The trailing + * `s.public_header_files = public_header_files` carries no array literal and + * is skipped by the same pattern. + */ +function declaredPublicHeaderGlobs(podspec: string): string[] { + const assignments = podspec.matchAll(/public_header_files\s*\+?=\s*\[([^\]]*)\]/g); + return [...assignments].flatMap((assignment) => + [...assignment[1]!.matchAll(/"([^"]+)"/g)].map((quoted) => quoted[1]!) + ); +} + +/** + * A CocoaPods file glob as a `RegExp` over paths relative to the package root. + * + * Covers the constructs the podspec uses - `**` for any depth, `*` within one + * segment, `{h,mm}` alternatives - and throws on anything else rather than + * quietly matching nothing, which would turn a real collision into a passing + * test. + */ +function globToRegExp(glob: string): RegExp { + if (/[?[\]!]/.test(glob)) { + throw new Error(`unsupported glob syntax in podspec pattern: ${glob}`); + } + const segments = glob.split('/'); + const pattern = segments.flatMap((segment, index) => { + // `**` stands for any number of directories, none included, so it brings + // its own trailing separator: `a/**/b.h` has to match `a/b.h` as well. + if (segment === '**') return ['(?:[^/]+/)*']; + const escaped = segment + .replace(/[.+^$()|\\]/g, '\\$&') + .replace( + /\{([^{}]*)\}/g, + (_, alternatives: string) => `(?:${alternatives.split(',').join('|')})` + ) + .replace(/\*/g, '[^/]*'); + return index < segments.length - 1 ? [escaped, '/'] : [escaped]; + }); + return new RegExp(`^${pattern.join('')}$`); +} + +/** Every file under `directory`, as a path relative to the package root. */ +function filesUnder(directory: string, prefix: string): string[] { + return readdirSync(directory).flatMap((entry) => { + const full = join(directory, entry); + const relative = `${prefix}/${entry}`; + return statSync(full).isDirectory() ? filesUnder(full, relative) : [relative]; + }); +} + +/** + * The files a glob matches. Only the top-level directory the glob names is + * walked, so the phonemis submodule is never read: it holds no public header, + * and CI installs without checking it out. + */ +function matches(glob: string): string[] { + const root = glob.split('/')[0]!; + const rootPath = join(PACKAGE_ROOT, root); + if (!existsSync(rootPath)) return []; + const regexp = globToRegExp(glob); + const candidates = statSync(rootPath).isDirectory() ? filesUnder(rootPath, root) : [root]; + return candidates.filter((file) => regexp.test(file)); +} + +const globs = declaredPublicHeaderGlobs(readFileSync(PODSPEC, 'utf8')); +const publicHeaders = new Map(globs.map((glob) => [glob, matches(glob)])); + +describe('podspec public headers', () => { + it('narrows the public set with public_header_files', () => { + // Without the attribute CocoaPods promotes every header in `source_files`, + // which is the state that broke `use_frameworks!` builds in 0.10.0. + expect(globs).not.toHaveLength(0); + }); + + it('declares no glob that matches nothing', () => { + // A moved or renamed entry point would otherwise leave the framework with + // no importable header, and no failing build until an app tried to use it. + const empty = [...publicHeaders] + .filter(([, files]) => files.length === 0) + .map(([glob]) => glob); + expect(empty).toEqual([]); + }); + + it('gives every public header a unique basename', () => { + const byBasename = new Map(); + for (const file of [...publicHeaders.values()].flat()) { + const basename = file.split('/').pop()!; + byBasename.set(basename, [...(byBasename.get(basename) ?? []), file]); + } + + // Each entry here is one `Multiple commands produce` error in an app built + // with `use_frameworks!`. Rename the header, or drop it from + // `public_header_files` when nothing outside the pod imports it. + const collisions = Object.fromEntries([...byBasename].filter(([, files]) => files.length > 1)); + expect(collisions).toEqual({}); + }); +}); diff --git a/packages/react-native-executorch/react-native-executorch.podspec b/packages/react-native-executorch/react-native-executorch.podspec index acdc8015b0..9e822321bd 100644 --- a/packages/react-native-executorch/react-native-executorch.podspec +++ b/packages/react-native-executorch/react-native-executorch.podspec @@ -97,6 +97,34 @@ Pod::Spec.new do |s| exclude_files += phonemis_source_files unless enable_phonemis s.exclude_files = exclude_files + # --- Public headers --- + # `use_frameworks!` - set directly for Firebase, or through + # expo-build-properties' `useFrameworks: "static"` - makes CocoaPods build + # this pod as a framework, and Xcode's Headers build phase copies every + # *public* header into one flat `Headers/` directory. With no + # `public_header_files` every header in `source_files` is public, and the + # tree has 21 basenames that occur more than once (`Types.h` in eleven task + # directories, `constants.h` in thirteen phonemis ones), so the build fails + # at planning with `Multiple commands produce .../Headers/Types.h` before a + # single file compiles. See discussion #203. + # + # Only the Objective-C entry points have to be visible to the app. The C++ + # headers are reached through the HEADER_SEARCH_PATHS below, so narrowing the + # public set costs nothing, and `__tests__/api/podspecPublicHeaders.test.ts` + # keeps it collision-free. + public_header_files = [ + "ios/**/*.h", + ] + # ============================================================================== + # LEGACY SUPPORT: include the legacy entry point + # (Remove when react-native-executorch/legacy is dropped) + # ============================================================================== + public_header_files += [ + "legacy/ios/**/*.h", + ] + # ============================================================================== + s.public_header_files = public_header_files + # --- Preprocessor flags --- extra_compiler_flags = [] extra_compiler_flags << "-DRNE_ENABLE_OPENCV" if enable_opencv From 6c013d291491c3737fef409ceafdf8e9d9c37338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 9 Sep 2026 12:40:51 +0200 Subject: [PATCH 2/2] fix(ios): declare the pod a static framework `use_frameworks!` with no linkage argument means dynamic, which is what Firebase's setup instructions show. CocoaPods then refuses to install, because a dynamic framework may not carry statically linked binaries and opencv-rne vendors one: [!] The 'Pods-YourApp' target has transitive dependencies that include statically linked binaries: (.../opencv-rne/opencv2.xcframework) `s.static_framework = true` resolves it without the app having to spell out `:linkage => :static`, and is inert when the pod builds as a static library. Verified on an Expo 57 app with the pod kept as a framework: dynamic linkage goes from a failed `pod install` to `** BUILD SUCCEEDED **` with this and the public-header fix together, and the default configuration still builds clean. --- .../react-native-executorch.podspec | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/react-native-executorch/react-native-executorch.podspec b/packages/react-native-executorch/react-native-executorch.podspec index 9e822321bd..cb9410c682 100644 --- a/packages/react-native-executorch/react-native-executorch.podspec +++ b/packages/react-native-executorch/react-native-executorch.podspec @@ -217,6 +217,19 @@ Pod::Spec.new do |s| # resource path. `s.ios.resource` achieves that via CocoaPods' resource copy. s.ios.resource = "third-party/ios/libs/executorch/mlx.metallib" if enable_mlx + # An app that turns on `use_frameworks!` without naming a linkage gets the + # default, dynamic - which is what Firebase's own setup instructions show. + # CocoaPods then refuses to install at all, because a dynamic framework may + # not carry statically linked binaries and opencv-rne vendors one: + # + # [!] The 'Pods-YourApp' target has transitive dependencies that include + # statically linked binaries: (.../opencv-rne/opencv2.xcframework) + # + # Declaring the pod a static framework resolves that without the app having + # to spell out `:linkage => :static`, and is inert when the pod is built as a + # static library, which is what happens with no `use_frameworks!` at all. + s.static_framework = true + # Backend xcframeworks are linked via force_load in OTHER_LDFLAGS (needed to # preserve __attribute__((constructor)) backend registrations). Only # ExecutorchLib goes in vendored_frameworks to avoid duplicate symbol errors.