diff --git a/packages/react-native/React-Core-prebuilt.podspec b/packages/react-native/React-Core-prebuilt.podspec index 98aa6a09b1df..0d7c08c3aee7 100644 --- a/packages/react-native/React-Core-prebuilt.podspec +++ b/packages/react-native/React-Core-prebuilt.podspec @@ -17,36 +17,51 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.platforms = min_supported_versions s.source = source + + # We vend two xcframeworks that ship together in the prebuilt tarball: + # - React.xcframework: the compiled core. Its per-slice React.framework carries + # every header + the framework module map, so `#import ` + # and `@import React;` resolve through FRAMEWORK_SEARCH_PATHS automatically. + # - ReactNativeHeaders.xcframework: headers-only. Carries every other namespace + # (, , folly, glog, ...). Its headers are flattened into a + # top-level Headers/ (see prepare_command) and exposed via the standard pod + # header search path. ( is supplied by the hermes-engine pod here; + # it is folded into ReactNativeHeaders only on the SwiftPM consumer side.) + # There is no clang VFS overlay. s.vendored_frameworks = "React.xcframework" s.preserve_paths = '**/*.*' - s.header_mappings_dir = 'React.xcframework/Headers' - s.source_files = 'React.xcframework/Headers/**/*.{h,hpp}' - - s.module_name = 'React' - s.module_map = 'React.xcframework/Modules/module.modulemap' - s.public_header_files = 'React.xcframework/Headers/**/*.h' + s.header_mappings_dir = 'Headers' + s.source_files = 'Headers/**/*.{h,hpp}' + s.public_header_files = 'Headers/**/*.h' add_rn_third_party_dependencies(s) - # We need to make sure that the React.xcframework is copied correctly - in the downloaded tarball - # the root directory is the framework, but when using it we need to have it in a subdirectory - # called React.xcframework, so we need to move the contents of the tarball into that directory. - # This is done in the prepare_command. - # We need to make sure that the headers are copied to the right place - local tar.gz has a different structure - # than the one from the maven repo + # The downloaded tarball ships React.xcframework and ReactNativeHeaders.xcframework + # at its root. We make sure React.xcframework is in its own subdirectory (the Maven + # tarball lays the framework contents at the root; the local tar.gz has a different + # structure) and flatten ReactNativeHeaders' headers into a top-level Headers/ dir + # so CocoaPods exposes them on the header search path. s.prepare_command = <<~'CMD' CURRENT_PATH=$(pwd) XCFRAMEWORK_PATH="${CURRENT_PATH}/React.xcframework" - # Check if XCFRAMEWORK_PATH is empty - if [ -z "$XCFRAMEWORK_PATH" ]; then - echo "ERROR: XCFRAMEWORK_PATH is empty." - exit 0 + # Flatten ReactNativeHeaders' headers (identical across slices) into Headers/ + # BEFORE we sweep stray root entries into React.xcframework. + mkdir -p Headers + RNH_XCFRAMEWORK_PATH=$(find "$CURRENT_PATH" -type d -name "ReactNativeHeaders.xcframework" | head -n 1) + if [ -n "$RNH_XCFRAMEWORK_PATH" ]; then + RNH_HEADERS_PATH=$(find "$RNH_XCFRAMEWORK_PATH" -type d -name "Headers" | head -n 1) + if [ -n "$RNH_HEADERS_PATH" ]; then + cp -R "$RNH_HEADERS_PATH/." Headers + fi + rm -rf "$RNH_XCFRAMEWORK_PATH" fi mkdir -p "${XCFRAMEWORK_PATH}" - find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 ! -name "$(basename "$XCFRAMEWORK_PATH")" -exec mv {} "$XCFRAMEWORK_PATH" \; + find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 \ + ! -name "$(basename "$XCFRAMEWORK_PATH")" ! -name "Headers" \ + -exec mv {} "$XCFRAMEWORK_PATH" \; CMD # If we are passing a local tarball, we don't want to switch between Debug and Release diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 252588c98432..141965707a51 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -13,26 +13,21 @@ ### building ReactNativeCore from source (then this function does nothing). def add_rncore_dependency(s) if !ReactNativeCoreUtils.build_rncore_from_source() - # Add the dependency + # `` resolves through the vendored React.framework; every other + # namespace (``, ``, ``, ...) resolves + # through the flattened ReactNativeHeaders headers that React-Core-prebuilt + # exposes on its header search path. No clang VFS overlay. s.dependency "React-Core-prebuilt" current_pod_target_xcconfig = s.to_hash["pod_target_xcconfig"] || {} current_pod_target_xcconfig = current_pod_target_xcconfig.to_h unless current_pod_target_xcconfig.is_a?(Hash) - # Add VFS overlay flags for both Objective-C and Swift - # The VFS overlay file is pre-resolved at pod install time for each platform slice. - # We reference it directly in the xcframework using the React-VFS.yaml file that - # is written to the React-Core-prebuilt folder during setup_vfs_overlay. - # See scripts/ios-prebuild/__docs__/README.md for more details on VFS overlays. - vfs_overlay_flag = "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - current_pod_target_xcconfig["OTHER_CFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CFLAGS"] += " #{vfs_overlay_flag}" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] += " #{vfs_overlay_flag}" - # For Swift, we need to use -Xcc to pass flags to the underlying Clang compiler - # Both the flag and its argument need separate -Xcc prefixes - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] += " -Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" + # HEADER_SEARCH_PATHS may already be a String or an Array (e.g. after + # add_rn_third_party_dependencies); normalize to an Array before appending. + header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] + header_search_paths = header_search_paths.split(" ") if header_search_paths.is_a?(String) + header_search_paths << "\"$(PODS_ROOT)/React-Core-prebuilt/Headers\"" + current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths s.pod_target_xcconfig = current_pod_target_xcconfig end @@ -521,71 +516,33 @@ def self.get_nightly_npm_version() return latest_nightly end - # Processes the VFS overlay file from the React.xcframework to resolve the ${ROOT_PATH} placeholder. - # This method should be called from react_native_post_install after pod install completes. + # Configures the xcconfig files for aggregate (main app) targets and third-party pod + # targets so the prebuilt ReactNativeHeaders are resolvable. These targets do not go + # through add_rncore_dependency, so they won't otherwise get the header search path. # - # The VFS overlay file maps header import paths to their actual locations within the xcframework. - # Since the xcframework contains platform-specific slices, we generate a resolved VFS file for each - # slice and also create a default VFS file that can be used immediately (before script phases run). - def self.process_vfs_overlay() - return if @@build_from_source - - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - xcframework_path = File.join(prebuilt_path, "React.xcframework") - vfs_template_path = File.join(xcframework_path, "React-VFS-template.yaml") - - unless File.exist?(vfs_template_path) - rncore_log("VFS overlay template not found at #{vfs_template_path}", :error) - exit 1 - end - - rncore_log("Processing VFS overlay file...") - - # Read the template content - vfs_template_content = File.read(vfs_template_path) - - # Write the VFS file - use the top-level xcframework path - # so that ${ROOT_PATH}/Headers points to the xcframework's Headers folder - resolved_vfs_content = vfs_template_content.gsub('${ROOT_PATH}', xcframework_path) - resolved_vfs_path = File.join(prebuilt_path, "React-VFS.yaml") - File.write(resolved_vfs_path, resolved_vfs_content) - rncore_log(" Created VFS overlay at #{resolved_vfs_path}") - - rncore_log("VFS overlay setup complete") - end - - # Configures the xcconfig files for aggregate (main app) targets to enable VFS overlay for React Native Core. - # This is needed because the main app target does not go through podspec processing, - # so it won't get the VFS overlay flags from add_rncore_dependency. + # `` resolves through the vendored React.framework; this adds the search + # path to the flattened ReactNativeHeaders headers (every other namespace). There is + # no clang VFS overlay. # # Parameters: # - installer: The CocoaPods installer object def self.configure_aggregate_xcconfig(installer) return if @@build_from_source - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - vfs_overlay_path = File.join(prebuilt_path, "React-VFS.yaml") - - unless File.exist?(vfs_overlay_path) - rncore_log("VFS overlay not found at #{vfs_overlay_path}, skipping prebuilt xcconfig configuration", :error) - exit 1 - end - rncore_log("Configuring xcconfig for prebuilt React Native Core...") - vfs_overlay_flag = " -ivfsoverlay \"#{vfs_overlay_path}\"" - swift_vfs_overlay_flag = " -Xcc -ivfsoverlay -Xcc \"#{vfs_overlay_path}\"" + headers_search_path = " \"$(PODS_ROOT)/React-Core-prebuilt/Headers\"" - # Add flags to aggregate target xcconfigs (these are used by the main app target) + # Add the header search path to aggregate target xcconfigs (used by the main app target) installer.aggregate_targets.each do |aggregate_target| aggregate_target.xcconfigs.each do |config_name, config_file| - add_vfs_overlay_flags(config_file.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(config_file.attributes, headers_search_path) xcconfig_path = aggregate_target.xcconfig_path(config_name) config_file.save_as(xcconfig_path) end end - # Add flags to ALL pod targets (for third-party pods that don't call add_rncore_dependency) + # Add the header search path to ALL pod targets (for third-party pods that don't call add_rncore_dependency) installer.pod_targets.each do |pod_target| pod_target.build_settings.each do |config_name, build_settings| xcconfig_path = pod_target.xcconfig_path(config_name) @@ -593,11 +550,11 @@ def self.configure_aggregate_xcconfig(installer) xcconfig = Xcodeproj::Config.new(xcconfig_path) - # Check if VFS overlay is already present - other_cflags = xcconfig.attributes["OTHER_CFLAGS"] || "" - next if other_cflags.include?("ivfsoverlay") + # Skip if the prebuilt header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("React-Core-prebuilt/Headers") - add_vfs_overlay_flags(xcconfig.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(xcconfig.attributes, headers_search_path) xcconfig.save_as(xcconfig_path) end end @@ -605,11 +562,9 @@ def self.configure_aggregate_xcconfig(installer) rncore_log("Prebuilt xcconfig configuration complete") end - # Helper method to add VFS overlay flags to an xcconfig attributes map - def self.add_vfs_overlay_flags(attributes, vfs_overlay_flag, swift_vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", swift_vfs_overlay_flag) + # Helper method to add the prebuilt ReactNativeHeaders header search path to an xcconfig attributes map + def self.add_prebuilt_header_search_paths(attributes, headers_search_path) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "HEADER_SEARCH_PATHS", headers_search_path) # Suppress incomplete umbrella warnings for the prebuilt frameworks (it is expected, as our umbrella headers do not include all headers) ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc -Wno-incomplete-umbrella") end diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 4d2786314714..3e3779094ec6 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -111,123 +111,46 @@ The build process uses specific `xcodebuild` flags: - Build times vary depending on the target platform and configuration - XCFrameworks support multiple architectures in a single bundle -## Known Issues - -The generated XCFrameworks currently use CocoaPods-style header structures -rather than standard framework header conventions. This may cause modularity -issues when: - -- Consuming the XCFrameworks in projects that expect standard framework headers -- Building dependent frameworks that rely on proper module boundaries -- Integrating with Swift Package Manager projects expecting modular headers - -## VFS Overlay System - -The prebuilt XCFrameworks use Clang's Virtual File System (VFS) overlay -mechanism to enable header imports without modifying the actual header file -structure. This is necessary because React Native's headers are organized -differently than standard framework conventions. - -### Overview - -The VFS overlay creates a virtual mapping between the import paths used in code -(e.g., `#import `) and the actual physical -locations of headers within the XCFramework. This allows the prebuilt frameworks -to work seamlessly while maintaining the original import syntax. - -### Build-Time VFS Generation (`vfs.js`) - -The `vfs.js` script creates a VFS overlay template during the prebuild process: - -1. **Header Collection** (`headers.js`): Scans all podspec files in the React - Native package to discover header files and their target import paths. - -2. **VFS Structure Building**: The `buildVFSStructure()` function creates a - hierarchical directory tree representation from the header mappings. Clang's - VFS overlay requires directories to contain their children in a tree - structure. - -3. **YAML Generation**: The `generateVFSOverlayYAML()` function converts the VFS - structure into Clang's expected YAML format. - -4. **Template Creation**: The generated overlay uses `${ROOT_PATH}` as a - placeholder for the actual installation path. This template is included in - the XCFramework as `React-VFS-template.yaml`. - -#### Key Functions - -- `createVFSOverlay(rootFolder)`: Main entry point that generates the complete - VFS overlay YAML string -- `createVFSOverlayContents(rootFolder)`: Creates the VFS overlay object - structure -- `buildVFSStructure(mappings)`: Builds the hierarchical directory tree from - flat mappings -- `resolveVFSOverlay(vfsTemplate, rootPath)`: Replaces `${ROOT_PATH}` with the - actual path - -### Runtime VFS Processing (CocoaPods) - -When consuming prebuilt frameworks via CocoaPods, the VFS overlay is processed -at pod install time by `rncore.rb`: - -#### `process_vfs_overlay()` - -Called during `react_native_post_install`, this method: - -1. Reads the `React-VFS-template.yaml` from the XCFramework -2. Resolves the `${ROOT_PATH}` placeholder with the actual XCFramework path -3. Writes the resolved overlay to - `$(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml` - -#### `add_rncore_dependency(s)` - -Adds VFS overlay compiler flags to podspecs that depend on React Native: - -```ruby -# For C/C++ compilation -OTHER_CFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -OTHER_CPLUSPLUSFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - -# For Swift compilation (flags passed to underlying Clang) -OTHER_SWIFT_FLAGS += "-Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -``` - -#### `configure_aggregate_xcconfig(installer)` - -Configures VFS overlay flags for: - -- **Aggregate targets**: Main app targets that don't go through podspec - processing -- **All pod targets**: Third-party pods that don't explicitly call - `add_rncore_dependency` - -This ensures all compilation units in the project can resolve React Native -headers through the VFS overlay. - -### VFS Overlay Format - -The VFS overlay uses Clang's hierarchical YAML format: - -```yaml -version: 0 -case-sensitive: false -roots: - - name: '${ROOT_PATH}/Headers' - type: 'directory' - contents: - - name: 'react' - type: 'directory' - contents: - - name: 'renderer' - type: 'directory' - contents: - - name: 'Size.h' - type: 'file' - external-contents: '${ROOT_PATH}/Headers/React/react/renderer/Size.h' -``` - -The structure maps virtual paths (what the compiler sees) to physical paths -(where the files actually exist in the XCFramework). +## Header Resolution (headers-spec layout) + +The prebuilt XCFrameworks ship a **headers-spec layout** so that header imports +resolve through plain header/framework search paths — there is **no clang VFS +overlay**. The layout contract is defined and validated in code: + +- `headers-spec.js`: the executable layout contract (rules R1–R8) — which + namespaces are hoisted, which carry module maps, and how collisions are + rejected. +- `headers-inventory.js`: scans the source tree to build the live header + inventory that feeds the spec. +- `headers-compose.js`: emits the layout. `emitReactFrameworkHeaders()` writes the + `React/` and bare-aliased headers into every slice's `React.framework/Headers`, + and `buildReactNativeHeadersXcframework()` assembles the headers-only + `ReactNativeHeaders.xcframework` carrying every other namespace (incl. `react/`) + plus the third-party dependency namespaces (`folly`, `glog`, `boost`, `fmt`, + `double-conversion`, `fast_float`). The Hermes public headers (``) + are folded in only on the SwiftPM consumer side (`ensureHeadersLayout`); the + published prebuild artifact does not yet carry them (TODO in `xcframework.js`). + +### Artifacts + +The prebuild (`xcframework.js`) always produces: + +- `React.xcframework` — the compiled React core. Each slice's `React.framework` + carries the headers-spec layout (every `` header + the framework + module map), which is what both CocoaPods and SwiftPM consume. +- `ReactNativeHeaders.xcframework` — headers-only; carries every other namespace. + Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the + `React-Core-prebuilt` pod (headers flattened onto the header search path). + +### CocoaPods consumption + +The `React-Core-prebuilt` pod vends `React.xcframework` (so `` and +`@import React;` resolve through the framework module via `FRAMEWORK_SEARCH_PATHS`) +and flattens `ReactNativeHeaders.xcframework`'s headers into a top-level `Headers/` +exposed on the pod header search path (so ``, ``, `` +resolve). `rncore.rb` adds the `HEADER_SEARCH_PATHS` entry to +`React-Core-prebuilt/Headers` for podspec, aggregate (main app), and third-party +pod targets. No `-ivfsoverlay` flags are added. ## Integrating in your project with Cocoapods diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js new file mode 100644 index 000000000000..c409fecf0b9f --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -0,0 +1,328 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers compose — emits the headers-spec layout (rules R1–R8 in + * headers-spec.js) into a React.xcframework and builds the headers-only + * ReactNativeHeaders.xcframework beside it. The prebuild path (xcframework.js) + * composes before signing (R7); `ensureHeadersLayout()` applies the same + * emission to an already-cached artifact. One projector, spec-driven, + * byte-identical output either way. + */ + +const {computeInventory} = require('./headers-inventory'); +const { + DEPS_NAMESPACES, + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */ + +/** + * Computes the spec plan from the live source tree. Throws on collisions + * (R8) — a collision means the spec and the source tree disagree and the + * artifact must not be produced. + */ +function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ { + const plan = planFromInventory(computeInventory(rnRoot)); + if (plan.collisions.length > 0) { + throw new Error( + `headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`, + ); + } + return plan; +} + +/** + * Copies spec entries (each `{relPath, source}`) into a staging dir, creating + * parent dirs. Shared by the React.framework and ReactNativeHeaders emission. + */ +function stageEntries( + stage /*: string */, + entries /*: Array */, + rnRoot /*: string */, +) /*: void */ { + for (const e of entries) { + const dest = path.join(stage, e.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.copyFileSync(path.join(rnRoot, e.source), dest); + } +} + +/** + * Emits the React.framework side of the spec (R1, R4, R6) into every slice + * of an xcframework: Headers root = React/ hoisted to root + bare aliases, + * generated umbrella + framework module map. Replaces each slice's Headers + * and Modules. The xcframework's ROOT Headers/ (the CocoaPods header surface) + * is left untouched. + */ +function emitReactFrameworkHeaders( + xcfwPath /*: string */, + plan /*: HeadersSpecPlan */, + rnRoot /*: string */, +) /*: void */ { + const stage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.react-stage-'), + ); + stageEntries(stage, plan.react, rnRoot); + fs.writeFileSync( + path.join(stage, 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + ); + + // A slice is any entry carrying a React.framework. The framework as built by + // xcodebuild -create-xcframework ships no Headers/ dir of its own — this + // emission creates it (and replaces Modules), so detect by the framework, not + // by a pre-existing Headers/. + const slices = fs + .readdirSync(xcfwPath) + .filter(d => + fs.existsSync(path.join(xcfwPath, d, 'React.framework')), + ); + for (const slice of slices) { + const fwk = path.join(xcfwPath, slice, 'React.framework'); + fs.rmSync(path.join(fwk, 'Headers'), {recursive: true, force: true}); + execSync(`/bin/cp -Rc "${stage}" "${path.join(fwk, 'Headers')}"`); + fs.rmSync(path.join(fwk, 'Modules'), {recursive: true, force: true}); + fs.mkdirSync(path.join(fwk, 'Modules'), {recursive: true}); + fs.writeFileSync( + path.join(fwk, 'Modules', 'module.modulemap'), + renderReactModuleMap(), + ); + } + fs.rmSync(stage, {recursive: true, force: true}); + console.log( + `headers-compose: React.framework spec layout -> ${slices.join(', ')} ` + + `(${plan.react.length} headers, umbrella ${plan.umbrella.length})`, + ); +} + +/*:: +type StubSlice = { + name: string, // human label + sdk: string, // xcrun --sdk name + targets: Array, // clang -target triples (lipo'd when > 1) +}; +*/ + +const DEFAULT_STUB_SLICES /*: Array */ = [ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, +]; + +// Mac Catalyst slice — used by the real compose (the cached-artifact +// repackage path skips it to stay fast; React.xcframework carries it). +const CATALYST_STUB_SLICE /*: StubSlice */ = { + name: 'mac-catalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], +}; + +/** + * Builds ReactNativeHeaders.xcframework (R2, R5): a headers-only LIBRARY + * xcframework (stub static archives — nothing embeds in apps) whose Headers + * root carries every non-React namespace incl. the third-party deps + * namespaces, plus module.modulemap with the plain per-namespace modules. + * SPM serves its Headers automatically to dependents — no flags. + */ +function buildReactNativeHeadersXcframework( + outDir /*: string */, + plan /*: HeadersSpecPlan */, + depsHeaders /*: string */, + rnRoot /*: string */, + includeCatalyst /*: boolean */ = false, + // Optional dir containing a `hermes/` namespace (Hermes public headers from + // the hermes-ios tarball's destroot/include). Folded in as a textual + // namespace like folly/glog so `` resolves without per-library + // wiring. null when unstaged — then `` stays unavailable. + hermesHeaders /*: ?string */ = null, +) /*: string */ { + // ---- stage headers ---- + const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); + stageEntries(stage, plan.reactNativeHeaders, rnRoot); + for (const ns of plan.depsNamespaces) { + const src = path.join(depsHeaders, ns); + if (fs.existsSync(src)) { + execSync(`/bin/cp -Rc "${src}" "${path.join(stage, ns)}"`); + } else { + console.warn(`headers-compose: deps namespace missing: ${ns}`); + } + } + // Hermes public headers (separate source from the deps namespaces — they + // come from the hermes-ios tarball, not ReactNativeDependencies). Vend only + // the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying + // it here would double-vend. + let hermesFolded = false; + if (hermesHeaders != null) { + const src = path.join(hermesHeaders, 'hermes'); + if (fs.existsSync(src)) { + execSync(`/bin/cp -Rc "${src}" "${path.join(stage, 'hermes')}"`); + hermesFolded = true; + } else { + console.warn(`headers-compose: hermes headers missing at ${src}`); + } + } + fs.writeFileSync( + path.join(stage, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + ); + + // ---- stub static archives per slice ---- + const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); + fs.writeFileSync( + path.join(work, 'stub.c'), + '// ReactNativeHeaders is headers-only; this stub satisfies xcframework tooling.\nstatic int RNHeadersStub __attribute__((unused)) = 0;\n', + ); + const slices = includeCatalyst + ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] + : DEFAULT_STUB_SLICES; + const libs = slices.map(slice => { + const sdkPath = execSync(`xcrun --sdk ${slice.sdk} --show-sdk-path`) + .toString() + .trim(); + const thins = slice.targets.map((t, i) => { + const obj = path.join(work, `stub-${slice.name}-${i}.o`); + execSync( + `xcrun clang -c -target ${t} -isysroot "${sdkPath}" "${path.join(work, 'stub.c')}" -o "${obj}"`, + ); + const lib = path.join(work, `stub-${slice.name}-${i}.a`); + execSync(`xcrun libtool -static -o "${lib}" "${obj}" 2>/dev/null`); + return lib; + }); + const outLib = path.join(work, `libReactNativeHeaders-${slice.name}.a`); + if (thins.length === 1) { + fs.copyFileSync(thins[0], outLib); + } else { + execSync( + `xcrun lipo -create ${thins.map(l => `"${l}"`).join(' ')} -output "${outLib}"`, + ); + } + return outLib; + }); + + // ---- compose ---- + const outXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + fs.rmSync(outXcfw, {recursive: true, force: true}); + execSync( + `xcodebuild -create-xcframework ` + + libs.map(l => `-library "${l}" -headers "${stage}"`).join(' ') + + ` -output "${outXcfw}"`, + {stdio: 'pipe'}, + ); + fs.rmSync(stage, {recursive: true, force: true}); + fs.rmSync(work, {recursive: true, force: true}); + console.log( + `headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}` + + `${hermesFolded ? ', hermes' : ''}; ` + + `${Object.keys(plan.namespaceModules).length} namespace modules)`, + ); + return outXcfw; +} + +/** + * Ensures the headers-spec layout exists at `outDir`, composed from the cache + * slot's artifacts: clones React.xcframework (APFS clonefile), strips the + * stale signature (R7 — production signs after compose), emits the spec + * layout into every slice, and builds ReactNativeHeaders.xcframework from + * the plan + the slot's deps headers. + * + * Skips when the freshness marker matches the source artifact (same + * realpath + Info.plist mtime) unless `force`. Any consumer with a cache slot + * gets composed artifacts automatically — no published ReactNativeHeaders + * required. + */ +function ensureHeadersLayout( + artifactsDir /*: string */, + rnRoot /*: string */, + outDir /*: string */, + force /*: boolean */ = false, +) /*: {reactXcfw: string, headersXcfw: string} */ { + const sourceXcfw = fs.realpathSync( + path.join(artifactsDir, 'React.xcframework'), + ); + const depsHeaders = path.join( + artifactsDir, + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + // Hermes public headers staged into the slot by download-spm-artifacts + // (the hermes-ios tarball ships them in destroot/include, which the + // xcframework extraction otherwise discards). null when absent — then + // ReactNativeHeaders composes without the hermes namespace. + const hermesHeadersDir = path.join(artifactsDir, 'hermes-headers'); + const hermesHeaders = fs.existsSync(path.join(hermesHeadersDir, 'hermes')) + ? hermesHeadersDir + : null; + const reactXcfw = path.join(outDir, 'React.xcframework'); + const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + const markerPath = path.join(outDir, '.composed-from'); + + const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist')); + // Fold the hermes-headers presence into the marker so a slot that gains + // staged hermes headers (e.g. after a tooling upgrade re-downloads them) + // recomposes instead of reusing a hermes-less ReactNativeHeaders. + const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\n`; + if ( + !force && + fs.existsSync(reactXcfw) && + fs.existsSync(headersXcfw) && + fs.existsSync(markerPath) && + fs.readFileSync(markerPath, 'utf8') === marker + ) { + return {reactXcfw, headersXcfw}; + } + + console.log( + `headers-compose: composing layout from ${path.basename(artifactsDir)} slot...`, + ); + fs.rmSync(reactXcfw, {recursive: true, force: true}); + fs.rmSync(markerPath, {force: true}); + fs.mkdirSync(outDir, {recursive: true}); + execSync(`/bin/cp -Rc "${sourceXcfw}" "${reactXcfw}"`); + fs.rmSync(path.join(reactXcfw, '_CodeSignature'), { + recursive: true, + force: true, + }); + + const plan = computeSpecPlan(rnRoot); + emitReactFrameworkHeaders(reactXcfw, plan, rnRoot); + buildReactNativeHeadersXcframework( + outDir, + plan, + depsHeaders, + rnRoot, + false, + hermesHeaders, + ); + fs.writeFileSync(markerPath, marker); + return {reactXcfw, headersXcfw}; +} + +module.exports = { + computeSpecPlan, + emitReactFrameworkHeaders, + buildReactNativeHeadersXcframework, + ensureHeadersLayout, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js new file mode 100644 index 000000000000..274156914089 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -0,0 +1,569 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Inventory and classify every header the React xcframework ships — the input + * to the headers spec (headers-spec.js). + * + * Enumerates headers through the SAME podspec-driven discovery the prebuild + * uses (headers.js), so the inventory cannot drift from the shipped set. For + * each header it records: + * + * - both identities: the pod-namespaced layout path (`Headers//`) + * and the natural path (the include path consumers write) + * - language surface: objc | objcxx | cxx | c (with `#ifdef __cplusplus` + * guard awareness, so ObjC headers that only reach C++ behind guards are + * not misclassified) + * - a modularizability bucket (can this header live in a Clang module?) + * + * `computeInventory()` returns the classified set in-memory for the prebuild + * compose step; the CLI (`node scripts/ios-prebuild/headers-inventory.js`) + * writes the same set as a JSON manifest. Read-only: never touches the trees + * it describes. + */ + +const {getHeaderFilesFromPodspecs} = require('./headers'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type Identity = { + pod: string, // pod folder name in Headers/ (specName with '-' -> '_') + spec: string, // (sub)spec name the header came from + namespacedPath: string, // path inside the xcframework Headers/ dir + source: string, // repo-relative path to the physical file + bareAlias?: boolean, // synthetic root-level alias (React_RCTAppDelegate rule) +}; + +type IncludeRef = { + token: string, // text between <> or "" + cxxGuarded: boolean, // true when only reachable under #ifdef __cplusplus +}; + +type HeaderEntry = { + naturalPath: string, + identities: Array, + lang: 'objc' | 'objcxx' | 'cxx' | 'c', + bucket: 'objc-modular-candidate' | 'objc-blocked' | 'objcxx' | 'cxx', + includes: { + internal: Array<{naturalPath: string, cxxGuarded: boolean}>, + thirdParty: Array<{lib: string, token: string, cxxGuarded: boolean}>, + hermes: Array, + system: Array, + std: Array<{token: string, cxxGuarded: boolean}>, + metaInternal: Array, + otherPlatform: Array, + notShipped: Array, + unresolved: Array, + }, +}; +*/ + +// Third-party C++ libraries that RN's public headers re-expose (Tier 3 of the +// modularization doc). Keyed by the first include-path segment. +const THIRD_PARTY_LIBS /*: Set */ = new Set([ + 'folly', + 'boost', + 'fmt', + 'glog', + 'double-conversion', + 'fast_float', +]); + +// Apple SDK / platform include roots (first path segment). Includes resolving +// here are "system": always modular or always available, never our problem. +const SDK_PREFIXES = new Set([ + 'Accelerate', + 'Accessibility', + 'AVFoundation', + 'AVKit', + 'CommonCrypto', + 'CoreFoundation', + 'CoreGraphics', + 'CoreLocation', + 'CoreMedia', + 'CoreServices', + 'CoreText', + 'CoreVideo', + 'Foundation', + 'ImageIO', + 'JavaScriptCore', + 'MachO', + 'Metal', + 'MetalKit', + 'MobileCoreServices', + 'Network', + 'PhotosUI', + 'QuartzCore', + 'SafariServices', + 'Security', + 'SwiftUI', + 'TargetConditionals.h', + 'UIKit', + 'UserNotifications', + 'WebKit', + 'XCTest', + 'arm', + 'dispatch', + 'libkern', + 'mach', + 'mach-o', + 'malloc', + 'objc', + 'os', + 'simd', + 'sys', +]); + +/** + * Scans a header's text line by line, tracking the preprocessor-conditional + * stack just enough to know whether a line is only compiled under + * `__cplusplus`. Returns the include list and language-marker observations. + * Heuristic by design: nested #if logic beyond __cplusplus is treated as + * "other" and ignored. + */ +function scanHeader(text /*: string */) /*: { + includes: Array, + hasObjC: boolean, + hasUnguardedCxx: boolean, + hasGuardedCxx: boolean, +} */ { + const includes /*: Array */ = []; + let hasObjC = false; + let hasUnguardedCxx = false; + let hasGuardedCxx = false; + + // Stack frames: 'cpp' (only under __cplusplus), 'notcpp', 'other'. + const stack /*: Array<'cpp' | 'notcpp' | 'other'> */ = []; + const inCxxOnly = () => stack.includes('cpp'); + + const includeRe = /^\s*#\s*(?:include|import)\s+(?:<([^>]+)>|"([^"]+)")/; + const objcRe = + /^\s*(@(interface|protocol|implementation|class\s|end)|NS_ASSUME_NONNULL_BEGIN)/; + const cxxRe = + /^\s*(namespace\s+[A-Za-z_]|template\s*<|extern\s+"C\+\+"|using\s+(namespace\s|[A-Za-z_]\w*\s*=))/; + + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/\/\/.*$/, ''); + const cond = line.match(/^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$/); + if (cond) { + const [, directive, rest] = cond; + const mentionsCpp = /__cplusplus/.test(rest); + if (directive === 'ifdef' || directive === 'if') { + stack.push( + mentionsCpp && + !/!\s*defined|defined\s*\(\s*__cplusplus\s*\)\s*==\s*0/.test(rest) + ? 'cpp' + : 'other', + ); + } else if (directive === 'ifndef') { + stack.push(mentionsCpp ? 'notcpp' : 'other'); + } else if (directive === 'else') { + const top = stack.pop() ?? 'other'; + stack.push( + top === 'cpp' ? 'notcpp' : top === 'notcpp' ? 'cpp' : 'other', + ); + } else if (directive === 'elif') { + stack.pop(); + stack.push(mentionsCpp ? 'cpp' : 'other'); + } else if (directive === 'endif') { + stack.pop(); + } + continue; + } + + const inc = line.match(includeRe); + if (inc) { + includes.push({ + token: inc[1] != null ? inc[1] : `"${inc[2]}"`, + cxxGuarded: inCxxOnly(), + }); + } + if (objcRe.test(line)) { + hasObjC = true; + } + if (cxxRe.test(line)) { + if (inCxxOnly()) { + hasGuardedCxx = true; + } else { + hasUnguardedCxx = true; + } + } + } + return {includes, hasObjC, hasUnguardedCxx, hasGuardedCxx}; +} + +// Meta-internal headers referenced behind RN_DISABLE_OSS_PLUGIN_HEADER (the +// FB*Plugins pattern) or fbjni/FBI18n — never resolvable in OSS, by design. +const META_INTERNAL_RE /*: RegExp */ = /^(fbjni|FBI18n)\/|^React\/FB\w+Plugins\.h$/; +// Non-Apple platform headers (Android-only branches in shared headers). +const OTHER_PLATFORM_PREFIXES = new Set(['android', 'jni']); + +// C++ standard library headers have no slash and no extension (); +// C standard headers have no slash and a .h (). +function classifyExternal( + token /*: string */, + ownNamespaces /*: Set */, + rootFolder /*: string */, +) /*: string */ { + const first = token.split('/')[0]; + if (THIRD_PARTY_LIBS.has(first)) { + return 'thirdParty'; + } + if (first === 'hermes') { + return 'hermes'; + } + if (META_INTERNAL_RE.test(token)) { + return 'metaInternal'; + } + if (OTHER_PLATFORM_PREFIXES.has(first)) { + return 'otherPlatform'; + } + if (!token.includes('/')) { + return token.endsWith('.h') ? 'system' : 'std'; + } + if (SDK_PREFIXES.has(first)) { + return 'system'; + } + // RN's own include namespace but absent from the shipped set: either a + // genuinely unshipped header or a header_dir-flattening mismatch (headers.js + // ships /, dropping inner subdirs like mounting/stubs/). + if ( + ownNamespaces.has(first) || + fs.existsSync(path.join(rootFolder, 'ReactCommon', token)) + ) { + return 'notShipped'; + } + return 'unresolved'; +} + +function buildInventory(rootFolder /*: string */) /*: { + entries: Map, + sourceToNatural: Map>, + collisions: Array<{naturalPath: string, sources: Array}>, +} */ { + const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); + + // naturalPath -> entry skeleton; absolute source -> naturalPaths it serves. + const entries /*: Map */ = new Map(); + const sourceToNatural /*: Map> */ = new Map(); + const naturalToSources /*: Map> */ = new Map(); + + const addIdentity = ( + naturalPath /*: string */, + identity /*: Identity */, + absSource /*: string */, + ) => { + let entry = entries.get(naturalPath); + if (!entry) { + entry = { + naturalPath, + identities: [], + lang: 'c', + bucket: 'cxx', + includes: { + internal: [], + thirdParty: [], + hermes: [], + system: [], + std: [], + metaInternal: [], + otherPlatform: [], + notShipped: [], + unresolved: [], + }, + }; + entries.set(naturalPath, entry); + } + entry.identities.push(identity); + + const naturals = sourceToNatural.get(absSource) ?? []; + if (!naturals.includes(naturalPath)) { + naturals.push(naturalPath); + } + sourceToNatural.set(absSource, naturals); + + const sources = naturalToSources.get(naturalPath) ?? new Set(); + sources.add(absSource); + naturalToSources.set(naturalPath, sources); + }; + + for (const podspecPath of Object.keys(podSpecsWithHeaderFiles)) { + const headerMaps = podSpecsWithHeaderFiles[podspecPath]; + // xcframework.js and vfs.js both use the ROOT spec's name (first map) as + // the pod folder, with the same first-occurrence '-' -> '_' replacement. + const podName = headerMaps[0].specName.replace('-', '_'); + + for (const headerMap of headerMaps) { + for (const header of headerMap.headers) { + // Some header patterns are written as *.{m,mm,cpp,h}; only headers ship. + if (!/\.(h|hpp)$/.test(header.source)) { + continue; + } + // Natural path = the VFS key: the podspec target, with root-level + // targets of header_dir-less pods prefixed by the pod name (vfs.js rule). + let naturalPath = header.target; + if ( + !naturalPath.includes('/') && + (!headerMap.headerDir || headerMap.headerDir === '') + ) { + naturalPath = `${podName}/${naturalPath}`; + } + const identity /*: Identity */ = { + pod: podName, + spec: headerMap.specName, + namespacedPath: path.join(podName, header.target), + source: path.relative(rootFolder, header.source), + }; + addIdentity(naturalPath, identity, header.source); + + // The merged ReactCoreHeaders tree ALSO exposes React_RCTAppDelegate + // headers bare at the root (hosts write #import ). + // Model that second identity explicitly. + if (podName === 'React_RCTAppDelegate') { + addIdentity( + path.basename(header.target), + { + ...identity, + bareAlias: true, + }, + header.source, + ); + } + } + } + } + + const collisions = []; + for (const [naturalPath, sources] of naturalToSources) { + if (sources.size > 1) { + collisions.push({ + naturalPath, + sources: Array.from(sources) + .map(s => path.relative(rootFolder, s)) + .sort(), + }); + } + } + collisions.sort((a, b) => a.naturalPath.localeCompare(b.naturalPath)); + + return {entries, sourceToNatural, collisions}; +} + +function classifyEntries( + entries /*: Map */, + sourceToNatural /*: Map> */, + rootFolder /*: string */, +) /*: void */ { + // RN's own top-level include namespaces, derived from the shipped set, so + // "in our namespace but not shipped" is detectable. + const ownNamespaces = new Set( + Array.from(entries.keys()) + .map(p => p.split('/')[0]) + .filter(p => p.includes('.') === false), + ); + + // Scan each entry's primary source once. + for (const entry of entries.values()) { + const absSource = path.join(rootFolder, entry.identities[0].source); + let text; + try { + text = fs.readFileSync(absSource, 'utf8'); + } catch { + entry.includes.unresolved.push(''); + continue; + } + const scan = scanHeader(text); + const isHpp = absSource.endsWith('.hpp'); + if (scan.hasObjC && scan.hasUnguardedCxx) { + entry.lang = 'objcxx'; + } else if (scan.hasObjC) { + entry.lang = 'objc'; + } else if (scan.hasUnguardedCxx || isHpp) { + entry.lang = 'cxx'; + } else { + entry.lang = 'c'; + } + + for (const inc of scan.includes) { + let token = inc.token; + // Quoted include: resolve against the source dir and map back to a + // natural path if the resolved file is itself a shipped header. + if (token.startsWith('"')) { + const resolved = path.resolve( + path.dirname(absSource), + token.slice(1, -1), + ); + const naturals = sourceToNatural.get(resolved); + if (naturals && naturals.length > 0) { + entry.includes.internal.push({ + naturalPath: naturals[0], + cxxGuarded: inc.cxxGuarded, + }); + } + // Quoted includes that don't land on a shipped header are + // pod-internal/private — not part of the public surface contract. + continue; + } + if (entries.has(token)) { + entry.includes.internal.push({ + naturalPath: token, + cxxGuarded: inc.cxxGuarded, + }); + continue; + } + const kind = classifyExternal(token, ownNamespaces, rootFolder); + if (kind === 'thirdParty') { + entry.includes.thirdParty.push({ + lib: token.split('/')[0], + token, + cxxGuarded: inc.cxxGuarded, + }); + } else if (kind === 'hermes') { + entry.includes.hermes.push(token); + } else if (kind === 'system') { + entry.includes.system.push(token); + } else if (kind === 'std') { + entry.includes.std.push({token, cxxGuarded: inc.cxxGuarded}); + } else if (kind === 'metaInternal') { + entry.includes.metaInternal.push(token); + } else if (kind === 'otherPlatform') { + entry.includes.otherPlatform.push(token); + } else if (kind === 'notShipped') { + entry.includes.notShipped.push(token); + } else { + entry.includes.unresolved.push(token); + } + } + } + + // Fixpoint over UNGUARDED edges only: what an Obj-C (non-C++) consumer of + // this header actually pulls in. Decides modularizability of the ObjC surface. + const reachesCxx /*: Map */ = new Map(); + const reachesTp /*: Map> */ = new Map(); + for (const [naturalPath, entry] of entries) { + reachesCxx.set( + naturalPath, + entry.lang === 'cxx' || + entry.lang === 'objcxx' || + entry.includes.std.some(s => !s.cxxGuarded), + ); + reachesTp.set( + naturalPath, + new Set( + entry.includes.thirdParty.filter(t => !t.cxxGuarded).map(t => t.lib), + ), + ); + } + let changed = true; + while (changed) { + changed = false; + for (const [naturalPath, entry] of entries) { + let cxx = reachesCxx.get(naturalPath) ?? false; + const tp = reachesTp.get(naturalPath) ?? new Set(); + const beforeCxx = cxx; + const beforeTp = tp.size; + for (const dep of entry.includes.internal) { + if (dep.cxxGuarded) { + continue; + } + cxx = cxx || (reachesCxx.get(dep.naturalPath) ?? false); + for (const lib of reachesTp.get(dep.naturalPath) ?? []) { + tp.add(lib); + } + } + if (cxx !== beforeCxx || tp.size !== beforeTp) { + reachesCxx.set(naturalPath, cxx); + reachesTp.set(naturalPath, tp); + changed = true; + } + } + } + + for (const [naturalPath, entry] of entries) { + if (entry.lang === 'cxx') { + entry.bucket = 'cxx'; + } else if (entry.lang === 'objcxx') { + entry.bucket = 'objcxx'; + } else { + const cxx = reachesCxx.get(naturalPath) ?? false; + const tp = Array.from(reachesTp.get(naturalPath) ?? []).sort(); + if (!cxx && tp.length === 0) { + entry.bucket = 'objc-modular-candidate'; + } else { + entry.bucket = 'objc-blocked'; + } + } + } +} + +function main() /*: void */ { + const argv = process.argv.slice(2); + const getFlag = (name /*: string */) /*: ?string */ => { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; + }; + const rootFolder = path.resolve( + getFlag('--root') ?? path.join(__dirname, '..', '..'), + ); + const outPath = path.resolve( + getFlag('--out') ?? path.join(rootFolder, 'build', 'header-inventory.json'), + ); + + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + const headers = Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ); + + const manifest = { + formatVersion: 1, + generatedBy: 'scripts/ios-prebuild/headers-inventory.js', + root: rootFolder, + totals: {headers: headers.length}, + collisions, + headers, + }; + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + + console.log(`Wrote ${headers.length} headers to ${outPath}`); +} + +if (require.main === module) { + main(); +} + +/** + * In-memory inventory for tooling that needs the classified header set + * without going through the JSON manifest on disk (e.g. the prebuild compose + * step feeding headers-spec.planFromInventory). + */ +function computeInventory( + rootFolder /*: string */, +) /*: {headers: Array} */ { + const {entries, sourceToNatural} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + return { + headers: Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ), + }; +} + +module.exports = { + buildInventory, + classifyEntries, + computeInventory, + scanHeader, + THIRD_PARTY_LIBS, + META_INTERNAL_RE, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js new file mode 100644 index 000000000000..93c3bf8a4dcc --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -0,0 +1,234 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * THE HEADERS SPEC — executable contract for the packaged header layout. + * + * One source of truth: the prebuild compose step (headers-compose.js) EMITS + * artifacts from it, and the SPM tooling derives what consumers need from it + * (nothing extra, by design). + * + * The rules: + * + * R1. React.framework/Headers ROOT serves the `React/` namespace (contents + * hoisted to root) plus the bare root aliases. The framework name supplies + * the `React/` prefix, so `` resolves verbatim through + * FRAMEWORK_SEARCH_PATHS. The `react/` (lowercase) namespace is NOT here — + * it ships in ReactNativeHeaders (R2). Resolving it through React.framework + * would require case-folding `react.framework` → `React.framework`, which + * only works on case-insensitive filesystems; the header-search-path route + * is exact and works everywhere. + * R2. Every other namespace (incl. `react/`) ships in ONE headers-only library + * xcframework ("ReactNativeHeaders"), namespace dirs at its Headers root, + * INCLUDING the third-party deps namespaces (folly/glog/boost/fmt/ + * double-conversion/fast_float, sourced from the deps artifact) — making + * ReactNativeDependencies binary-only. Served by exact header-search-path + * lookup, so resolution is filesystem-case-independent. + * R3. NO include rewriting anywhere — source headers are byte-identical to + * the repo (content authority = source files; layout authority = this + * spec). Consumers compile unchanged except bare-form angle includes + * (R6). + * R4. React.framework gets a framework module map with an umbrella over the + * ObjC modular surface: objc-modular-candidate ∧ React/-namespace ∧ no + * '+'-category header ∧ no C extern-inline definition (C99 extern inline + * emits a STRONG symbol per importing .m TU → duplicate symbols; + * RCTTextInputNativeCommands.h found empirically). + * R5. Every namespace with objc-modular-candidates gets a module declaring + * exactly those candidates (framework modules may not textually include + * non-modular framework headers; yoga + RCTDeprecation found + * empirically). Namespaces whose name is not a valid module identifier + * (e.g. jsinspector-modern) are exempt — they have no candidates today; + * the verifier asserts that stays true. `react/` is also exempt: its few + * objc-modular-candidates stay textual (as they already were inside + * React.framework) so no `react` module aliases the `React` framework + * module. + * R6. Bare root aliases are servable only as `` — bare angle forms + * (`#import `) have no framework spelling. This is the + * accepted, measured consumer migration (~4 lines ecosystem-wide). + * R7. Artifacts are code-signed AFTER header composition (signature pins the + * header manifest). + * R8. Collisions are ERRORS: two different source files may never project to + * the same destination path. + */ + +const fs = require('fs'); +const path = require('path'); + +const RN_ROOT = path.join(__dirname, '..', '..'); + +/*:: +export type SpecEntry = { + relPath: string, // destination under the artifact's Headers root + source: string, // repo-relative source file + naturalPath: string, // canonical include identity (inventory key) +}; + +export type HeadersSpecPlan = { + // React.xcframework -> React.framework/Headers (R1) + react: Array, + // ReactNativeHeaders.xcframework -> Headers (R2); deps namespaces are + // added by the emitter from the deps artifact (not per-file here). + reactNativeHeaders: Array, + depsNamespaces: Array, + // R4: umbrella header list (React/-relative paths) + umbrella: Array, + // R5: plain modules for ReactNativeHeaders' module.modulemap + namespaceModules: {[ns: string]: Array}, + collisions: Array, +}; +*/ + +// R2: third-party namespaces relocated from the deps artifact. +const DEPS_NAMESPACES = [ + 'folly', + 'glog', + 'boost', + 'fmt', + 'double-conversion', + 'fast_float', +]; + +// R4/R5 umbrella exclusion: C extern-inline definitions. +const EXTERN_INLINE_RE /*: RegExp */ = + /\b(RCT_EXTERN\s+inline|extern\s+inline)\b/; + +const MODULE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isUmbrellaSafe(h /*: any */) /*: boolean */ { + if (h.bucket !== 'objc-modular-candidate' || h.naturalPath.includes('+')) { + return false; + } + try { + return !EXTERN_INLINE_RE.test( + fs.readFileSync(path.join(RN_ROOT, h.identities[0].source), 'utf8'), + ); + } catch { + return false; + } +} + +/** + * Computes the full layout plan from the header inventory manifest + * (build/header-inventory.json — regenerate with header-inventory.js). + */ +function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ { + const react /*: Array */ = []; + const reactNativeHeaders /*: Array */ = []; + const umbrella /*: Array */ = []; + const namespaceModules /*: {[string]: Array} */ = {}; + const collisions /*: Array */ = []; + const seen /*: Map */ = new Map(); + + for (const h of manifest.headers) { + const np = h.naturalPath; + const source = h.identities[0].source; + let bucketKey; + let entryList; + let relPath; + if (np.startsWith('React/')) { + relPath = np.slice(6); // R1: hoist React/ to the framework Headers root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else if (!np.includes('/')) { + relPath = np; // R1/R6: bare alias at root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else { + // R2: every other namespace (incl. react/) keeps its prefix and is + // served from ReactNativeHeaders via the header search path. + relPath = np; + bucketKey = `ReactNativeHeaders/${relPath}`; + entryList = reactNativeHeaders; + } + const prev = seen.get(bucketKey); + if (prev != null) { + if (prev !== source) { + collisions.push(`${bucketKey}: ${prev} vs ${source}`); // R8 + } + continue; + } + seen.set(bucketKey, source); + entryList.push({relPath, source, naturalPath: np}); + + // R4: React umbrella membership. + if (np.startsWith('React/') && isUmbrellaSafe(h)) { + umbrella.push(np); + } + // R5: namespace modules (only for ReactNativeHeaders namespaces). `react/` + // is exempt — its few modular candidates stay textual so no `react` module + // aliases the `React` framework module. + if (entryList === reactNativeHeaders) { + const ns = np.split('/')[0]; + if (ns !== 'react' && MODULE_IDENT_RE.test(ns) && isUmbrellaSafe(h)) { + if (!namespaceModules[ns]) { + namespaceModules[ns] = []; + } + namespaceModules[ns].push(np); + } + } + } + + umbrella.sort(); + for (const ns of Object.keys(namespaceModules)) { + namespaceModules[ns].sort(); + } + + return { + react, + reactNativeHeaders, + depsNamespaces: DEPS_NAMESPACES, + umbrella, + namespaceModules, + collisions, + }; +} + +/** Renders React.framework's module map (R4). */ +function renderReactModuleMap() /*: string */ { + return `framework module React { + umbrella header "React-umbrella.h" + export * + module * { export * } +} +`; +} + +/** Renders the umbrella header content (R4). */ +function renderUmbrellaHeader(umbrella /*: Array */) /*: string */ { + return umbrella.map(u => `#import <${u}>`).join('\n') + '\n'; +} + +/** + * Renders ReactNativeHeaders' module.modulemap (R5): PLAIN (non-framework) + * modules, one per namespace with modular candidates — discovered implicitly + * by clang via the auto-added header search path. Headers are referenced by + * their path relative to the Headers root (= the modulemap's directory). + */ +function renderNamespaceModuleMap( + namespaceModules /*: {[string]: Array} */, +) /*: string */ { + const blocks = []; + for (const ns of Object.keys(namespaceModules).sort()) { + blocks.push( + `module ${ns} {\n` + + namespaceModules[ns].map(hh => ` header "${hh}"`).join('\n') + + `\n export *\n}`, + ); + } + return blocks.join('\n\n') + '\n'; +} + +module.exports = { + planFromInventory, + renderReactModuleMap, + renderUmbrellaHeader, + renderNamespaceModuleMap, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/types.js b/packages/react-native/scripts/ios-prebuild/types.js index 56cad1f9ab18..663ebfa77a91 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -22,24 +22,6 @@ export type Destination = export type BuildFlavor = 'Debug' | 'Release'; export type MavenSubGroup = 'hermes' | 'react'; - -export type VFSEntry = { - name: string, - type: 'file' | 'directory', - 'external-contents'?: string, - contents?: Array, -}; - -export type VFSOverlay = { - version: number, - 'case-sensitive': boolean, - roots: Array, -}; - -export type HeaderMapping = { - key: string, - path: string, -}; */ module.exports = {}; diff --git a/packages/react-native/scripts/ios-prebuild/vfs.js b/packages/react-native/scripts/ios-prebuild/vfs.js deleted file mode 100644 index 13e2cb233d5f..000000000000 --- a/packages/react-native/scripts/ios-prebuild/vfs.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -/*:: import type {HeaderMapping, VFSEntry, VFSOverlay} from './types'; */ - -const headers = require('./headers'); - -const {getHeaderFilesFromPodspecs} = headers; - -const ROOT_PATH_PLACEHOLDER = '${ROOT_PATH}'; - -/** - * Builds a hierarchical VFS directory structure from a list of header mappings. - * Clang's VFS overlay requires a tree structure where directories contain their children. - */ -function buildVFSStructure( - mappings /*: Array */, -) /*: Array */ { - // Group files by their directory structure - const dirTree /*: Map> */ = new Map(); - - for (const mapping of mappings) { - const parts = mapping.key.split('/'); - const fileName = parts[parts.length - 1]; - const dirPath = parts.slice(0, -1).join('/'); - - if (!dirTree.has(dirPath)) { - dirTree.set(dirPath, new Map()); - } - const filesMap = dirTree.get(dirPath); - if (filesMap) { - filesMap.set(fileName, mapping.path); - } - } - - // Build the root-level entries (files at root + top-level directories) - const rootDirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - const topLevel = dirPath.split('/')[0]; - if (topLevel) { - rootDirs.add(topLevel); - } - } - - const roots /*: Array */ = []; - - // Add files that live at the root (e.g. key === 'RCTAppDelegate.h') - const rootFiles = dirTree.get(''); - if (rootFiles) { - for (const [fileName, sourcePath] of Array.from( - rootFiles.entries(), - ).sort()) { - roots.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - for (const rootDir of Array.from(rootDirs).sort()) { - const dirEntry = buildDirectoryEntry(rootDir, '', dirTree); - roots.push(dirEntry); - } - - return roots; -} - -/** - * Recursively builds a directory entry for the VFS - */ -function buildDirectoryEntry( - dirName /*: string */, - parentPath /*: string */, - dirTree /*: Map> */, -) /*: VFSEntry */ { - const currentPath = parentPath ? `${parentPath}/${dirName}` : dirName; - const contents /*: Array */ = []; - - // Add files in this directory - const filesInDir = dirTree.get(currentPath); - if (filesInDir) { - for (const [fileName, sourcePath] of Array.from( - filesInDir.entries(), - ).sort()) { - contents.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - // Add subdirectories - const subdirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - if (dirPath.startsWith(currentPath + '/')) { - const remainder = dirPath.slice(currentPath.length + 1); - const nextDir = remainder.split('/')[0]; - if (nextDir) { - subdirs.add(nextDir); - } - } - } - - for (const subdir of Array.from(subdirs).sort()) { - contents.push(buildDirectoryEntry(subdir, currentPath, dirTree)); - } - - return { - name: dirName, - type: 'directory', - contents, - }; -} - -/** - * Simple YAML generator for VFS overlay structure (hierarchical format) - */ -function generateVFSOverlayYAML(overlay /*: VFSOverlay */) /*: string */ { - let yaml = ''; - - yaml += `version: ${String(overlay.version)}\n`; - yaml += `case-sensitive: ${String(overlay['case-sensitive'])}\n`; - yaml += `roots:\n`; - - for (const root of overlay.roots) { - yaml += generateEntryYAML(root, 1); - } - - return yaml; -} - -/** - * Recursively generates YAML for a VFS entry - */ -function generateEntryYAML( - entry /*: VFSEntry */, - indent /*: number */, -) /*: string */ { - const spaces = ' '.repeat(indent); - let yaml = ''; - - yaml += `${spaces}- name: '${entry.name}'\n`; - yaml += `${spaces} type: '${entry.type}'\n`; - - if (entry['external-contents']) { - yaml += `${spaces} external-contents: '${entry['external-contents']}'\n`; - } - - if (entry.contents && entry.contents.length > 0) { - yaml += `${spaces} contents:\n`; - for (const child of entry.contents) { - yaml += generateEntryYAML(child, indent + 2); - } - } - - return yaml; -} - -/** - * Creates a VFS overlay object from the header files in podspecs. - * The source paths use ${ROOT_PATH} as a placeholder for later replacement - * with the actual root path on the end user's machine. - * - * The VFS overlay wraps all header mappings under a single root at - * ${ROOT_PATH}/Headers, which matches the HEADER_SEARCH_PATHS configured - * in rncore.rb. This allows the compiler to find headers like - * by looking up ${ROOT_PATH}/Headers/yoga/style/Style.h - * which the VFS redirects to the flat location in the xcframework. - * - * @param rootFolder The root folder of the React Native package - * @returns A VFS overlay object that can be serialized to YAML - */ -function createVFSOverlayContents(rootFolder /*: string */) /*: VFSOverlay */ { - // Get header files from podspecs (disable testing since we just need the mappings) - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - const mappings /*: Array */ = []; - - // Process each podspec and its header files - Object.keys(podSpecsWithHeaderFiles).forEach(podspecPath => { - const headerMaps = podSpecsWithHeaderFiles[podspecPath]; - - // Use the first podspec spec name as the podspec name (this is the root spec) - const podSpecName = headerMaps[0].specName.replace('-', '_'); - - headerMaps.forEach(headerMap => { - headerMap.headers.forEach(header => { - // The key is just the target path (the import path) - // e.g., 'react/renderer/graphics/Size.h' for #import - let key = header.target; - - // If the podspec doesn't specify a header_dir, CocoaPods exposes public headers under - // (and umbrella headers typically use quoted imports resolved relative - // to the pod's public headers directory). To mirror that layout and avoid collisions - // between pods, prefix root-level header targets with the pod spec name. - if ( - !key.includes('/') && - (!headerMap.headerDir || headerMap.headerDir === '') - ) { - key = `${podSpecName}/${key}`; - } - - // The external-contents path is always podSpecName + header.target because - // xcframework.js copies headers to: outputHeadersPath/podSpecName/headerFile.target - // So the VFS must point to that same location. - const sourcePath = `${ROOT_PATH_PLACEHOLDER}/Headers/${podSpecName}/${header.target}`; - - mappings.push({ - key, - path: sourcePath, - }); - }); - }); - }); - - // Build the hierarchical VFS structure from mappings - const innerRoots = buildVFSStructure(mappings); - - // Wrap all roots under a single ${ROOT_PATH}/Headers root. - // This is required because Clang's VFS overlay needs absolute paths for root entries. - // The compiler will have -I${ROOT_PATH}/Headers in its include paths, so when it - // searches for , it looks for ${ROOT_PATH}/Headers/yoga/style/Style.h. - // The VFS overlay intercepts this and maps it to the actual flat location. - const wrappedRoot /*: VFSEntry */ = { - name: `${ROOT_PATH_PLACEHOLDER}/Headers`, - type: 'directory', - contents: innerRoots, - }; - - return { - version: 0, - 'case-sensitive': false, - roots: [wrappedRoot], - }; -} - -/** - * Creates a VFS overlay YAML file from the header files in podspecs. - * This is a convenience function that combines createVFSOverlayContents and - * generateVFSOverlayYAML into a single call. - * - * @param rootFolder The root folder of the React Native package - * @returns The VFS overlay as a YAML string ready to be written to a file - */ -function createVFSOverlay(rootFolder /*: string */) /*: string */ { - const overlay = createVFSOverlayContents(rootFolder); - return generateVFSOverlayYAML(overlay); -} - -/** - * Resolves a VFS overlay template by replacing the ${ROOT_PATH} placeholder - * with the actual root path. This is the equivalent of the Ruby create_vfs_overlay - * function in rncore.rb. - * - * The VFS overlay template contains ${ROOT_PATH} placeholders that need to be - * replaced with the actual path to the xcframework on the end user's machine - * (e.g., the path to React.xcframework in the Pods folder). - * - * @param vfsTemplate The VFS overlay template content (YAML string with ${ROOT_PATH} placeholders) - * @param rootPath The actual root path to substitute for ${ROOT_PATH} - * @returns The resolved VFS overlay YAML string with absolute paths - */ -function resolveVFSOverlay( - vfsTemplate /*: string */, - rootPath /*: string */, -) /*: string */ { - return vfsTemplate.split(ROOT_PATH_PLACEHOLDER).join(rootPath); -} - -module.exports = { - createVFSOverlay, - resolveVFSOverlay, -}; diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 1f7d473046e5..f021edbf68cc 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,46 +13,16 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); -const headers = require('./headers'); const utils = require('./utils'); -const vfs = require('./vfs'); const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); const {execSync} = childProcess; -const {getHeaderFilesFromPodspecs} = headers; -const {createFolderIfNotExists, createLogger} = utils; -const {createVFSOverlay} = vfs; +const {createLogger} = utils; const frameworkLog = createLogger('XCFramework'); -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const REACT_CORE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React-umbrella.h', -); - -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React_RCTAppDelegate-umbrella.h', -); - -const RN_MODULEMAP_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'module.modulemap', -); - function buildXCFrameworks( rootFolder /*: string */, buildFolder /*: string */, @@ -70,7 +40,7 @@ function buildXCFrameworks( buildType, 'React.xcframework', ); - // Delete all target platform folders (everything but the Headers and Modules folders) + // Delete any previous output try { fs.rmSync(outputPath, {recursive: true, force: true}); } catch (error) { @@ -104,98 +74,41 @@ function buildXCFrameworks( return; } - // Use the header files from podspecs - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - // Delete header files to the output path - const outputHeadersPath = path.join(outputPath, 'Headers'); - - // Store umbrella headers keyed on podspec names - const umbrellaHeaders /*: {[key: string]: string} */ = {}; - const copiedHeaderFilesWithPodspecNames /*: {[key: string]: string[]} */ = {}; - - // Enumerate podspecs and copy headers, create umbrella headers and module map file - Object.keys(podSpecsWithHeaderFiles).forEach(podspec => { - const headerFiles = podSpecsWithHeaderFiles[podspec] - .map(h => h.headers) - .flat(); - - // Use the first podspec spec name as the podspec name (this is the root spec in the podspec file) - const podSpecName = podSpecsWithHeaderFiles[podspec][0].specName.replace( - '-', - '_', - ); - - if (headerFiles.length > 0) { - // Create a folder for the podspec in the output headers path - const podSpecTargetFolder = path.join(outputHeadersPath, podSpecName); - - // Copy each header file to the podspec folder - copiedHeaderFilesWithPodspecNames[podSpecName] = headerFiles.map( - headerFile => { - const headerFileTargetPath = path.join( - podSpecTargetFolder, - headerFile.target, - ); - createFolderIfNotExists(path.dirname(headerFileTargetPath)); - fs.copyFileSync(headerFile.source, headerFileTargetPath); - return headerFileTargetPath; - }, - ); - - // Create umbrella header file for the podspec - const umbrellaHeaderFilename = path.join( - podSpecTargetFolder, - podSpecName + '-umbrella.h', - ); - - if ( - podSpecName === 'React_Core' || - podSpecName === 'React_RCTAppDelegate' - ) { - if (podSpecName === 'React_Core') { - // Copy the React-umbrella.h file to the umbrella header filename - fs.copyFileSync( - REACT_CORE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } else { - fs.copyFileSync( - RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } - - // Store the umbrella header filename in the umbrellaHeaders object - umbrellaHeaders[podSpecName] = umbrellaHeaderFilename; - } - } - }); - - // Create the module map file using the header files in podSpecsWithHeaderFiles - const moduleMapFile = createModuleMapFile(outputPath); - if (!moduleMapFile) { - frameworkLog( - 'Failed to create module map file. The XCFramework may not work correctly. Stopping.', - 'error', - ); - return; - } + // Copy Symbols to symbols folder + copySymbols(outputPath, frameworkFolders); - // Copy header files and module map file to each platform slice in the XCFramework - copyHeaderFilesToSlices( + // Emit the headers-spec layout into every slice's React.framework and build + // the ReactNativeHeaders headers-only xcframework beside it. This is the only + // header surface consumers compile against — no root Headers/, no clang VFS + // overlay. MUST run before signing (spec R7: the signature pins the manifest). + const { + buildReactNativeHeadersXcframework, + computeSpecPlan, + emitReactFrameworkHeaders, + } = require('./headers-compose'); + const depsHeaders = path.join( rootFolder, - outputPath, - moduleMapFile, - umbrellaHeaders, - copiedHeaderFilesWithPodspecNames, + 'third-party', + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + const plan = computeSpecPlan(rootFolder); + emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // NOTE: Hermes public headers (``) are folded into + // ReactNativeHeaders on the consumer side by ensureHeadersLayout. When this + // publish path is productionized, pass the prebuild's hermes destroot/include + // as the 6th arg so the PUBLISHED ReactNativeHeaders carries hermes too. + const headersXcfw = buildReactNativeHeadersXcframework( + path.dirname(outputPath), + plan, + depsHeaders, + rootFolder, + true, // include the mac-catalyst slice in the real compose ); - - // Copy Symbols to symbols folder - copySymbols(outputPath, frameworkFolders); if (identity) { signXCFramework(identity, outputPath); + signXCFramework(identity, headersXcfw); } // Tar the output folder to a .tar.gz file @@ -208,8 +121,12 @@ function buildXCFrameworks( ); frameworkLog('Creating tar file: ' + tarFilePath); try { + // Ship ReactNativeHeaders.xcframework alongside React.xcframework in the + // reactnative-core artifact so the React-Core-prebuilt pod can vend both + // (React.framework -> , ReactNativeHeaders -> every other + // namespace). The headers-only xcframework is a sibling of React.xcframework. execSync( - `tar -czf ${tarFilePath} -C ${path.dirname(outputPath)} React.xcframework`, + `tar -czf ${tarFilePath} -C ${path.dirname(outputPath)} React.xcframework ${path.basename(headersXcfw)}`, { stdio: 'inherit', }, @@ -220,6 +137,27 @@ function buildXCFrameworks( 'warning', ); } + + // Publish ReactNativeHeaders alongside React. + const headersTarPath = path.join( + buildFolder, + 'output', + 'xcframeworks', + buildType, + 'ReactNativeHeaders.xcframework.tar.gz', + ); + frameworkLog('Creating tar file: ' + headersTarPath); + try { + execSync( + `tar -czf ${headersTarPath} -C ${path.dirname(headersXcfw)} ReactNativeHeaders.xcframework`, + {stdio: 'inherit'}, + ); + } catch (error) { + frameworkLog( + `Error creating ReactNativeHeaders tar: ${error.message}`, + 'warning', + ); + } } function copySymbols( @@ -277,134 +215,6 @@ function copySymbols( }); } -// Copy header files and module map file to each platform slice in the XCFramework. -function copyHeaderFilesToSlices( - rootFolder /*:string*/, - outputPath /*:string*/, - moduleMapFile /*:string*/, - umbrellaHeaderFiles /*:{[key: string]: string}*/, - outputHeaderFiles /*: {[key: string]: string[]} */, -) { - frameworkLog('Linking modules and headers to platform folders for slice...'); - - // Enumerate all platform folders in the output path - const platformFolders = fs - .readdirSync(outputPath) - .map(folder => path.join(outputPath, folder)) - .filter(folder => { - return ( - fs.statSync(folder).isDirectory() && - !folder.endsWith('Headers') && - !folder.endsWith('Modules') - ); - }); - - platformFolders.forEach(platformFolder => { - // Link the Modules folder into the platform folder - const targetModulesFolder = path.join( - platformFolder, - 'React.Framework', - 'Modules', - ); - createFolderIfNotExists(targetModulesFolder); - - try { - fs.linkSync( - moduleMapFile, - path.join(targetModulesFolder, path.basename(moduleMapFile)), - ); - } catch (error) { - frameworkLog( - `Error copying module map file: ${error.message}. Check if the file exists at ${moduleMapFile}.`, - 'error', - ); - } - // Copy headers folder into the platform folder - const targetHeadersFolder = path.join( - platformFolder, - 'React.Framework', - 'Headers', - ); - - // Copy umbrella / header files into the platform folder - Object.keys(umbrellaHeaderFiles).forEach(podSpecName => { - const umbrellaHeaderFile = umbrellaHeaderFiles[podSpecName]; - - // Create the target folder for the umbrella header file - const targetPodSpecFolder = path.join(targetHeadersFolder, podSpecName); - createFolderIfNotExists(targetPodSpecFolder); - // Copy the umbrella header file to the target folder - try { - fs.copyFileSync( - umbrellaHeaderFile, - path.join(targetPodSpecFolder, path.basename(umbrellaHeaderFile)), - ); - } catch (error) { - frameworkLog( - `Error copying umbrella header file: ${umbrellaHeaderFile}\nError: ${error.message}. Check if the file exists.`, - 'error', - ); - } - }); - - Object.keys(outputHeaderFiles).forEach(podSpecName => { - outputHeaderFiles[podSpecName].forEach(headerFile => { - // Get the relative path from the root Headers folder to preserve directory structure - // headerFile is like /path/to/Headers/Yoga/yoga/style/Style.h - // We need to extract Yoga/yoga/style/Style.h and copy to the same structure in the slice - const rootHeadersFolder = path.join(outputPath, 'Headers'); - const relativeHeaderPath = path.relative(rootHeadersFolder, headerFile); - const targetHeaderFile = path.join( - targetHeadersFolder, - relativeHeaderPath, - ); - createFolderIfNotExists(path.dirname(targetHeaderFile)); - if (!fs.existsSync(targetHeaderFile)) { - try { - fs.copyFileSync(headerFile, targetHeaderFile); - } catch (error) { - frameworkLog( - `Error copying header file: ${error.message}. Check if the file exists.`, - 'error', - ); - } - } - }); - }); - }); - - // Create VFS overlay file at the XCFramework root (same for all platforms) - const vfsFilePath = path.join(outputPath, 'React-VFS-template.yaml'); - try { - fs.writeFileSync(vfsFilePath, createVFSOverlay(rootFolder), 'utf8'); - frameworkLog(`Created VFS overlay: ${path.basename(vfsFilePath)}`); - } catch (error) { - frameworkLog(`Error creating VFS overlay file: ${error.message}.`, 'error'); - } -} - -function createModuleMapFile(outputPath /*: string */) { - // Create/get the module map folder - const moduleMapFolder = path.join(outputPath, 'Modules'); - createFolderIfNotExists(moduleMapFolder); - - // Create the module map file - const moduleMapFile = path.join(moduleMapFolder, 'module.modulemap'); - - frameworkLog('Creating module map file: ' + moduleMapFile); - - try { - fs.copyFileSync(RN_MODULEMAP_PATH, moduleMapFile); - return moduleMapFile; - } catch (error) { - frameworkLog( - `Error creating module map file: ${error.message}. Check if the file exists.`, - 'error', - ); - return null; - } -} - function getArchsFromFramework(frameworkPath /*:string*/) { try { return execSync(`vtool -show-build ${frameworkPath}|grep platform`) diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index f9dac08218be..2cced9a8101f 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -571,11 +571,9 @@ def react_native_post_install( # with precompiled binaries. ReactNativePodsUtils.set_build_setting(installer, build_setting: "SWIFT_ENABLE_EXPLICIT_MODULES", value: "NO") - # Process the VFS overlay for prebuilt React Native Core - this is done as part of the post install so - # that we can update paths based on the final location of the Pods installation. - ReactNativeCoreUtils.process_vfs_overlay() - - # Configure xcconfig for prebuilt usage (VFS overlay, header paths, cleanup redundant paths) + # Make the prebuilt React.xcframework headers resolvable from aggregate (main app) + # and third-party pod targets that don't go through add_rncore_dependency. The headers + # are served directly from the xcframework's headers-spec layout — no clang VFS overlay. ReactNativeCoreUtils.configure_aggregate_xcconfig(installer) end diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index 2684d3250b5d..cab2f1257559 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -99,17 +99,19 @@ function replaceRNCoreConfiguration( throw new Error(`tar extraction failed with exit code ${result.status}`); } - // Verify extraction produced the expected xcframework structure + // Verify extraction produced the expected xcframework structure. The + // module map now lives per-slice inside React.framework, so check the + // xcframework's Info.plist instead of a root Modules/module.modulemap. const xcfwPath = path.join(tmpExtractDir, 'React.xcframework'); - const modulemapPath = path.join(xcfwPath, 'Modules', 'module.modulemap'); - if (!fs.existsSync(modulemapPath)) { + const infoPlistPath = path.join(xcfwPath, 'Info.plist'); + if (!fs.existsSync(infoPlistPath)) { throw new Error( - `Extraction verification failed: ${modulemapPath} not found`, + `Extraction verification failed: ${infoPlistPath} not found`, ); } - // Delete all directories in finalLocation - not files, since we want to - // keep the React-VFS.yaml file + // Delete only directories in finalLocation (e.g. the React.xcframework) - + // not files, so any sibling files written during pod install are preserved. const dirs = fs .readdirSync(finalLocation, {withFileTypes: true}) .filter(dirent => dirent.isDirectory()); @@ -144,6 +146,37 @@ function replaceRNCoreConfiguration( } } } + + // The podspec prepare_command flattens ReactNativeHeaders' headers into a + // top-level Headers/ dir, but it does not re-run on a config swap. Mirror + // it here: re-flatten the headers (identical across slices) and drop the + // now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers + // keeps resolving , , etc. + const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework'); + if (fs.existsSync(rnhXcfw)) { + const slice = fs + .readdirSync(rnhXcfw, {withFileTypes: true}) + .find( + dirent => + dirent.isDirectory() && + fs.existsSync(path.join(rnhXcfw, dirent.name.toString(), 'Headers')), + ); + if (slice) { + const headersDest = path.join(finalLocation, 'Headers'); + fs.rmSync(headersDest, {force: true, recursive: true}); + const cpHeaders = spawnSync( + 'cp', + ['-R', path.join(rnhXcfw, slice.name.toString(), 'Headers'), headersDest], + {stdio: 'inherit'}, + ); + if (cpHeaders.status !== 0) { + throw new Error( + `Flattening ReactNativeHeaders failed with exit code ${cpHeaders.status}`, + ); + } + fs.rmSync(rnhXcfw, {force: true, recursive: true}); + } + } } finally { // Clean up temp directory fs.rmSync(tmpDir, {force: true, recursive: true});