From df95f9ed9c0528743fef0f08e31b40178342b569 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 05:49:49 -0500 Subject: [PATCH 1/5] fix: sync focused 0.87 Apple artifact and type compatibility changes Curate thirteen upstream fixes for nested root nodes, Swift C++ tracing, optional TypeScript members, version-stamped headers, Hermes header composition and SwiftPM build configuration. Preserve mainline package versions and fork APIs. Independent review verified 778 tests, 131 snapshots, strict/legacy type consumers, facade ownership and exact source provenance. Full Apple C++ snapshot generation remains limited by the recorded duplicate-enum baseline issue. --- .github/workflows/prebuild-ios-core.yml | 35 ++- .../HostTargetTraceRecording.cpp | 17 +- .../tracing/HostTracingProfile.h | 15 +- .../tracing/TraceRecordingState.h | 11 + .../react/nativemodule/dom/NativeDOM.cpp | 14 +- packages/react-native/ReactNativeApi.d.ts | 258 ++++++++-------- packages/react-native/package.json | 5 +- .../__tests__/replace-rncore-version-test.js | 141 +++++++++ .../scripts/__tests__/script-exports-test.js | 32 ++ .../react-native/scripts/cocoapods/rncore.rb | 1 + packages/react-native/scripts/ios-prebuild.js | 1 + .../__tests__/headers-compose-test.js | 64 ++++ .../__tests__/xcframework-test.js | 70 +++++ .../react-native/scripts/ios-prebuild/cli.js | 7 + .../scripts/ios-prebuild/headers-compose.js | 26 +- .../scripts/ios-prebuild/headers-verify.js | 60 +++- .../scripts/ios-prebuild/utils.js | 27 ++ .../scripts/ios-prebuild/xcframework.js | 58 +++- .../scripts/replace-rncore-version.js | 178 ++++-------- .../react-native/scripts/setup-apple-spm.js | 120 +++++++- .../scripts/spm/__doc__/spm-scripts.md | 37 ++- .../__tests__/download-spm-artifacts-test.js | 135 ++++++++- .../generate-spm-autolinking-config-test.js | 105 +++++++ .../__tests__/remove-spm-injection-test.js | 146 ++++++++++ .../spm/__tests__/setup-apple-spm-test.js | 275 +++++++++++++++++- .../scripts/spm/autolinking-plugins.js | 4 +- .../scripts/spm/download-spm-artifacts.js | 76 ++++- .../spm/generate-spm-autolinking-config.js | 64 +++- .../scripts/spm/generate-spm-autolinking.js | 2 +- .../scripts/spm/generate-spm-xcodeproj.js | 66 ++++- .../scripts/spm/scaffold-package-swift.js | 6 +- .../react-native/scripts/spm/spm-types.js | 3 + .../__tests__/ReactNativeElement-itest.js | 44 ++- .../ios/RNTMyNativeViewComponentView.mm | 4 + private/core-cli-utils/src/private/app.js | 2 +- .../api-snapshots/ReactAndroidDebugCxx.api | 11 + .../api-snapshots/ReactAndroidNewarchCxx.api | 11 + .../api-snapshots/ReactAndroidReleaseCxx.api | 11 + .../api-snapshots/ReactAppleDebugCxx.api | 11 + .../api-snapshots/ReactAppleNewarchCxx.api | 11 + .../api-snapshots/ReactAppleReleaseCxx.api | 11 + .../api-snapshots/ReactCommonDebugCxx.api | 11 + .../api-snapshots/ReactCommonNewarchCxx.api | 11 + .../api-snapshots/ReactCommonReleaseCxx.api | 11 + .../ensureUndefinedOnOptionalMembers-test.js | 65 +++++ ...removeUndefinedFromOptionalMembers-test.js | 16 + .../ensureUndefinedOnOptionalMembers.js | 71 +++++ .../removeUndefinedFromOptionalMembers.js | 22 +- .../js-api/build-types/translateSourceFile.js | 1 + 49 files changed, 2021 insertions(+), 362 deletions(-) create mode 100644 packages/react-native/scripts/__tests__/replace-rncore-version-test.js create mode 100644 packages/react-native/scripts/__tests__/script-exports-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js create mode 100644 scripts/js-api/build-types/transforms/typescript/__tests__/ensureUndefinedOnOptionalMembers-test.js create mode 100644 scripts/js-api/build-types/transforms/typescript/ensureUndefinedOnOptionalMembers.js diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index db271c4e246d..ba96d1fc19ae 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -43,14 +43,8 @@ jobs: - name: Set Hermes version shell: bash run: | - # Non-stable RN builds resolve Hermes from npm's latest-v1 dist-tag. - # TODO: rename 'latest-v1' to 'latest' once V1 is the only Hermes on npm. - # Stable builds use the version pinned in version.properties. - if [ "${{ inputs.use-hermes-prebuilt }}" == "true" ]; then - HERMES_VERSION="latest-v1" - else - HERMES_VERSION=$(sed -n 's/^HERMES_VERSION_NAME=//p' packages/react-native/sdks/hermes-engine/version.properties) - fi + # [macOS] Both slice and compose jobs use the fork's exact SDK metadata. + HERMES_VERSION=$(node -p "require('./packages/react-native/scripts/ios-prebuild/hermes-version').readHermesMetadata('single').version") echo "Using Hermes version: $HERMES_VERSION" echo "HERMES_VERSION=$HERMES_VERSION" >> $GITHUB_ENV - name: Set React Native version @@ -133,7 +127,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/.build/output/xcframeworks - key: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v4-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} - name: Setup node.js if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/setup-node @@ -175,6 +169,21 @@ jobs: tar -xzf /tmp/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz -C /tmp/third-party/ mkdir -p packages/react-native/third-party/ mv /tmp/third-party/packages/react-native/third-party/ReactNativeDependencies.xcframework packages/react-native/third-party/ReactNativeDependencies.xcframework + - name: Set Hermes version + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + shell: bash + run: | + # [macOS] Match the slice job's SDK metadata, not a live npm dist-tag. + HERMES_VERSION=$(node -p "require('./packages/react-native/scripts/ios-prebuild/hermes-version').readHermesMetadata('single').version") + echo "Using Hermes version: $HERMES_VERSION" + echo "HERMES_VERSION=$HERMES_VERSION" >> $GITHUB_ENV + - name: Stage Hermes headers + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + working-directory: packages/react-native + env: + FLAVOR: ${{ matrix.flavor }} + run: | + node -e "require('./scripts/ios-prebuild/hermes').prepareHermesArtifactsAsync(require('./package.json').version, process.env.FLAVOR).then(()=>process.exit(0)).catch(e=>{console.error(e);process.exit(1)})" - name: Setup Keychain if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} uses: apple-actions/import-codesign-certs@v3 # https://github.com/marketplace/actions/import-code-signing-certificates @@ -185,12 +194,12 @@ jobs: if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT == '' }} run: | cd packages/react-native - node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" + node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-hermes' || '' }} - name: Create and Sign XCFramework if: ${{ steps.restore-ios-xcframework.outputs.cache-hit != 'true' && env.REACT_ORG_CODE_SIGNING_P12_CERT != '' }} run: | cd packages/react-native - node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" + node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" ${{ inputs.version-type != '' && '--require-hermes' || '' }} - name: Verify composed headers if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | @@ -200,7 +209,7 @@ jobs: # privileged-consumer/Expo fixtures). Catches consumer-facing header # regressions here instead of in downstream builds. cd packages/react-native - node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" + node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}" ${{ inputs.version-type != '' && '--require-stamped-version' || '' }} - name: Compress and Rename XCFramework if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | @@ -231,4 +240,4 @@ jobs: path: | packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.framework.dSYM.tar.gz - key: v3-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v4-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp index a6e2d5da63f6..f3022e786838 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTargetTraceRecording.cpp @@ -64,13 +64,16 @@ tracing::HostTracingProfile HostTargetTraceRecording::stop() { auto startTime = *startTime_; startTime_.reset(); - return tracing::HostTracingProfile{ - .processId = oscompat::getCurrentProcessId(), - .startTime = startTime, - .frameTimings = frameTimings_.pruneExpiredAndExtract(), - .instanceTracingProfiles = std::move(state.instanceTracingProfiles), - .runtimeSamplingProfiles = std::move(state.runtimeSamplingProfiles), - }; + // Member-wise assignment instead of designated initializers: + // HostTracingProfile declares its special members (move-only) and is no + // longer an aggregate. + tracing::HostTracingProfile profile; + profile.processId = oscompat::getCurrentProcessId(); + profile.startTime = startTime; + profile.frameTimings = frameTimings_.pruneExpiredAndExtract(); + profile.instanceTracingProfiles = std::move(state.instanceTracingProfiles); + profile.runtimeSamplingProfiles = std::move(state.runtimeSamplingProfiles); + return profile; } void HostTargetTraceRecording::recordFrameTimings( diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h index fd32cb3c2257..aa37579fc696 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfile.h @@ -23,9 +23,22 @@ namespace facebook::react::jsinspector_modern::tracing { * messages. */ struct HostTracingProfile { + HostTracingProfile() = default; + + // Explicitly move-only: FrameTimingSequence and RuntimeSamplingProfile are + // not copyable, so the implicit copy constructor is ill-formed the moment it + // is instantiated. Plain C++ never instantiates it, but Swift's C++ interop + // does when these headers are reached from an imported module, turning it + // into a hard compile error (Xcode 26.3). + HostTracingProfile(const HostTracingProfile &) = delete; + HostTracingProfile &operator=(const HostTracingProfile &) = delete; + HostTracingProfile(HostTracingProfile &&) = default; + HostTracingProfile &operator=(HostTracingProfile &&) = default; + ~HostTracingProfile() = default; + // The ID of the OS-level process that this Trace Recording is associated // with. - ProcessId processId; + ProcessId processId{}; // The timestamp at which this Trace Recording started. HighResTimeStamp startTime; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h index db0acc40b76c..2a3187c7610a 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h @@ -31,6 +31,17 @@ struct TraceRecordingState { { } + // Explicitly move-only: RuntimeSamplingProfile is not copyable, so the + // implicit copy constructor is ill-formed the moment it is instantiated. + // Plain C++ never instantiates it, but Swift's C++ interop (ClangImporter) + // does when a consumer imports these headers as part of a module, turning + // it into a hard compile error (Xcode 26.3). + TraceRecordingState(const TraceRecordingState &) = delete; + TraceRecordingState &operator=(const TraceRecordingState &) = delete; + TraceRecordingState(TraceRecordingState &&) = default; + TraceRecordingState &operator=(TraceRecordingState &&) = default; + ~TraceRecordingState() = default; + // The mode of this Trace Recording. tracing::Mode mode; diff --git a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp index da40ea3b7899..603bd0d69d5a 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp @@ -198,10 +198,6 @@ jsi::Value NativeDOM::getParentNode( } auto shadowNode = getShadowNode(rt, nativeNodeReference); - if (isRootShadowNode(*shadowNode)) { - // The parent of the root node is the document. - return jsi::Value{shadowNode->getSurfaceId()}; - } auto currentRevision = getCurrentShadowTreeRevision(rt, shadowNode->getSurfaceId()); @@ -209,6 +205,16 @@ jsi::Value NativeDOM::getParentNode( return jsi::Value::undefined(); } + // The parent of the surface's root node is the document. Only the actual + // root node qualifies: nested nodes that carry the `RootNodeKind` trait + // (e.g. , portals/overlays) still have a real parent in the shadow + // tree and must report it. Otherwise capture/bubble event propagation is + // silently severed at that boundary (a listener on an ancestor rendered + // above the modal would never receive descendant focus/blur, etc.). + if (ShadowNode::sameFamily(*currentRevision, *shadowNode)) { + return jsi::Value{shadowNode->getSurfaceId()}; + } + auto parentShadowNode = dom::getParentNode(currentRevision, *shadowNode); if (parentShadowNode == nullptr) { return jsi::Value::undefined(); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index fe41f971f4b0..e21c029dd30a 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<42fdaff3d087e2a1d276298b69718f30>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1733,11 +1733,11 @@ declare interface ButtonProps { readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onBlur?: (e: BlurEvent) => void readonly onFocus?: (e: FocusEvent_2) => void + readonly onPress?: (event?: GestureResponderEvent) => unknown readonly testID?: string readonly title: string readonly tooltip?: string readonly touchSoundDisabled?: boolean - readonly onPress?: (event?: GestureResponderEvent) => unknown } declare function cancelHeadlessTask(taskId: number, taskKey: string): void declare type Category = string @@ -2760,12 +2760,12 @@ declare type InterpolationConfigType< OutputT extends InterpolationConfigSupportedOutputType, > = Readonly< AnimatedNodeConfig & { + easing?: (input: number) => number extrapolate?: ExtrapolateType extrapolateLeft?: ExtrapolateType extrapolateRight?: ExtrapolateType inputRange: ReadonlyArray outputRange: ReadonlyArray - easing?: (input: number) => number } > declare type IOSKeyboardEvent = Readonly< @@ -3096,10 +3096,10 @@ declare type MacOSViewProps = { readonly enableFocusRing?: boolean readonly inverted?: boolean readonly mouseDownCanMoveWindow?: boolean - readonly tooltip?: string readonly onDragEnter?: (event: DragEvent_3) => void readonly onDragLeave?: (event: DragEvent_3) => void readonly onDrop?: (event: DragEvent_3) => void + readonly tooltip?: string } declare type Mapping = | AnimatedValue_default @@ -3498,6 +3498,14 @@ declare type OptionalFlatListProps = { end: number start: number } + getItemLayout?: ( + data: Readonly> | undefined, + index: number, + ) => { + index: number + length: number + offset: number + } horizontal?: boolean initialNumToRender?: number initialScrollIndex?: number @@ -3508,14 +3516,6 @@ declare type OptionalFlatListProps = { removeClippedSubviews?: boolean renderItem?: ListRenderItem strictMode?: boolean - getItemLayout?: ( - data: Readonly> | undefined, - index: number, - ) => { - index: number - length: number - offset: number - } } declare type OptionalPlatformSelectSpec = { [key in PlatformOSType]?: T } declare type OptionalSectionListProps = { @@ -3790,15 +3790,15 @@ declare type PressabilityConfig = { readonly pressRectOffset?: RectOrSize } declare type PressabilityEventHandlers = { - readonly onBlur: (event: BlurEvent) => void - readonly onClick: (event: GestureResponderEvent) => void - readonly onFocus: (event: FocusEvent_2) => void readonly onKeyDown?: (event: KeyDownEvent) => void readonly onKeyUp?: (event: KeyUpEvent) => void readonly onMouseEnter?: (event: MouseEvent_2) => void readonly onMouseLeave?: (event: MouseEvent_2) => void readonly onPointerEnter?: (event: PointerEvent_2) => void readonly onPointerLeave?: (event: PointerEvent_2) => void + readonly onBlur: (event: BlurEvent) => void + readonly onClick: (event: GestureResponderEvent) => void + readonly onFocus: (event: FocusEvent_2) => void readonly onResponderGrant: (event: GestureResponderEvent) => boolean | void readonly onResponderMove: (event: GestureResponderEvent) => void readonly onResponderRelease: (event: GestureResponderEvent) => void @@ -3835,6 +3835,9 @@ declare type PressableBaseProps = { readonly keyUpEvents?: Array readonly mouseDownCanMoveWindow?: boolean readonly onBlur?: (event: BlurEvent) => void + readonly onDragEnter?: (event: DragEvent_3) => void + readonly onDragLeave?: (event: DragEvent_3) => void + readonly onDrop?: (event: DragEvent_3) => void readonly onFocus?: (event: FocusEvent_2) => void readonly onHoverIn?: (event: MouseEvent_2) => unknown readonly onHoverOut?: (event: MouseEvent_2) => unknown @@ -3856,9 +3859,6 @@ declare type PressableBaseProps = { readonly tooltip?: string readonly validKeysDown?: ReadonlyArray readonly validKeysUp?: ReadonlyArray - readonly onDragEnter?: (event: DragEvent_3) => void - readonly onDragLeave?: (event: DragEvent_3) => void - readonly onDrop?: (event: DragEvent_3) => void } declare type PressableInstance = HostInstance declare interface PressableProps @@ -4531,6 +4531,14 @@ declare type ScrollViewBaseProps = { readonly autoscrollToTopThreshold?: number readonly minIndexForVisible: number } + readonly onContentSizeChange?: ( + contentWidth: number, + contentHeight: number, + ) => void + readonly onKeyboardDidHide?: (event: KeyboardEvent_2) => void + readonly onKeyboardDidShow?: (event: KeyboardEvent_2) => void + readonly onKeyboardWillHide?: (event: KeyboardEvent_2) => void + readonly onKeyboardWillShow?: (event: KeyboardEvent_2) => void readonly onMomentumScrollBegin?: (event: ScrollEvent) => void readonly onMomentumScrollEnd?: (event: ScrollEvent) => void readonly onScroll?: (event: ScrollEvent) => void @@ -4551,14 +4559,6 @@ declare type ScrollViewBaseProps = { readonly StickyHeaderComponent?: StickyHeaderComponentType readonly stickyHeaderHiddenOnScroll?: boolean readonly stickyHeaderIndices?: ReadonlyArray - readonly onContentSizeChange?: ( - contentWidth: number, - contentHeight: number, - ) => void - readonly onKeyboardDidHide?: (event: KeyboardEvent_2) => void - readonly onKeyboardDidShow?: (event: KeyboardEvent_2) => void - readonly onKeyboardWillHide?: (event: KeyboardEvent_2) => void - readonly onKeyboardWillShow?: (event: KeyboardEvent_2) => void } declare type ScrollViewComponentStatics = { readonly Context: typeof $$ScrollViewContext @@ -4639,13 +4639,13 @@ declare type ScrollViewPropsIOS = { readonly indicatorStyle?: "black" | "default" | "white" readonly maximumZoomScale?: number readonly minimumZoomScale?: number + readonly onScrollToTop?: (event: ScrollEvent) => void readonly pinchGestureEnabled?: boolean readonly scrollIndicatorInsets?: EdgeInsetsProp readonly scrollsToTop?: boolean readonly scrollToOverflowEnabled?: boolean readonly showsHorizontalScrollIndicator?: boolean readonly zoomScale?: number - readonly onScrollToTop?: (event: ScrollEvent) => void } declare interface ScrollViewScrollToOptions { animated?: boolean @@ -4799,6 +4799,17 @@ declare type ShareOptions = { } declare interface Spec extends TurboModule { readonly blur?: (reactTag: number) => void + readonly focus?: (reactTag: number) => void + readonly getConstantsForViewManager?: ( + viewManagerName: string, + ) => Object | undefined + readonly getDefaultEventTypes?: () => Array + readonly lazilyLoadView?: (name: string) => Object + readonly sendAccessibilityEvent?: ( + reactTag: number, + eventType: number, + ) => void + readonly setLayoutAnimationEnabledExperimental?: (enabled: boolean) => void readonly clearJSResponder: () => void readonly configureNextLayoutAnimation: ( config: Object, @@ -4827,13 +4838,7 @@ declare interface Spec extends TurboModule { height: number, ) => void, ) => void - readonly focus?: (reactTag: number) => void readonly getConstants: () => Object - readonly getConstantsForViewManager?: ( - viewManagerName: string, - ) => Object | undefined - readonly getDefaultEventTypes?: () => Array - readonly lazilyLoadView?: (name: string) => Object readonly manageChildren: ( containerTag: number, moveFromIndices: Array, @@ -4866,16 +4871,11 @@ declare interface Spec extends TurboModule { height: number, ) => void, ) => void - readonly sendAccessibilityEvent?: ( - reactTag: number, - eventType: number, - ) => void readonly setChildren: (containerTag: number, reactTags: Array) => void readonly setJSResponder: ( reactTag: number, blockNativeResponder: boolean, ) => void - readonly setLayoutAnimationEnabledExperimental?: (enabled: boolean) => void readonly updateView: ( reactTag: number, viewName: string, @@ -5418,11 +5418,11 @@ declare type TextInputMacOSProps_2 = { readonly onGrammarCheckChange?: (e: SettingChangeEvent) => unknown readonly onKeyDown?: (e: KeyDownEvent) => unknown readonly onKeyUp?: (e: KeyUpEvent) => unknown + readonly onPaste?: (event: PasteEvent_2) => void readonly onSpellCheckChange?: (e: SettingChangeEvent) => unknown readonly pastedTypes?: PastedTypesType readonly submitKeyEvents?: ReadonlyArray readonly tooltip?: string - readonly onPaste?: (event: PasteEvent_2) => void } declare interface TextInputProps extends Readonly< @@ -5520,6 +5520,7 @@ declare type TimingAnimationConfig = Readonly< AnimationConfig & { delay?: number duration?: number + easing?: (value: number) => number toValue: | AnimatedColor_default | AnimatedInterpolation_default @@ -5531,7 +5532,6 @@ declare type TimingAnimationConfig = Readonly< readonly x: number readonly y: number } - easing?: (value: number) => number } > declare type ToastAndroid = typeof ToastAndroid @@ -5684,12 +5684,12 @@ declare type TouchableWithoutFeedbackPropsIOS = { acceptsFirstMouse?: boolean draggedTypes?: DraggedTypesType enableFocusRing?: boolean - tooltip?: string onDragEnter?: (event: DragEvent_3) => void onDragLeave?: (event: DragEvent_3) => void onDrop?: (event: DragEvent_3) => void onMouseEnter?: (event: MouseEvent_2) => void onMouseLeave?: (event: MouseEvent_2) => void + tooltip?: string } declare type TouchEventProps = { readonly onTouchCancel?: (e: GestureResponderEvent) => void @@ -5987,22 +5987,22 @@ export { AccessibilityActionEvent, // a0d4daa0 AccessibilityActionInfo, // db47a917 AccessibilityInfo, // 489ed53e - AccessibilityProps, // afa87704 + AccessibilityProps, // 9f493a69 AccessibilityRole, // 3cf8c6c5 AccessibilityState, // b0c2b3f7 AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // c5864b68 + ActivityIndicator, // 01c581b6 ActivityIndicatorInstance, // a82dd4e7 - ActivityIndicatorProps, // dd6f004c - Alert, // 24958ab5 + ActivityIndicatorProps, // 07ba31c5 + Alert, // 8968797d AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 - AlertOptions, // 39b16cfa + AlertOptions, // 702d616d AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // e7241a74 + Animated, // bc2c3899 AppConfig, // e4e0157b AppRegistry, // b1f0909a AppState, // 12012be5 @@ -6016,9 +6016,9 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // 4ba4f941 BoxShadowValue, // b679703f - Button, // 8e70b924 - ButtonInstance, // 5983c48f - ButtonProps, // 69a911f2 + Button, // f9e54915 + ButtonInstance, // 516c48f2 + ButtonProps, // d0ebae51 Clipboard, // 41addb89 CodegenTypes, // ab4986cc ColorSchemeName, // 6615edd6 @@ -6038,9 +6038,9 @@ export { DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb DragEvent_2 as DragEvent, // e161690c - DrawerLayoutAndroid, // 80ee3edb + DrawerLayoutAndroid, // 15c5cf79 DrawerLayoutAndroidInstance, // c0694352 - DrawerLayoutAndroidProps, // 5884eb5e + DrawerLayoutAndroidProps, // ae1d2b83 DrawerSlideEvent, // c4ab8fba DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -6058,39 +6058,39 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // df6431a9 - FlatListInstance, // e06eece7 - FlatListProps, // fb158bc8 + FlatList, // b6b74467 + FlatListInstance, // d71d4c12 + FlatListProps, // 1e70bc52 FocusEvent_2 as FocusEvent, // 30b0dd35 FontVariant, // 7c7558bb GestureResponderEvent, // 3bd4697e - GestureResponderHandlers, // 3f4b3d6f + GestureResponderHandlers, // a4627cef HostComponent, // 74c10001 HostInstance, // f78dcaf8 I18nManager, // f9870e00 IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // 2a05118e - ImageBackground, // 43405e02 - ImageBackgroundInstance, // 4b6f798d - ImageBackgroundProps, // fdf47df6 + Image, // b95813c0 + ImageBackground, // 3e7207b2 + ImageBackgroundInstance, // e529f671 + ImageBackgroundProps, // 4451775e ImageErrorEvent, // 978933f4 ImageInstance, // 9a100753 ImageLoadEvent, // 77f0b718 ImageProgressEventIOS, // 445331a4 - ImageProps, // cea58952 - ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // 13761fda - ImagePropsIOS, // 4a080668 + ImageProps, // 579bd2be + ImagePropsAndroid, // ee00e1d5 + ImagePropsBase, // c84d4a43 + ImagePropsIOS, // 9e19c85d ImageRequireSource, // 681d683b ImageResizeMode, // d51106e2 ImageResolvedAssetSource, // f3060931 ImageSize, // 1c47cf88 - ImageSource, // 48c7f316 - ImageSourcePropType, // bfb5e5c6 + ImageSource, // ea31cf4a + ImageSourcePropType, // f522e093 ImageStyle, // ccf7055c - ImageURISource, // 016eb083 + ImageURISource, // 443d047c InputAccessoryView, // 29eefece InputAccessoryViewProps, // 2aa4ba24 InputModeOptions, // 4e8581b9 @@ -6099,9 +6099,9 @@ export { KeyEvent, // 2a18a436 KeyUpEvent, // ef64af4c Keyboard, // 12be6986 - KeyboardAvoidingView, // 84c12855 - KeyboardAvoidingViewInstance, // 5fcd1820 - KeyboardAvoidingViewProps, // a5e139b7 + KeyboardAvoidingView, // 05fed9b6 + KeyboardAvoidingViewInstance, // d72b03f8 + KeyboardAvoidingViewProps, // fc8095fc KeyboardEvent_2 as KeyboardEvent, // da6563cf KeyboardEventEasing, // af4091c8 KeyboardEventName, // 5564dd77 @@ -6126,12 +6126,12 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // eee9076c - ModalBaseProps, // 0de93395 + Modal, // f13bc1b4 + ModalBaseProps, // 0590b6e9 ModalInstance, // d466ce77 - ModalProps, // e855ab04 + ModalProps, // c215d9be ModalPropsAndroid, // 515fb173 - ModalPropsIOS, // 0e13cfcc + ModalPropsIOS, // 664ecb7e ModeChangeEvent, // f64bf69d MouseEvent_2 as MouseEvent, // 8bc148bf NativeAppEventEmitter, // 08d4c47d @@ -6150,10 +6150,10 @@ export { Networking, // bbc5be42 OpaqueColorValue, // 25f3fa5b PackagerAsset, // d1c88cf4 - PanResponder, // 17de9d78 - PanResponderCallbacks, // 4ee3d101 + PanResponder, // 7a455cf3 + PanResponderCallbacks, // 47ced9e9 PanResponderGestureState, // 54baf558 - PanResponderInstance, // b3afacbe + PanResponderInstance, // 90afdf2e PasteEvent, // c030c607 Permission, // 08f1c82f PermissionStatus, // 4b7de97b @@ -6165,17 +6165,17 @@ export { PlatformSelectSpec, // 09ed7758 PointValue, // 69db075f PointerEvent_2 as PointerEvent, // f47675d8 - PressabilityConfig, // 8df202e3 - PressabilityEventHandlers, // e7987b00 - Pressable, // 1ba7d4c7 + PressabilityConfig, // 73a23e9b + PressabilityEventHandlers, // c32b063c + Pressable, // b85e2d3a PressableAndroidRippleConfig, // ee32eaca PressableInstance, // eebfe911 - PressableProps, // 6f3e9ea7 + PressableProps, // 660a956d PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // debc6d5f + ProgressBarAndroid, // 48ba17d4 ProgressBarAndroidInstance, // ab545ef1 - ProgressBarAndroidProps, // eba62243 + ProgressBarAndroidProps, // b57747f8 PublicRootInstance, // 8040afd7 PublicTextInstance, // 6937c7bf PushNotificationEventName, // 84e7e150 @@ -6183,10 +6183,10 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 83571873 - RefreshControlInstance, // 92a44a91 - RefreshControlProps, // 28551311 - RefreshControlPropsAndroid, // 99f64c97 + RefreshControl, // 14791bbc + RefreshControlInstance, // 7b174fa4 + RefreshControlProps, // db5daf37 + RefreshControlPropsAndroid, // 8ac931ca RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d ResponderSyntheticEvent, // ebfa7f48 @@ -6197,24 +6197,24 @@ export { RootViewStyleProvider, // 6a15dcfc Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 1d65b30b + SafeAreaView, // 4db3c6d4 SafeAreaViewInstance, // 21dba39c ScaledSize, // 07e417c7 ScrollEvent, // 10f01ee3 - ScrollResponderType, // 51719b64 + ScrollResponderType, // d9133bdb ScrollToLocationParamsType, - ScrollView, // 8d4a580b - ScrollViewImperativeMethods, // efff3f67 - ScrollViewInstance, // 7421bd9f - ScrollViewProps, // a23b9a17 - ScrollViewPropsAndroid, // 44210553 - ScrollViewPropsIOS, // 19a1147f + ScrollView, // 0d767930 + ScrollViewImperativeMethods, // 288bc3d3 + ScrollViewInstance, // 970f388f + ScrollViewProps, // 0b7e6b60 + ScrollViewPropsAndroid, // 02f3df2e + ScrollViewPropsIOS, // aa75a790 ScrollViewScrollToOptions, // 3313411e SectionBase, - SectionList, // d8ed4ae2 + SectionList, // 7ae677d7 SectionListData, - SectionListInstance, // f0d79d5d - SectionListProps, // a06f752e + SectionListInstance, // a784ecfc + SectionListProps, // be0241cb SectionListRenderItem, // de6c0c34 SectionListRenderItemInfo, // bd038703 Separators, @@ -6225,67 +6225,67 @@ export { ShareActionSheetIOSOptions, // eff574f5 ShareContent, // 7c627896 ShareOptions, // 800c3a4e - StatusBar, // abc3fd90 + StatusBar, // c1aafc4e StatusBarAnimation, // 7fd047e6 - StatusBarInstance, // 31445769 - StatusBarProps, // bfedeab1 + StatusBarInstance, // ac2ee2c6 + StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // 3145ad8d + StyleSheet, // d8d99bbd SubmitBehavior, // c4ddf490 - Switch, // 7b1086fa + Switch, // d22e6b0f SwitchChangeEvent, // 899635b1 SwitchInstance, // 3c50eec5 - SwitchProps, // 64074d79 + SwitchProps, // 19335708 SystemEffectMacOS, // d026159b Systrace, // 626d178c TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text_2 as Text, // af6f8150 + Text_2 as Text, // 327ffb24 TextContentType, // 239b3ecc - TextInput, // 3ad5d822 - TextInputAndroidProps, // 7109938a + TextInput, // 2bf7e5e5 + TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b77af40e TextInputChangeEvent, // f55eef98 TextInputContentSizeChangeEvent, // a27cd32a TextInputEndEditingEvent, // e690b56b TextInputFocusEvent, // 51668a1e - TextInputIOSProps, // e8905f3e + TextInputIOSProps, // 0b56e3e3 TextInputInstance, // 9d5cf2e6 TextInputKeyPressEvent, // 546c5d07 - TextInputMacOSProps, // 4a7a4b02 - TextInputProps, // 2bf4840f + TextInputMacOSProps, // e1585d56 + TextInputProps, // 8356fd8c TextInputSelectionChangeEvent, // e58f2abc TextInputSubmitEditingEvent, // 6bcb2aa5 TextInstance, // 05463a96 TextLayoutEvent, // 3f54186f - TextProps, // 96b3535c + TextProps, // 646ae33e TextStyle, // 694c5ffb ToastAndroid, // 88a8969a - TouchableHighlight, // d2d261ca + TouchableHighlight, // bc72f77a TouchableHighlightInstance, // b510c0eb - TouchableHighlightProps, // 9abae613 - TouchableNativeFeedback, // 205c3d42 - TouchableNativeFeedbackInstance, // 284d8dc6 - TouchableNativeFeedbackProps, // e2bb1c8e - TouchableOpacity, // 13b593b5 + TouchableHighlightProps, // 631c5cec + TouchableNativeFeedback, // 406004d7 + TouchableNativeFeedbackInstance, // 1319896f + TouchableNativeFeedbackProps, // 0b603dd4 + TouchableOpacity, // 54ca206c TouchableOpacityInstance, // b186055b - TouchableOpacityProps, // 69b78d84 - TouchableWithoutFeedback, // 5647bbf2 - TouchableWithoutFeedbackProps, // b36f07bb + TouchableOpacityProps, // 8dd85d25 + TouchableWithoutFeedback, // 8234d9b8 + TouchableWithoutFeedbackProps, // 0c126762 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 - UIManager, // a1a7cc01 + UIManager, // afbcdf05 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 467d00f2 + View, // a0da551d ViewInstance, // ffde5573 - ViewProps, // 2a62eb65 - ViewPropsAndroid, // ca64ec97 + ViewProps, // e5a9d4c1 + ViewPropsAndroid, // 6d1dbdcd ViewPropsIOS, // 58ee19bf - ViewPropsMacOS, // a39358a0 + ViewPropsMacOS, // 9f0f0ce7 ViewStyle, // 2fc81116 VirtualViewMode, // 6be59722 VirtualizedList, // d4e15eaa @@ -6301,10 +6301,10 @@ export { processColor, // 6e877698 registerCallableModule, // 839c8cfe requireNativeComponent, // f133b4a3 - useAnimatedColor, // e3511f81 - useAnimatedValue, // b18adb63 - useAnimatedValueXY, // c7ee2332 + useAnimatedColor, // a9dffd9f + useAnimatedValue, // 25733d13 + useAnimatedValueXY, // 04c70878 useColorScheme, // d585efdb - usePressability, // a33fa4bf + usePressability, // 60418f41 useWindowDimensions, // bb4b683f } diff --git a/packages/react-native/package.json b/packages/react-native/package.json index fe3db74bffb0..5ddc8b9ca9a7 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -43,7 +43,10 @@ "types": null, "default": "./Libraries/*.js" }, - "./scripts/*": "./scripts/*", + "./scripts/*": "./scripts/*.js", + "./scripts/codegen/generate-artifacts-executor": "./scripts/codegen/generate-artifacts-executor/index.js", + "./scripts/*.sh": "./scripts/*.sh", + "./scripts/*.rb": "./scripts/*.rb", "./asset-registry": { "types": null, "default": "./src/asset-registry.js" diff --git a/packages/react-native/scripts/__tests__/replace-rncore-version-test.js b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js new file mode 100644 index 000000000000..f435e2fa1a9d --- /dev/null +++ b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js @@ -0,0 +1,141 @@ +/** + * 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. + * + * @format + * @noflow + */ + +'use strict'; + +const {replaceRNCoreConfiguration} = require('../replace-rncore-version'); +const {execFileSync} = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const VERSION = '0.87.0-test'; +const SLICE = 'ios-arm64_x86_64-simulator'; +const BINARY = path.join(SLICE, 'React.framework', 'React'); + +function writeFile(filePath, contents) { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, contents); +} + +function buildTarball(podsRoot, configuration) { + const stage = fs.mkdtempSync(path.join(podsRoot, `stage-${configuration}-`)); + writeFile(path.join(stage, 'React.xcframework', 'Info.plist'), ''); + writeFile( + path.join(stage, 'React.xcframework', BINARY), + `binary-${configuration}`, + ); + // The tarball must also ship ReactNativeHeaders: without it the pre-fix code + // throws its fail-closed error before reaching the module map, so the + // regression test below would pass for the wrong reason. + writeFile( + path.join( + stage, + 'ReactNativeHeaders.xcframework', + SLICE, + 'Headers', + 'module.modulemap', + ), + 'module yoga {}\n', + ); + const artifacts = path.join(podsRoot, 'ReactNativeCore-artifacts'); + fs.mkdirSync(artifacts, {recursive: true}); + execFileSync('tar', [ + '-czf', + path.join( + artifacts, + `reactnative-core-${VERSION.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`, + ), + '-C', + stage, + '.', + ]); + fs.rmSync(stage, {recursive: true, force: true}); +} + +describe('replaceRNCoreConfiguration', () => { + let podsRoot; + let pod; + let cwd; + + beforeEach(() => { + podsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rncore-test-')); + pod = path.join(podsRoot, 'React-Core-prebuilt'); + // What the podspec prepare_command leaves behind after `pod install`. + writeFile( + path.join(pod, 'Headers', 'module.modulemap'), + 'module yoga {}\n', + ); + writeFile(path.join(pod, 'React.xcframework', 'Info.plist'), ''); + writeFile(path.join(pod, 'React.xcframework', BINARY), 'binary-Debug'); + buildTarball(podsRoot, 'Release'); + cwd = process.cwd(); + // The script phase runs with Pods/ as its working directory. + process.chdir(podsRoot); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(podsRoot, {recursive: true, force: true}); + }); + + it('installs the framework for the requested configuration', () => { + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect( + fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'), + ).toBe('binary-Release'); + }); + + // Regression test for #57803: recreating the module map mid-build lets a + // concurrent dependency scan miss it, and the React module then precompiles + // without -fmodule-map-file and fails on non-modular includes. + it('leaves Headers/module.modulemap untouched', () => { + const moduleMap = path.join(pod, 'Headers', 'module.modulemap'); + const before = fs.statSync(moduleMap).ino; + const contentsBefore = fs.readFileSync(moduleMap, 'utf8'); + + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect(fs.statSync(moduleMap).ino).toBe(before); + expect(fs.readFileSync(moduleMap, 'utf8')).toBe(contentsBefore); + }); + + // The swap used to purge every directory and restore this file by hand. It + // now replaces React.xcframework alone, so the file is never disturbed. + it('leaves an Expo-generated React-use-frameworks.modulemap in place', () => { + const expoModuleMap = path.join(pod, 'React-use-frameworks.modulemap'); + writeFile(expoModuleMap, 'module React {}\n'); + + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + + expect(fs.readFileSync(expoModuleMap, 'utf8')).toBe('module React {}\n'); + }); + + it('fails when the tarball has no React.xcframework', () => { + const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-')); + writeFile(path.join(stage, 'unrelated.txt'), 'nope'); + execFileSync('tar', [ + '-czf', + path.join( + podsRoot, + 'ReactNativeCore-artifacts', + `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`, + ), + '-C', + stage, + '.', + ]); + + expect(() => + replaceRNCoreConfiguration('Release', VERSION, podsRoot), + ).toThrow(/Extraction verification failed/); + }); +}); diff --git a/packages/react-native/scripts/__tests__/script-exports-test.js b/packages/react-native/scripts/__tests__/script-exports-test.js new file mode 100644 index 000000000000..073f4d77f388 --- /dev/null +++ b/packages/react-native/scripts/__tests__/script-exports-test.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +const {execFileSync} = require('child_process'); +const path = require('path'); + +test('Node resolves extensionless script exports, including the fork codegen directory', () => { + // Test native Node package conditions rather than the Jest resolver's map. + execFileSync(process.execPath, [ + '-e', + ` + const assert = require('assert/strict'); + const req = require('module').createRequire(${JSON.stringify(path.resolve(__dirname, '../../package.json'))}); + for (const [name, suffix] of [ + ['setup-apple-spm', '/setup-apple-spm.js'], + ['codegen/generate-artifacts-executor', '/codegen/generate-artifacts-executor/index.js'], + ['react-native-xcode.sh', '/react-native-xcode.sh'], + ['react_native_pods.rb', '/react_native_pods.rb'], + ]) { + assert(req.resolve('react-native-macos/scripts/' + name).endsWith(suffix)); + } + assert.throws(() => req.resolve('react-native-macos/scripts/setup-apple-spm.js')); + `, + ]); +}); diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 00b9327871e4..d8586b10f9cf 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -568,6 +568,7 @@ def self.add_prebuilt_header_search_paths(attributes, headers_search_path) # Quoted so a $(PODS_ROOT) containing spaces stays a single clang argument. module_map_flag = " \"-fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap\"" ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", module_map_flag) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", module_map_flag) ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc" + module_map_flag) end end diff --git a/packages/react-native/scripts/ios-prebuild.js b/packages/react-native/scripts/ios-prebuild.js index 1109b99df299..7079821cc2e9 100644 --- a/packages/react-native/scripts/ios-prebuild.js +++ b/packages/react-native/scripts/ios-prebuild.js @@ -77,6 +77,7 @@ async function main() { frameworkPaths, buildType, cli.identity, + cli.requireHermes, ); } diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js index 80ff6d3253b6..27cc84e54b11 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -13,6 +13,7 @@ const { COMPOSE_TOOLING_FILES, composeToolingHash, + emitReactFrameworkHeaders, } = require('../headers-compose'); const fs = require('fs'); const path = require('path'); @@ -40,6 +41,69 @@ describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', }); }); +test('only the version header uses the built overlay on iOS and macOS slices', () => { + const os = require('os'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stamped-headers-')); + const rnRoot = path.join(root, 'source'); + const overlay = path.join(root, 'built'); + const xcfw = path.join(root, 'React.xcframework'); + const versionSource = 'React/Base/ReactNativeVersion.h'; + const tracingSource = + 'ReactCommon/jsinspector-modern/tracing/TraceRecordingState.h'; + const sentinel = '#define REACT_NATIVE_VERSION_MAJOR 1000\n'; + const stamped = + '#define REACT_NATIVE_VERSION_MAJOR 0\n#define REACT_NATIVE_VERSION_MINOR 87\n'; + for (const [dir, name, text] of [ + [rnRoot, versionSource, sentinel], + [overlay, versionSource, stamped], + [rnRoot, tracingSource, '// current move-only tracing definition\n'], + [overlay, tracingSource, '// stale copyable tracing definition\n'], + ]) { + fs.mkdirSync(path.dirname(path.join(dir, name)), {recursive: true}); + fs.writeFileSync(path.join(dir, name), text); + } + const slices = ['ios-arm64', 'macos-arm64_x86_64']; + for (const slice of slices) { + fs.mkdirSync(path.join(xcfw, slice, 'React.framework'), {recursive: true}); + } + const plan = { + react: [ + {relPath: 'ReactNativeVersion.h', source: versionSource}, + {relPath: 'TraceRecordingState.h', source: tracingSource}, + ], + umbrella: [], + privateReactHeaders: {modular: [], textual: []}, + }; + try { + emitReactFrameworkHeaders(xcfw, plan, rnRoot, overlay); + for (const slice of slices) { + const headers = path.join(xcfw, slice, 'React.framework', 'Headers'); + expect( + fs.readFileSync(path.join(headers, 'ReactNativeVersion.h'), 'utf8'), + ).toBe(stamped); + expect( + fs.readFileSync(path.join(headers, 'TraceRecordingState.h'), 'utf8'), + ).toBe('// current move-only tracing definition\n'); + } + expect(fs.readFileSync(path.join(rnRoot, versionSource), 'utf8')).toBe( + sentinel, + ); + emitReactFrameworkHeaders(xcfw, plan, rnRoot); + expect( + fs.readFileSync( + path.join( + xcfw, + slices[0], + 'React.framework/Headers/ReactNativeVersion.h', + ), + 'utf8', + ), + ).toBe(sentinel); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +}); + describe('composeToolingHash', () => { test('returns a 64-char hex sha256 digest', () => { const hash = composeToolingHash(); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js new file mode 100644 index 000000000000..210c41f2e40c --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js @@ -0,0 +1,70 @@ +/** + * 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 strict-local + * @format + */ + +'use strict'; + +const {resolveHermesHeaders} = require('../xcframework'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +describe('resolveHermesHeaders', () => { + let tmp /*: string */ = ''; + let buildFolder /*: string */ = ''; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xcframework-test-')); + buildFolder = path.join(tmp, '.build'); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + test('returns the base include directory when hermes/hermes.h exists', () => { + const includeDir = path.join( + buildFolder, + 'artifacts', + 'hermes', + 'destroot', + 'include', + ); + fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true}); + fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), ''); + + expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir); + }); + + test('finds a non-standard nested include directory', () => { + const includeDir = path.join( + buildFolder, + 'artifacts', + 'hermes', + 'nested', + 'archive', + 'destroot', + 'include', + ); + fs.mkdirSync(path.join(includeDir, 'hermes'), {recursive: true}); + fs.writeFileSync(path.join(includeDir, 'hermes', 'hermes.h'), ''); + + expect(resolveHermesHeaders(buildFolder, true)).toBe(includeDir); + }); + + test('returns null when Hermes headers are absent and not required', () => { + expect(resolveHermesHeaders(buildFolder, false)).toBeNull(); + }); + + test('throws when Hermes headers are absent and required', () => { + expect(() => resolveHermesHeaders(buildFolder, true)).toThrow( + /ReactNativeHeaders[\s\S]*[\s\S]*destroot\/include/, + ); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/cli.js b/packages/react-native/scripts/ios-prebuild/cli.js index ebb3ba1dfc65..03125f39d52e 100644 --- a/packages/react-native/scripts/ios-prebuild/cli.js +++ b/packages/react-native/scripts/ios-prebuild/cli.js @@ -72,6 +72,11 @@ const cli = yargs describe: 'Specify the code signing identity to use for signing the frameworks.', }) + .option('require-hermes', { + type: 'boolean', + describe: + 'Require Hermes headers when composing the ReactNativeHeaders XCFramework.', + }) .help(); /** @@ -86,6 +91,7 @@ async function getCLIConfiguration() /*: Promise, identity: ?string, + requireHermes: boolean, |}> */ { // Run input parsing const argv = await cli.argv; @@ -131,6 +137,7 @@ async function getCLIConfiguration() /*: Promise */, rnRoot /*: string */, + overlayDir /*: ?string */ = null, ) /*: void */ { for (const e of entries) { const dest = path.join(stage, e.relPath); @@ -130,7 +131,24 @@ function stageEntries( `#import <${e.redirectTo}>\n`, ); } else { - fs.copyFileSync(path.join(rnRoot, e.source), dest); + // ReactNativeVersion.h is the one shipped header STAMPED at build time: + // the slice job runs set-rn-artifacts-version before building, while the + // source tree keeps the 1000.0.0 dev sentinel. Take just this file's + // content from the built header tree (`overlayDir`, i.e. `.build/headers`) + // when present, so the compose ships the real version without re-stamping + // its own checkout. Every OTHER header is authoritative in the source + // tree (only the layout is spec-derived), so it always copies from + // source — never from a build tree that could be stale relative to it. + const isStamped = path.basename(e.source) === 'ReactNativeVersion.h'; + const overlaySource = + isStamped && overlayDir != null + ? path.join(overlayDir, e.source) + : null; + const src = + overlaySource != null && fs.existsSync(overlaySource) + ? overlaySource + : path.join(rnRoot, e.source); + fs.copyFileSync(src, dest); } } } @@ -146,11 +164,12 @@ function emitReactFrameworkHeaders( xcfwPath /*: string */, plan /*: HeadersSpecPlan */, rnRoot /*: string */, + overlayDir /*: ?string */ = null, ) /*: void */ { const stage = fs.mkdtempSync( path.join(path.dirname(xcfwPath), '.react-stage-'), ); - stageEntries(stage, plan.react, rnRoot); + stageEntries(stage, plan.react, rnRoot, overlayDir); fs.writeFileSync( path.join(stage, 'React-umbrella.h'), renderUmbrellaHeader(plan.umbrella), @@ -248,10 +267,11 @@ function buildReactNativeHeadersXcframework( hermesHeaders /*: ?string */ = null, // [macOS] Derive sidecar platforms from the binary whenever one is available. binaryXcfw /*: ?string */ = null, + overlayDir /*: ?string */ = null, ) /*: string */ { // ---- stage headers ---- const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); - stageEntries(stage, plan.reactNativeHeaders, rnRoot); + stageEntries(stage, plan.reactNativeHeaders, rnRoot, overlayDir); // 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 diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index 79bebc800348..30a9ce778954 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -35,6 +35,7 @@ * Usage: * node scripts/ios-prebuild/headers-verify.js [--flavor Debug|Release] * [--artifacts ] [--skip-compile] [--update-baseline] + * [--require-stamped-version] */ const {computeInventory} = require('./headers-inventory'); @@ -410,6 +411,49 @@ function runCompileGates( } } +// --------------------------------------------------------------------------- +// Version stamp gate +// --------------------------------------------------------------------------- + +/** + * Release/nightly artifacts must not ship ReactNativeVersion.h with the + * 1000.0.0 dev sentinel: the compose step copies headers from the source + * tree, so a compose job that forgot to run set-rn-artifacts-version.js + * would silently publish a sentinel header, breaking every library that + * gates code on REACT_NATIVE_VERSION_MAJOR/MINOR. + */ +function verifyVersionStamp(artifactsDir /*: string */) /*: void */ { + const copies = []; + const walk = (dir /*: string */) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const name = String(entry.name); + const full = path.join(dir, name); + if (entry.isDirectory()) { + walk(full); + } else if (name === 'ReactNativeVersion.h') { + copies.push(full); + } + } + }; + walk(artifactsDir); + if (copies.length === 0) { + throw new Error( + `no ReactNativeVersion.h found under ${artifactsDir} — cannot verify the version stamp.`, + ); + } + const unstamped = copies.filter(f => + /REACT_NATIVE_VERSION_MAJOR\s+1000\b/.test(fs.readFileSync(f, 'utf8')), + ); + if (unstamped.length > 0) { + throw new Error( + `ReactNativeVersion.h still contains the 1000.0.0 dev sentinel — run ` + + `scripts/releases/set-rn-artifacts-version.js before composing:\n ` + + unstamped.join('\n '), + ); + } + log(`version stamp OK (${copies.length} copies checked).`); +} + // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- @@ -419,11 +463,13 @@ function parseArgs(argv /*: Array */) /*: { artifacts: ?string, skipCompile: boolean, updateBaseline: boolean, + requireStampedVersion: boolean, } */ { let flavor = 'Debug'; let artifacts /*: ?string */ = null; let skipCompile = false; let updateBaseline = false; + let requireStampedVersion = false; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--flavor') { flavor = argv[++i]; @@ -433,9 +479,17 @@ function parseArgs(argv /*: Array */) /*: { skipCompile = true; } else if (argv[i] === '--update-baseline') { updateBaseline = true; + } else if (argv[i] === '--require-stamped-version') { + requireStampedVersion = true; } } - return {flavor, artifacts, skipCompile, updateBaseline}; + return { + flavor, + artifacts, + skipCompile, + updateBaseline, + requireStampedVersion, + }; } function main(argv /*:: ?: Array */) /*: void */ { @@ -460,6 +514,10 @@ function main(argv /*:: ?: Array */) /*: void */ { `(node scripts/ios-prebuild -c -f ${args.flavor}).`, ); } + if (args.requireStampedVersion) { + verifyVersionStamp(artifactsDir); + } + const {reactSlice, rnhHeaders} = verifyStructural(plan, artifactsDir); if (args.skipCompile) { diff --git a/packages/react-native/scripts/ios-prebuild/utils.js b/packages/react-native/scripts/ios-prebuild/utils.js index d1c60f838b52..99a98c65e34b 100644 --- a/packages/react-native/scripts/ios-prebuild/utils.js +++ b/packages/react-native/scripts/ios-prebuild/utils.js @@ -12,6 +12,7 @@ const {execSync} = require('child_process'); const fs = require('fs'); +const path = require('path'); /** * Creates a folder if it does not exist @@ -28,6 +29,31 @@ function createFolderIfNotExists(folderPath /*:string*/) /*: string */ { return folderPath; } +function findFirst( + dir /*: string */, + predicate /*: (name: string) => boolean */, + depth /*: number */, +) /*: string | null */ { + if (depth <= 0 || !fs.existsSync(dir)) { + return null; + } + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + const full /*: string */ = path.join(dir, entry.name); + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + if (predicate(entry.name)) { + return full; + } + if (entry.isDirectory()) { + const hit = findFirst(full, predicate, depth - 1); + if (hit != null) { + return hit; + } + } + } + return null; +} + function throwIfOnEden() { try { execSync('eden info', {stdio: 'ignore'}); @@ -104,6 +130,7 @@ async function computeNightlyTarballURL( module.exports = { createFolderIfNotExists, + findFirst, throwIfOnEden, createLogger, computeNightlyTarballURL, diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 1dcb2408a99f..9ebf1037fc23 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -19,16 +19,45 @@ const fs = require('fs'); const path = require('path'); const {execFileSync} = childProcess; // [macOS] -const {createLogger} = utils; +const {createLogger, findFirst} = utils; const frameworkLog = createLogger('XCFramework'); +function resolveHermesHeaders( + buildFolder /*: string */, + required /*: boolean */, +) /*: ?string */ { + const hermesArtifacts = path.join(buildFolder, 'artifacts', 'hermes'); + const baseInclude = path.join(hermesArtifacts, 'destroot', 'include'); + if (fs.existsSync(path.join(baseInclude, 'hermes', 'hermes.h'))) { + return baseInclude; + } + + const includeDir = findFirst(hermesArtifacts, name => name === 'include', 8); + if ( + includeDir != null && + fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h')) + ) { + return includeDir; + } + + if (required) { + throw new Error( + 'Cannot compose ReactNativeHeaders: will not resolve because ' + + "hermes/hermes.h is missing. Stage the hermes-ios tarball's " + + 'destroot/include into .build/artifacts/hermes before composing.', + ); + } + return null; +} + function buildXCFrameworks( rootFolder /*: string */, buildFolder /*: string */, frameworkFolders /*: Array */, buildType /*: BuildFlavor */, identity /*: ?string */, + requireHermes /*: boolean */ = false, ) { // Let's run codegen for FBReactNativeSpec otherwise some headers will be missing generateFBReactNativeSpecIOS('.'); @@ -89,7 +118,14 @@ function buildXCFrameworks( emitReactFrameworkHeaders, } = require('./headers-compose'); const plan = computeSpecPlan(rootFolder); - emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // Built header tree from the slice jobs (downloaded to `.build/headers`). + // When present, the compose sources header CONTENT from here (see + // stageEntries) so build-time generated/stamped headers — notably the + // version-stamped ReactNativeVersion.h — ship without re-stamping this + // checkout. Absent (e.g. a local compose with no prior build) → source tree. + const builtHeadersDir = path.join(buildFolder, 'headers'); + const overlayDir = fs.existsSync(builtHeadersDir) ? builtHeadersDir : null; + emitReactFrameworkHeaders(outputPath, plan, rootFolder, overlayDir); // ReactNativeHeaders is PURE-RN — the third-party deps namespaces ship in // the ReactNativeDependenciesHeaders sidecar built by the deps prebuild // (scripts/releases/ios-prebuild), so the core compose no longer needs the @@ -98,19 +134,9 @@ function buildXCFrameworks( // ReactNativeHeaders so consumers resolve `` out of the box // (same fold ensureHeadersLayout does consumer-side). The hermes-ios tarball // is staged at .build/artifacts/hermes/destroot/include by the hermes prebuild - // step; pass it when its `hermes/` namespace is present, else null (then - // `` stays consumer-composed, as before). - const hermesInclude = path.resolve( - process.cwd(), - '.build', - 'artifacts', - 'hermes', - 'destroot', - 'include', - ); - const hermesHeaders = fs.existsSync(path.join(hermesInclude, 'hermes')) - ? hermesInclude - : null; + // step; pass it when its `hermes/` namespace is present, else null for local + // development. CI release composition requires it and fails closed. + const hermesHeaders = resolveHermesHeaders(buildFolder, requireHermes); const headersXcfw = buildReactNativeHeadersXcframework( path.dirname(outputPath), plan, @@ -118,6 +144,7 @@ function buildXCFrameworks( true, // include the mac-catalyst slice in the real compose hermesHeaders, outputPath, // [macOS] Match the binary, including its macOS slice. + overlayDir, ); if (identity) { @@ -280,4 +307,5 @@ function signXCFramework( module.exports = { buildXCFrameworks, + resolveHermesHeaders, }; diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index b3d960890dfe..d0810efee5a4 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -59,7 +59,7 @@ function replaceRNCoreConfiguration( configuration /*: string */, version /*: string */, podsRoot /*: string */, -) { +) /*: void */ { // Filename comes from rncore.rb const tarballURLPath = `${podsRoot}/ReactNativeCore-artifacts/reactnative-core-${version.toLowerCase()}-${configuration.toLowerCase()}.tar.gz`; @@ -73,18 +73,6 @@ function replaceRNCoreConfiguration( const tmpExtractDir = path.join(tmpDir, 'React-Core-prebuilt'); fs.mkdirSync(tmpExtractDir, {recursive: true}); - // Preserve Expo-generated modulemap before replacing directories - const useFrameworksModulemapName = 'React-use-frameworks.modulemap'; - const useFrameworksModulemapPath = path.join( - finalLocation, - useFrameworksModulemapName, - ); - let savedModulemap = null; - if (fs.existsSync(useFrameworksModulemapPath)) { - console.log('Preserving', useFrameworksModulemapName); - savedModulemap = fs.readFileSync(useFrameworksModulemapPath); - } - try { console.log('Extracting the tarball to temp dir', tarballURLPath); const result = spawnSync( @@ -110,98 +98,30 @@ function replaceRNCoreConfiguration( ); } - // 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()); - for (const dirent of dirs) { - const direntName = - typeof dirent.name === 'string' ? dirent.name : dirent.name.toString(); - const dirPath = `${finalLocation}/${direntName}`; - console.log('Removing directory', dirPath); - fs.rmSync(dirPath, {force: true, recursive: true}); - } - - // Move extracted directories from temp to final location - const extractedEntries = fs - .readdirSync(tmpExtractDir, {withFileTypes: true}) - .filter(dirent => dirent.isDirectory()); - for (const dirent of extractedEntries) { - const direntName = - typeof dirent.name === 'string' ? dirent.name : dirent.name.toString(); - const src = path.join(tmpExtractDir, direntName); - const dst = path.join(finalLocation, direntName); - const mvResult = spawnSync('mv', [src, dst], {stdio: 'inherit'}); - if (mvResult.status !== 0) { - // Fallback: copy recursively then remove source - console.log(`mv failed for ${direntName}, falling back to cp -R`); - const cpResult = spawnSync('cp', ['-R', src, dst], { - stdio: 'inherit', - }); - if (cpResult.status !== 0) { - throw new Error( - `cp fallback failed with exit code ${cpResult.status}`, - ); - } + // Replace only the compiled framework. Headers/ is flattened from + // ReactNativeHeaders by the podspec prepare_command, and the prebuild + // compose job emits one set of those headers for both configurations, so a + // config switch leaves them identical. Leaving them alone keeps + // Headers/module.modulemap — which consumers activate through + // -fmodule-map-file — in place for the whole build; deleting and recreating + // it mid-build lets a concurrent dependency scan miss it, and the React + // module then precompiles without it (#57803). + const dest = path.join(finalLocation, 'React.xcframework'); + console.log('Replacing', dest); + fs.rmSync(dest, {force: true, recursive: true}); + const mvResult = spawnSync('mv', [xcfwPath, dest], {stdio: 'inherit'}); + if (mvResult.status !== 0) { + // Fallback: copy recursively then remove source + console.log('mv failed for React.xcframework, falling back to cp -R'); + const cpResult = spawnSync('cp', ['-R', xcfwPath, dest], { + stdio: 'inherit', + }); + if (cpResult.status !== 0) { + throw new Error(`cp fallback failed with exit code ${cpResult.status}`); } } - - // 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. - // - // Fail closed when the swapped-in tarball lacks ReactNativeHeaders: the - // directory purge above already deleted the previous Headers/, so - // continuing silently would leave the injected -fmodule-map-file flag - // dangling and break every include only on a config switch — - // with no pointer to the version-skewed artifact that caused it. - const rnhXcfw = path.join(finalLocation, 'ReactNativeHeaders.xcframework'); - if (!fs.existsSync(rnhXcfw)) { - throw new Error( - `ReactNativeHeaders.xcframework not found in the extracted tarball at ${finalLocation}. ` + - 'The downloaded artifact predates the headers-spec layout (or is incomplete); ' + - 'use a prebuilt tarball matching this react-native version.', - ); - } - const slice = fs - .readdirSync(rnhXcfw, {withFileTypes: true}) - .find( - dirent => - dirent.isDirectory() && - fs.existsSync(path.join(rnhXcfw, dirent.name.toString(), 'Headers')), - ); - if (!slice) { - throw new Error( - `No slice with a Headers directory found inside ${rnhXcfw}.`, - ); - } - 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}); - - // Restore Expo-generated modulemap after directory replacement. - // Runs in finally so it is not skipped if mv/cp partially fails. - if (savedModulemap != null) { - const restoredPath = path.join(finalLocation, useFrameworksModulemapName); - fs.writeFileSync(restoredPath, savedModulemap); - console.log('Restored', useFrameworksModulemapName); - } } } @@ -227,29 +147,33 @@ function main( console.log('Done replacing React Native prebuilt'); } -// This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok -const argv = yargs - .option('c', { - alias: 'configuration', - description: - 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".', - }) - .option('r', { - alias: 'reactNativeVersion', - description: - 'The Version of React Native associated with the React-Core prebuilt tarball.', - }) - .option('p', { - alias: 'podsRoot', - description: 'The path to the Pods root folder', - }) - .usage('Usage: $0 -c Debug -r -p ').argv; - -// $FlowFixMe[prop-missing] -const configuration = argv.configuration; -// $FlowFixMe[prop-missing] -const version = argv.reactNativeVersion; -// $FlowFixMe[prop-missing] -const podsRoot = argv.podsRoot; - -main(configuration, version, podsRoot); +if (require.main === module) { + // This script is executed in the Pods folder, which is usually not synched to Github, so it should be ok + const argv = yargs + .option('c', { + alias: 'configuration', + description: + 'Configuration to use to download the right React-Core prebuilt version. Allowed values are "Debug" and "Release".', + }) + .option('r', { + alias: 'reactNativeVersion', + description: + 'The Version of React Native associated with the React-Core prebuilt tarball.', + }) + .option('p', { + alias: 'podsRoot', + description: 'The path to the Pods root folder', + }) + .usage('Usage: $0 -c Debug -r -p ').argv; + + // $FlowFixMe[prop-missing] + const configuration = argv.configuration; + // $FlowFixMe[prop-missing] + const version = argv.reactNativeVersion; + // $FlowFixMe[prop-missing] + const podsRoot = argv.podsRoot; + + main(configuration, version, podsRoot); +} + +module.exports = {replaceRNCoreConfiguration}; diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 578ebc5c63da..8aebcf0700c5 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -55,6 +55,7 @@ * must contain debug/ and release/ cache slots. * [advanced] --download Artifact policy (default: auto). * [advanced] --skip-codegen Skip the react-native codegen step. + * [advanced] --config-command Override the autolinking config command. * * Steps performed (add/update): * 1. react-native codegen → build/generated/ios/ + install SPM codegen template @@ -83,14 +84,19 @@ const { } = require('./spm/generate-spm-autolinking'); const { generateAutolinkingConfig, + parseConfigCommandJson, + readEnvConfigCommand, + resolveEnvConfigCommand, } = require('./spm/generate-spm-autolinking-config'); const {main: generatePackage} = require('./spm/generate-spm-package'); const {findSourcePath} = require('./spm/generate-spm-package'); const { + SPM_INJECTED_MARKER, cleanupDanglingJavaScriptCoreRef, cleanupLeftoverPodsGroup, findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, + readPinnedConfigCommand, removeSpmInjection, } = require('./spm/generate-spm-xcodeproj'); const {scaffoldAll} = require('./spm/scaffold-package-swift'); @@ -187,6 +193,11 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { default: false, describe: '[advanced] Skip the react-native codegen step', }) + .option('config-command', { + type: 'string', + describe: + '[advanced] JSON array of the argv used to generate autolinking.json, overriding the default @react-native-community/cli config command. Also settable via RCT_SPM_AUTOLINKING_CONFIG_COMMAND. Either way `add`/`update` remembers the value in .spm-injected.json, so later runs and Xcode builds reuse it. Example: \'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]\'', + }) .usage( 'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.', ) @@ -214,6 +225,10 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { version: parsed.version ?? null, artifacts: parsed.artifacts ?? null, skipCodegen: parsed['skip-codegen'], + configCommand: + parsed['config-command'] != null + ? parseConfigCommandJson(parsed['config-command'], '--config-command') + : null, downloadPolicy: parsed.download, productName: parsed['product-name'] ?? null, xcodeprojPath: parsed.xcodeproj ?? null, @@ -833,6 +848,7 @@ async function setupXcodeproj( // (injectSpmIntoExistingXcodeproj preserves it — see // generate-spm-xcodeproj.js). artifactsVersionOverride: args.version ?? null, + configCommand: resolveConfigCommandToPin(args), }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); @@ -892,6 +908,88 @@ function logNextSteps( log('To remove SPM later: `npx react-native spm deinit`'); } +// The autolinking config command for this run: an explicit `--config-command` +// first, then the value a previous `add`/`update` pinned into the injection +// marker. undefined means "no explicit command", which is what makes +// generateAutolinkingConfig fall back to RCT_SPM_AUTOLINKING_CONFIG_COMMAND and +// then to the built-in default — so the pin has to be WITHHELD while the env +// var is set, or a stale pin would outrank a developer's env override. +function resolveExplicitConfigCommand( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: Array | void */ { + if (args.configCommand != null) { + return args.configCommand; + } + if (readEnvConfigCommand() != null) { + return undefined; + } + const pinned = readPinnedConfigCommand(appRoot); + if (pinned == null) { + return undefined; + } + log( + `Autolinking config command (pinned in ${SPM_INJECTED_MARKER}): ` + + pinned.join(' '), + ); + return pinned; +} + +// The command to record in the injection marker. The env var is resolved here +// too, because the Xcode build phase inherits neither the flag nor the shell +// that set it — an env-only override that went unpinned would leave the build +// re-deriving autolinking.json with the default command. null pins nothing and +// preserves any earlier pin. An invalid env value throws, as the flag does, +// though `add` has already failed closed on it by this point. +function resolveConfigCommandToPin( + args /*: SetupArgs */, +) /*: ?Array */ { + return args.configCommand ?? resolveEnvConfigCommand(); +} + +// Generate autolinking.json, failing closed on a config-command error. +// +// generateAutolinkingConfig throws ONLY when the config command itself fails — +// a non-zero exit, unparseable output, or a config missing +// project.ios.sourceDir. Swallowing that (the old behavior) let the run proceed +// and emit an empty Autolinked package, which only surfaced much later as an +// inscrutable `unable to resolve module dependency` at build time. Instead we +// set process.exitCode = 2 (a hard Xcode build-phase error, matching the +// RemoteVersionError path) and return null so the caller stops. +// +// A genuinely native-module-free app does NOT reach the error path: its command +// exits 0 with valid, empty-dependency JSON, so generateAutolinkingConfig +// returns normally and the empty-package path downstream stays valid. +function generateAutolinkingConfigOrFailClosed( + opts /*: { + projectRoot: string, + configCommand?: Array, + generate?: typeof generateAutolinkingConfig, + } */, +) /*: ?AutolinkingConfigResult */ { + const generate = opts.generate ?? generateAutolinkingConfig; + try { + return generate({ + projectRoot: opts.projectRoot, + configCommand: opts.configCommand, + }); + } catch (e) { + logError( + `Failed to generate autolinking.json: ${e.message}\n` + + 'The autolinking config command failed. If this app replaces ' + + '@react-native-community/cli autolinking (e.g. an Expo app), set ' + + 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND (or pass --config-command) to a ' + + 'JSON argv array whose command prints the React Native CLI config, ' + + 'e.g. \'["npx","expo-modules-autolinking","react-native-config",' + + '"--json","--platform","ios"]\'. An earlier `add`/`update` may also ' + + `have pinned a command in ${SPM_INJECTED_MARKER}; re-run with ` + + '--config-command to replace a stale one.', + ); + process.exitCode = 2; + return null; + } +} + async function main(argv /*:: ?: Array */) /*: Promise */ { let appRoot = process.cwd(); const projectRoot = findProjectRoot(appRoot); @@ -981,16 +1079,16 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { let autolinkingConfigResult /*: ?AutolinkingConfigResult */ = null; if (needsCliConfig) { log('Generating autolinking.json (CLI config)...'); - try { - autolinkingConfigResult = generateAutolinkingConfig({projectRoot}); - log( - `Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`, - ); - } catch (e) { - logError( - `generate-spm-autolinking-config failed: ${e.message}. External native modules may not be discovered.`, - ); + autolinkingConfigResult = generateAutolinkingConfigOrFailClosed({ + projectRoot, + configCommand: resolveExplicitConfigCommand(args, appRoot), + }); + if (autolinkingConfigResult == null) { + // Fail closed: the config command errored and the helper already set + // process.exitCode = 2. Stop rather than emit an empty Autolinked package. + return; } + log(`Wrote ${path.relative(appRoot, autolinkingConfigResult.outputPath)}`); } const reactNativeRoot = resolveReactNativeRoot( autolinkingConfigResult, @@ -1159,7 +1257,11 @@ module.exports = { main, detectStandardRnLayoutRedirect, findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, + parseArgs, resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, shouldAutoDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md index d0d0f9cbf7a0..abe6df11fc71 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -137,6 +137,40 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`). | `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | +| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | + +### The autolinking config command is remembered + +An app that replaces `@react-native-community/cli` autolinking (an Expo app, +for example) has to tell `spm` how to produce `autolinking.json`. Pass the +command once, on `add` or `update`: + +```bash +npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +``` + +Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and +the build-time `sync` — resolves the command in this order: + +1. `--configCommand` +2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` +3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an + earlier `add`/`update` +4. the default `@react-native-community/cli config` + +`add`/`update` pin whichever of the first two routes supplied the command, +validated as an argv array; a later run that passes neither keeps the existing +pin, and passing `--configCommand` again replaces it. The pin exists because +the **Sync SPM Autolinking** build phase inherits neither your flag nor the +shell that exported the env var — without it, a successful `add` is followed by +failing builds, because the phase re-derives `autolinking.json` with the +default command. A pin never shadows the env var, so an override in your shell +still takes effect, and a pin that no longer parses is ignored in favor of the +default. + +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` +therefore falls back to the default command unless you pass `--configCommand` +(or export the env var) again. ### Debug/Release flavor is automatic @@ -161,7 +195,7 @@ package graph, or require a second build. | Path | Commit? | Why | |------|---------|-----| | `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. | -| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. | +| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse, such as the [autolinking config command](#the-autolinking-config-command-is-remembered). | | `build/generated/` | No | Codegen/autolinking output; regenerated | | `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache | | `Package.resolved` | No | SwiftPM resolution file; machine-specific | @@ -335,6 +369,7 @@ across apps; refresh it with `react-native spm update --download force`. | "not contained in target" | Re-run setup (regenerates file-level symlinks) | | Codegen fails | Use `--skipCodegen` to iterate on other parts | | "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | | Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | | Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | | Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | diff --git a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js index 113bbee91606..f3caadf5e0b3 100644 --- a/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -20,6 +20,7 @@ const { resolveCacheSlotVersion, resolveHermesArtifact, resolveLatestV1Version, + resolveLocalHermesCompilerVersion, resolveNightlyVersion, resolveRNCoreArtifact, resolveRNDepsArtifact, @@ -65,6 +66,67 @@ function routerFetch(routes /*: {[string]: any} */) { // artifact at the RN nightly version (which won't exist on Maven). // --------------------------------------------------------------------------- +// Creates a scratch dir with (optionally) a `node_modules/hermes-compiler` +// package inside it, mimicking a real project root for +// resolveLocalHermesCompilerVersion()'s require.resolve({paths: [rnRoot]}). +function makeFakeRnRoot(hermesCompilerVersion /*: ?string */) /*: string */ { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-root-')); + if (hermesCompilerVersion != null) { + const pkgDir = path.join(root, 'node_modules', 'hermes-compiler'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({name: 'hermes-compiler', version: hermesCompilerVersion}), + ); + } + return root; +} + +// Forces resolveLocalHermesCompilerVersion() to behave as if hermes-compiler +// isn't installed, regardless of any workspace-hoisted hermes-compiler the host +// environment provides. Jest's require.resolve ignores the {paths: [rnRoot]} +// scoping and still finds the hoisted package, so stubbing module resolution is +// ineffective here; instead we stub fs.readFileSync to throw MODULE_NOT_FOUND +// for the resolved hermes-compiler/package.json — the exact signal a genuine +// resolution miss produces — which drives the function down its "not installed" +// branch. Reads of any other file fall through to the real implementation. +// Undo with jest.restoreAllMocks(). +function mockHermesCompilerUnresolvable() { + const realReadFileSync = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { + if (String(file).includes(`${path.sep}hermes-compiler${path.sep}`)) { + const error = new Error( + "Cannot find module 'hermes-compiler/package.json'", + ); + error.code = 'MODULE_NOT_FOUND'; + throw error; + } + return realReadFileSync.call(fs, file, ...rest); + }); +} + +describe('resolveLocalHermesCompilerVersion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reads the version from the locally installed hermes-compiler package', () => { + const root = makeFakeRnRoot('0.13.7'); + expect(resolveLocalHermesCompilerVersion(root)).toBe('0.13.7'); + }); + + it('returns null when no hermes-compiler is resolvable from the given root', () => { + // Resolve from a real, isolated scratch root that has no hermes-compiler + // installed (same setup as the fallback test below) so require.resolve + // stays scoped to that root and misses, rather than falling back to + // whatever hermes-compiler the host environment happens to hoist. The + // function must report absence rather than fabricating a version. + const root = makeFakeRnRoot(null); + mockHermesCompilerUnresolvable(); + expect(resolveLocalHermesCompilerVersion(root)).toBeNull(); + }); +}); + describe('resolveHermesArtifact', () => { let origFetch; let origHermesEnv; @@ -82,6 +144,7 @@ describe('resolveHermesArtifact', () => { } else { delete process.env.HERMES_VERSION; } + jest.restoreAllMocks(); }); // Mock fetch with a router: each entry's key is a URL substring; the value @@ -92,43 +155,74 @@ describe('resolveHermesArtifact', () => { } describe('default behavior (no HERMES_VERSION set)', () => { - it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => { + it('uses the locally pinned hermes-compiler version, without hitting npm', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - // Pretend the release URL exists once we ask for 0.13.0. - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, }); const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', null, + rnRoot, + ); + expect(result.version).toBe('0.13.7'); + expect(result.url).toContain('/0.13.7/'); + // Must resolve straight from node_modules — no npm registry round trip. + expect(globalThis.fetch).not.toHaveBeenCalledWith( + expect.stringContaining('registry.npmjs.org'), + expect.anything(), ); - expect(result.version).toBe('0.13.0'); - expect(result.url).toContain('/0.13.0/'); - // The RN nightly hash MUST NOT leak into the hermes URL. - expect(result.url).not.toContain('20260519'); }); it('ignores rawVersion (the RN --version arg) when HERMES_VERSION is unset', async () => { + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ - 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, - 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + 'hermes-ios/0.13.7/hermes-ios-0.13.7': {ok: true}, }); // Caller passes the original RN --version verbatim; hermes should - // still default to latest-v1 instead of using this. + // still use the locally pinned version instead of using this. const result = await resolveHermesArtifact( '0.87.0-nightly-20260519-58cd1bf58', 'debug', '0.87.0-nightly-20260519-58cd1bf58', + rnRoot, ); - expect(result.version).toBe('0.13.0'); + expect(result.version).toBe('0.13.7'); expect(result.url).not.toContain('20260519'); }); + + it('falls back to the latest-v1 npm dist-tag when hermes-compiler is not locally installed', async () => { + const rnRoot = makeFakeRnRoot(null); + // Stub require.resolve to fail so the local lookup is guaranteed to miss, + // even in environments that hoist a workspace hermes-compiler. The + // resolver must then fall through to the latest-v1 dist-tag (0.13.0). + mockHermesCompilerUnresolvable(); + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + rnRoot, + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).toContain('/0.13.0/'); + // Confirm the dist-tag lookup actually ran — the fallback path, not a + // locally pinned version, produced this result. + const hitLatestV1 = globalThis.fetch.mock.calls.some(([url]) => + String(url).includes('hermes-compiler/latest-v1'), + ); + expect(hitLatestV1).toBe(true); + }); }); describe('HERMES_VERSION escape hatches', () => { - it('HERMES_VERSION= uses it verbatim', async () => { + it('HERMES_VERSION= uses it verbatim, even with a local package installed', async () => { process.env.HERMES_VERSION = '0.13.5'; + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, }); @@ -136,13 +230,15 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.13.5'); expect(result.url).toContain('/0.13.5/'); }); - it('HERMES_VERSION=latest-v1 resolves via npm dist-tag', async () => { + it('HERMES_VERSION=latest-v1 resolves via npm dist-tag, even with a local package installed', async () => { process.env.HERMES_VERSION = 'latest-v1'; + const rnRoot = makeFakeRnRoot('0.13.7'); mockFetch({ 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, @@ -151,12 +247,14 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.13.0'); }); it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => { process.env.HERMES_VERSION = 'nightly'; + const rnRoot = makeFakeRnRoot(null); mockFetch({ 'hermes-compiler/nightly': {json: {version: '0.14.0-nightly-abc'}}, 'hermes-ios/0.14.0-nightly-abc/hermes-ios-0.14.0-nightly-abc': { @@ -167,12 +265,14 @@ describe('resolveHermesArtifact', () => { '0.87.0-nightly-anything', 'debug', null, + rnRoot, ); expect(result.version).toBe('0.14.0-nightly-abc'); }); it('falls back to the hermes snapshot URL when the release is missing', async () => { process.env.HERMES_VERSION = '0.13.5'; + const rnRoot = makeFakeRnRoot(null); globalThis.fetch = jest.fn(async (url, opts) => { if (opts && opts.method === 'HEAD') { return {status: 404}; @@ -185,7 +285,12 @@ describe('resolveHermesArtifact', () => { '2', }; }); - const result = await resolveHermesArtifact('0.87.0', 'debug', null); + const result = await resolveHermesArtifact( + '0.87.0', + 'debug', + null, + rnRoot, + ); expect(result.url).toContain('maven-snapshots'); expect(result.url).toContain('hermes-ios-debug.tar.gz'); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js index 6e56cca8171f..5118a5e09854 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js @@ -36,6 +36,20 @@ const os = require('os'); const path = require('path'); let tmpProjects = []; +let originalConfigCommandEnv; + +beforeEach(() => { + originalConfigCommandEnv = process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; +}); + +afterEach(() => { + if (originalConfigCommandEnv == null) { + delete process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND; + } else { + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = originalConfigCommandEnv; + } +}); function makeTmpProject() { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-')); @@ -256,4 +270,95 @@ describe('generateAutolinkingConfig', () => { rawJson: raw, }); }); + + describe('config command override', () => { + it('uses the config command from RCT_SPM_AUTOLINKING_CONFIG_COMMAND', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'my-cli', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['my-cli', 'config']); + }); + + it('prefers an explicit configCommand over the environment variable', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = JSON.stringify([ + 'environment', + 'config', + ]); + + generateAutolinkingConfig({ + projectRoot, + configCommand: ['explicit', 'config'], + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual(['explicit', 'config']); + }); + + it('throws when the environment variable is not JSON', () => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = 'not json'; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }); + + it.each(['[]', '[1,2]'])( + 'throws when the environment variable is not a non-empty string array: %s', + rawConfigCommand => { + const {projectRoot} = makeTmpProject(); + process.env.RCT_SPM_AUTOLINKING_CONFIG_COMMAND = rawConfigCommand; + + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + }, + ); + + it('falls back to the default command when the environment variable is unset', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: command => { + receivedCommand = command; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual([ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', + ]); + }); + }); }); diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 0282e4bf57fc..db544d351be1 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -14,6 +14,7 @@ const { SPM_INJECTED_MARKER, injectSpmIntoExistingXcodeproj, readArtifactsVersionOverride, + readPinnedConfigCommand, removeSpmInjection, } = require('../generate-spm-xcodeproj'); const fs = require('fs'); @@ -393,3 +394,148 @@ describe('readArtifactsVersionOverride', () => { expect(readArtifactsVersionOverride(appRoot)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// configCommand — the marker field persisting an explicit `spm add/update +// --config-command ''`. Without the pin, the build-time `sync` +// re-derived autolinking.json with the default @react-native-community/cli +// command and failed the "Sync SPM Autolinking" build phase in apps (e.g. Expo +// apps) that replace it. +// --------------------------------------------------------------------------- +describe('configCommand marker field', () => { + const EXPO_COMMAND = [ + 'npx', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios', + ]; + + it('records an explicit config command into the marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('defaults to null when --config-command has never been given', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toBeNull(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('preserves the pin on a later run without --config-command', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual(EXPO_COMMAND); + expect(readPinnedConfigCommand(appRoot)).toEqual(EXPO_COMMAND); + }); + + it('a later explicit --config-command overwrites the previous pin', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + expect(readMarker(xcodeprojPath).configCommand).toEqual([ + 'my-cli', + 'config', + ]); + expect(readPinnedConfigCommand(appRoot)).toEqual(['my-cli', 'config']); + }); + + it('deinit drops the pin along with the whole marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: EXPO_COMMAND, + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// readPinnedConfigCommand — pure fs read, used by setup-apple-spm.js (including +// the build-time `sync`) to reuse the config command an earlier `add`/`update` +// pinned. A hand-edited or corrupt marker must degrade to the env/default path +// instead of injecting a bogus argv or throwing mid-build. +// --------------------------------------------------------------------------- +describe('readPinnedConfigCommand', () => { + it('returns null when no xcodeproj has been injected yet', () => { + const {appRoot} = scaffoldApp(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it('returns null (never throws) on a malformed marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + '{ not valid json', + 'utf8', + ); + expect(() => readPinnedConfigCommand(appRoot)).not.toThrow(); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); + + it.each([ + ['a bare string', '"npx expo-modules-autolinking"'], + ['an empty array', '[]'], + ['a non-string member', '["npx", 7]'], + ['an empty-string member', '["npx", ""]'], + ['an object', '{"command": "npx"}'], + ])('returns null for a pinned value that is %s', (_label, pinned) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + configCommand: ['my-cli', 'config'], + }); + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + marker.configCommand = JSON.parse(pinned); + fs.writeFileSync(markerPath, JSON.stringify(marker), 'utf8'); + expect(readPinnedConfigCommand(appRoot)).toBeNull(); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index abdaaf823364..7386e4ffcbab 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -14,7 +14,11 @@ const { detectStandardRnLayoutRedirect, ensureBothArtifactFlavors, findInjectedXcodeproj, + generateAutolinkingConfigOrFailClosed, + parseArgs, resolveAction, + resolveConfigCommandToPin, + resolveExplicitConfigCommand, shouldAutoDeintegrate, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); @@ -26,12 +30,17 @@ const path = require('path'); // Create an in-place-injected xcodeproj fixture: a directory carrying the // `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). -function mkInjectedXcodeproj(appRoot, name) { +function mkInjectedXcodeproj(appRoot, name, markerFields = {}) { const dir = path.join(appRoot, name); fs.mkdirSync(dir, {recursive: true}); fs.writeFileSync( path.join(dir, SPM_INJECTED_MARKER), - JSON.stringify({rootUuid: 'X', target: 'MyApp', injectedUuids: []}), + JSON.stringify({ + rootUuid: 'X', + target: 'MyApp', + injectedUuids: [], + ...markerFields, + }), ); return dir; } @@ -59,6 +68,268 @@ function gitInitAndCommit(dir) { execFileSync('git', ['commit', '-m', 'init'], opts); } +describe('parseArgs', () => { + it('parses --config-command as a JSON argv array', () => { + const args = parseArgs([ + 'update', + '--config-command', + '["a","b","config"]', + ]); + + expect(args.action).toBe('update'); + expect(args.configCommand).toEqual(['a', 'b', 'config']); + }); + + it('sets configCommand to null when --config-command is omitted', () => { + expect(parseArgs(['update']).configCommand).toBeNull(); + }); + + it('throws for an invalid --config-command value', () => { + expect(() => parseArgs(['update', '--config-command', 'not json'])).toThrow( + /--config-command/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// generateAutolinkingConfigOrFailClosed — the fail-closed policy main() applies +// to the autolinking config step. Swallowing a config-command error (the old +// behavior) let the build proceed with a silently-empty Autolinked package that +// only surfaced later as `unable to resolve module dependency`. A native- +// module-free app does NOT hit the error path: its command exits 0 with valid +// empty-dependency JSON and the generator returns normally. +// --------------------------------------------------------------------------- + +describe('generateAutolinkingConfigOrFailClosed', () => { + let prevExitCode; + let warnSpy; + + beforeEach(() => { + prevExitCode = process.exitCode; + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + process.exitCode = prevExitCode; + jest.restoreAllMocks(); + }); + + it('returns the config result and leaves the exit code untouched on success', () => { + const result = { + config: {}, + outputPath: '/app/ios/autolinking.json', + rawJson: '{}', + }; + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => result, + }); + + expect(out).toBe(result); + expect(process.exitCode).not.toBe(2); + }); + + it('passes projectRoot and configCommand through to the generator', () => { + let received; + generateAutolinkingConfigOrFailClosed({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + generate: opts => { + received = opts; + return {config: {}, outputPath: '', rawJson: ''}; + }, + }); + + expect(received).toEqual({ + projectRoot: '/proj', + configCommand: ['my-cli', 'config'], + }); + }); + + it('fails closed (null, exit 2, actionable error) when the config command errors', () => { + const out = generateAutolinkingConfigOrFailClosed({ + projectRoot: '/app', + generate: () => { + throw new Error("'my-cli config' exited with status 1"); + }, + }); + + expect(out).toBeNull(); + expect(process.exitCode).toBe(2); + const warnings = warnSpy.mock.calls.map(c => c.join(' ')).join('\n'); + // Names the override so the next person can fix it... + expect(warnings).toMatch(/RCT_SPM_AUTOLINKING_CONFIG_COMMAND/); + // ...and preserves the underlying cause. + expect(warnings).toMatch(/exited with status 1/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveExplicitConfigCommand — the autolinking config command every action +// (add/update/sync/scaffold) runs with: `--config-command` → +// RCT_SPM_AUTOLINKING_CONFIG_COMMAND → the value pinned in `.spm-injected.json` +// → the built-in default. undefined means "let generateAutolinkingConfig pick +// the env var or the default". +// --------------------------------------------------------------------------- + +describe('resolveExplicitConfigCommand', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const PINNED = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + let logSpy; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + function pin(configCommand) { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', {configCommand}); + } + + it('prefers an explicit --config-command over the env var and the pin', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect( + resolveExplicitConfigCommand( + {configCommand: ['flag', 'config']}, + tempDir, + ), + ).toEqual(['flag', 'config']); + }); + + it('lets the env var win over the pin (a stale pin must not shadow it)', () => { + process.env[ENV] = '["from-env","config"]'; + pin(PINNED); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('uses the pin when neither the flag nor the env var is set', () => { + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + // Names the source, so a stale pin is diagnosable from the build log. + expect(logSpy.mock.calls.map(c => c.join(' ')).join('\n')).toMatch( + /\.spm-injected\.json/, + ); + }); + + it('ignores a blank env var and falls through to the pin', () => { + process.env[ENV] = ' '; + pin(PINNED); + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(PINNED); + }); + + it('falls back to the default (undefined) with no flag, env var or pin', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when the pinned value is malformed', () => { + pin('npx expo-modules-autolinking'); + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); + + it('falls back to the default when no project is injected yet', () => { + expect(resolveExplicitConfigCommand({configCommand: null}, tempDir)).toBe( + undefined, + ); + }); +}); + +// --------------------------------------------------------------------------- +// resolveConfigCommandToPin — what `add`/`update` records in the injection +// marker: the explicit `--config-command`, else the env override, since the +// Xcode build phase inherits neither. null pins nothing (and preserves any +// earlier pin). +// --------------------------------------------------------------------------- + +describe('resolveConfigCommandToPin', () => { + const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + const FROM_ENV = ['npx', 'expo-modules-autolinking', 'react-native-config']; + let tempDir; + let prevEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-config-command-pin-')); + prevEnv = process.env[ENV]; + delete process.env[ENV]; + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (prevEnv === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = prevEnv; + } + jest.restoreAllMocks(); + }); + + it('pins the env-derived command when only the env var is set', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect(resolveConfigCommandToPin({configCommand: null})).toEqual(FROM_ENV); + }); + + it('pins the explicit --config-command over the env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + expect( + resolveConfigCommandToPin({configCommand: ['flag', 'config']}), + ).toEqual(['flag', 'config']); + }); + + it('pins nothing when the env var is blank', () => { + process.env[ENV] = ' \t '; + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('pins nothing when neither the flag nor the env var is set', () => { + expect(resolveConfigCommandToPin({configCommand: null})).toBeNull(); + }); + + it('fails loud rather than pinning garbage from an invalid env var', () => { + process.env[ENV] = 'npx expo-modules-autolinking'; + expect(() => resolveConfigCommandToPin({configCommand: null})).toThrow( + /RCT_SPM_AUTOLINKING_CONFIG_COMMAND/, + ); + }); + + it('is resolved back by a later run with neither flag nor env var', () => { + process.env[ENV] = JSON.stringify(FROM_ENV); + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', { + configCommand: resolveConfigCommandToPin({configCommand: null}), + }); + delete process.env[ENV]; + + expect( + resolveExplicitConfigCommand({configCommand: null}, tempDir), + ).toEqual(FROM_ENV); + }); +}); + // --------------------------------------------------------------------------- // resolveAction — zero-arg default. Explicit action wins; otherwise `update` // when an injection marker exists, else `add` (first run). diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index aa001e4a1cb1..82626894c681 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -117,7 +117,7 @@ function discoverPlugins( ); } const pluginPath = path.resolve(dep.root, rel); - let fn: unknown; + let fn /*: unknown */ = null; try { // $FlowFixMe[unsupported-syntax] dynamic require by computed path fn = require(pluginPath); @@ -175,7 +175,7 @@ function invokePlugins( const seenFrameworkNames /*: Set */ = new Set(); for (const {depName, pluginPath, plugin} of plugins) { - let result: unknown; + let result /*: unknown */ = null; try { result = plugin(context); } catch (e) { diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js index 4b428f354090..c3ebc32d5935 100644 --- a/packages/react-native/scripts/spm/download-spm-artifacts.js +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -393,15 +393,63 @@ async function resolveRNDepsArtifact( return {url: snapshotUrl, version}; } +/** + * Resolves the `hermes-compiler` npm package's version from THIS project's own + * node_modules — the exact same lookup generate-spm-xcodeproj.js's + * resolveHermesCliPathSetting() uses to find the hermesc binary that will + * compile the JS bundle, and the same one react-native-xcode.sh falls back to + * for SwiftPM builds. Returns null when the package isn't resolvable (e.g. + * USE_HERMES=false apps that never installed it) so the caller can fall back + * to the npm dist-tag lookup. + */ +function resolveLocalHermesCompilerVersion( + rnRoot /*: string */, +) /*: string | null */ { + try { + const pkgPath = require.resolve('hermes-compiler/package.json', { + paths: [rnRoot], + }); + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkg /*: {version: string} */ = JSON.parse( + fs.readFileSync(pkgPath, 'utf8'), + ); + assertSafeVersion(pkg.version, 'local hermes-compiler/package.json'); + return pkg.version; + } catch (error) { + // A MODULE_NOT_FOUND resolution failure is the expected case (e.g. + // USE_HERMES=false apps that never installed hermes-compiler) — fall back + // silently. Any other failure means hermes-compiler IS installed but its + // package.json is unreadable/malformed or carries an unsafe version; warn + // loudly rather than silently regressing to the live latest-v1 dist-tag, + // which would re-introduce the version-skew crash this resolves (#57917). + if (error.code !== 'MODULE_NOT_FOUND') { + log( + ` WARNING: hermes-compiler is installed but its version could not be resolved (${error.message}); falling back to the latest-v1 dist-tag, which may not match the pinned hermesc and can crash at launch with "Wrong bytecode version".`, + ); + } + return null; + } +} + /** * Returns {url, version} for Hermes. Hermes uses its own version space * decoupled from React Native's nightly cadence — RN's `hermes-compiler` * npm package publishes a `latest-v1` dist-tag that always resolves to a - * binary that's been built and uploaded to Maven. Our default mirrors RN's - * CocoaPods prebuild path (see scripts/ios-prebuild/hermes.js): + * binary that's been built and uploaded to Maven. * - * HERMES_VERSION unset → 'latest-v1' dist-tag - * HERMES_VERSION=latest-v1 → same (explicit) + * HERMES_VERSION unset → version pinned by the locally installed + * hermes-compiler package (node_modules). + * This is the SAME source + * resolveHermesCliPathSetting() reads for + * HERMES_CLI_PATH, so the downloaded VM and + * the hermesc that compiles the JS bundle + * always agree — a mismatched pair crashes at + * launch with "Wrong bytecode version" (#57917). + * Falls back to the 'latest-v1' npm dist-tag + * (RN's CocoaPods prebuild default; see + * scripts/ios-prebuild/hermes.js) only when + * hermes-compiler isn't locally resolvable. + * HERMES_VERSION=latest-v1 → 'latest-v1' dist-tag (explicit) * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag * HERMES_VERSION= → use that version verbatim * @@ -413,8 +461,17 @@ async function resolveHermesArtifact( rnVersion /*: string */, flavor /*: string */, rawVersion /*: string | null */, + rnRoot /*: string */, ) /*: Promise */ { - let version = process.env.HERMES_VERSION ?? 'latest-v1'; + let version = process.env.HERMES_VERSION; + + if (version == null) { + const localVersion = resolveLocalHermesCompilerVersion(rnRoot); + if (localVersion != null) { + log(` Using locally pinned hermes-compiler: ${localVersion}`); + } + version = localVersion ?? 'latest-v1'; + } if (version === 'nightly') { version = await resolveNightlyVersion('hermes-compiler'); @@ -943,7 +1000,7 @@ async function processArtifact( return localPath; }; - let tarPath: string; + let tarPath /*: string */ = ''; let fromShared = false; if (isLocalTarball) { tarPath = url; @@ -970,7 +1027,7 @@ async function processArtifact( onProgress(xcframeworkName, 0, 0, 0, false, 0); } const tmpExtractDir = path.join(outputDir, '.extract-tmp', label); - let xcfwPath: string; + let xcfwPath /*: string */ = ''; try { xcfwPath = extractXCFramework(tarPath, tmpExtractDir); } catch (e) { @@ -1119,7 +1176,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { label: 'hermes', name: 'hermes-engine', resolve: () => - resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion), + resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion, rnRoot), sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`, }, ]; @@ -1349,7 +1406,7 @@ function validateArtifactsCache( if (!fs.existsSync(artifactsJsonPath)) { return `artifacts.json missing in ${artifactsDir}`; } - let json: {[string]: {xcframeworkPath: string, url: string}}; + let json /*: {[string]: {xcframeworkPath: string, url: string}} */ = {}; try { // $FlowFixMe[unclear-type] JSON.parse returns any const parsed /*: any */ = JSON.parse( @@ -1390,6 +1447,7 @@ module.exports = { main, resolveCacheSlotVersion, resolveHermesArtifact, + resolveLocalHermesCompilerVersion, REQUIRED_ARTIFACTS, validateArtifactsCache, // Exposed for unit tests (pure / fetch-stubbable helpers). diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js index 58ebb96b2f92..22053ba5c8ef 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -16,6 +16,8 @@ * * Invokes the React Native community CLI to produce its config and writes the * raw JSON to /build/generated/autolinking/autolinking.json. + * The config command can be overridden by `--config-command` or + * `RCT_SPM_AUTOLINKING_CONFIG_COMMAND`, in that order, before the default. * * No filtering or reshaping happens here — the downstream consumer * (generate-spm-autolinking.js) does its own iOS-only filtering when reading @@ -60,6 +62,32 @@ const FALLBACK_CONFIG_COMMAND = [ 'config', ]; +function parseConfigCommandJson( + raw /*: string */, + source /*: string */, +) /*: Array */ { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + `${source}: config command must be a JSON array of strings. Example: '["npx","@react-native-community/cli","config"]'`, + ); + } + + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + !parsed.every(value => typeof value === 'string' && value.length > 0) + ) { + throw new Error( + `${source}: config command must be a non-empty JSON array of non-empty strings`, + ); + } + + return parsed; +} + function resolveDefaultConfigCommand( projectRoot /*: string */, ) /*: Array */ { @@ -94,6 +122,31 @@ function resolveDefaultConfigCommand( return FALLBACK_CONFIG_COMMAND; } +const ENV_CONFIG_COMMAND = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; + +// The raw env override, or null when unset or blank. Exported so callers that +// only need to know whether the override is in play (setup-apple-spm.js) share +// this blankness rule instead of re-deriving it. +function readEnvConfigCommand() /*: ?string */ { + const raw = process.env[ENV_CONFIG_COMMAND]; + return typeof raw === 'string' && raw.trim().length > 0 ? raw : null; +} + +// The env override, parsed and validated, or null when unset or blank. Throws +// on a set-but-invalid value — never silently degrades to the default. +function resolveEnvConfigCommand() /*: ?Array */ { + const raw = readEnvConfigCommand(); + return raw == null ? null : parseConfigCommandJson(raw, ENV_CONFIG_COMMAND); +} + +// Env-var / default resolution for the autolinking config command. An explicit +// `configCommand` (e.g. from `--config-command`) is handled upstream by +// generateAutolinkingConfig's destructuring default, so it never reaches here — +// this only decides between the env-var override and the built-in default. +function resolveConfigCommand(projectRoot /*: string */) /*: Array */ { + return resolveEnvConfigCommand() ?? resolveDefaultConfigCommand(projectRoot); +} + function defaultCliRunner( command /*: Array */, opts /*: {cwd: string} */, @@ -116,7 +169,7 @@ function generateAutolinkingConfig( ) /*: GenerateAutolinkingConfigResult */ { const { projectRoot, - configCommand = resolveDefaultConfigCommand(projectRoot), + configCommand = resolveConfigCommand(projectRoot), cliRunner = defaultCliRunner, } = opts; @@ -158,4 +211,11 @@ function generateAutolinkingConfig( return {config, outputPath: outPath, rawJson}; } -module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand}; +module.exports = { + generateAutolinkingConfig, + parseConfigCommandJson, + readEnvConfigCommand, + resolveConfigCommand, + resolveDefaultConfigCommand, + resolveEnvConfigCommand, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index ce47509cb3c8..d8f14abbb85c 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -426,7 +426,7 @@ function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { let hasClang = false; const walk = (dir /*: string */, depth /*: number */) => { if (depth > 6 || (hasSwift && hasClang)) return; - let entries: Array<{name: string, isDirectory(): boolean}>; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { // $FlowFixMe[incompatible-type] Dirent typing entries = fs.readdirSync(dir, {withFileTypes: true}); diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index f22fe26455e7..1cbe06304b8a 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -22,6 +22,7 @@ */ const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); +const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, addArrayStringValues, @@ -1799,13 +1800,13 @@ function readGeneratedSourcesManifest( appRoot /*: string */, ) /*: Array */ { const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST); - let raw: string; + let raw /*: string */ = ''; try { raw = fs.readFileSync(manifestPath, 'utf8'); } catch { return []; } - let entries: unknown; + let entries /*: unknown */ = null; try { entries = JSON.parse(raw); } catch { @@ -1844,7 +1845,7 @@ function readGeneratedSourcesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array, ...} */ { +) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -1857,10 +1858,11 @@ function readMarker( // Returns the `*.xcodeproj` under `appRoot` carrying a `.spm-injected.json` // marker (the user-owned project SPM packages were injected into in place), // or null when none has been injected yet. Pure fs reads — safe for the -// build-time sync (sync-spm-autolinking.js, via readArtifactsVersionOverride -// below) to call without pulling in any pbxproj-editing machinery at runtime. +// marker readers below, and for callers that only locate the project (setup- +// apple-spm.js's action defaulting and `deinit`), to call without exercising +// any pbxproj-editing machinery. function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { - let entries: Array<{name: string, isDirectory(): boolean}>; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { // $FlowFixMe[incompatible-type] Dirent typing entries = fs.readdirSync(appRoot, {withFileTypes: true}); @@ -1884,11 +1886,13 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { * update --version` pinned into the injected xcodeproj's `.spm-injected.json` * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj * below), or null when no project is injected yet, no override is pinned, or - * the marker can't be read (never throws). Pure fs reads — the build-time - * sync (sync-spm-autolinking.js) calls this to prefer the pinned version over - * the one derived from node_modules/react-native/package.json, so a - * version-mismatched setup keeps healing against the SAME artifact slot the - * explicit `--version` selected. + * the marker can't be read (never throws). Pure fs reads. + * + * Nothing in production calls this yet: the pin is written but never read + * back, so the build-time sync (sync-spm-autolinking.js) still derives the + * version from node_modules/react-native/package.json and can heal against a + * different artifact slot than the explicit `--version` selected. Only the + * tests cover it. */ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { const xcodeprojPath = findInjectedXcodeproj(appRoot); @@ -1899,13 +1903,39 @@ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { return typeof override === 'string' && override.length > 0 ? override : null; } +/** + * Read the autolinking config command a previous `spm add`/`update` pinned into + * the injected xcodeproj's `.spm-injected.json` marker, or null when nothing + * usable is pinned. Pure fs reads, like readArtifactsVersionOverride above, but + * this one IS wired: setup-apple-spm.js's resolveExplicitConfigCommand reads it + * on add/update/scaffold and on the build-time `sync`. Re-validated through the + * same parseConfigCommandJson the flag goes through, and never throws, so a + * hand-edited marker degrades to the env-var/default command instead of + * injecting a bogus argv into the build. + */ +function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array */ { + const xcodeprojPath = findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return null; + } + const pinned = readMarker(xcodeprojPath)?.configCommand; + if (pinned == null) { + return null; + } + try { + return parseConfigCommandJson(JSON.stringify(pinned), SPM_INJECTED_MARKER); + } catch { + return null; + } +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -2022,6 +2052,16 @@ function injectSpmIntoExistingXcodeproj( prevMarker?.artifactsVersionOverride ?? null; + // Same set-or-preserve contract as the version pin above, for the autolinking + // config command `add`/`update` resolved (`--config-command` or + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND) — the build-time sync sees neither the + // flag nor the developer's shell environment, so without the pin it + // re-derives autolinking.json with the default @react-native-community/cli + // command and breaks apps that replace it. Read back by + // readPinnedConfigCommand (above). No "clear" verb yet either; `deinit` drops + // the whole marker, this field with it. + const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null; + // Marker: idempotency signal + the exact, reversible record of every edit so // `deinit` (removeSpmInjection) can undo precisely what was added. writeIfChanged( @@ -2038,6 +2078,7 @@ function injectSpmIntoExistingXcodeproj( // `update` to reconcile away entries that left the manifest. generatedSources: generatedSourceUuids, artifactsVersionOverride, + configCommand, scheme: { file: schemeResult.file, created: schemeResult.status === 'created', @@ -2220,5 +2261,6 @@ module.exports = { removePreActionFromScheme, findInjectedXcodeproj, readArtifactsVersionOverride, + readPinnedConfigCommand, SPM_INJECTED_MARKER, }; diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index 09e246b5cb17..cc3fb34b0e88 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -197,7 +197,7 @@ function collectSubdirs( ]); const out /*: Array */ = []; const walk = (absDir /*: string */, relDir /*: string */) => { - let entries: Array<{name: string, isDirectory(): boolean}>; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; try { // $FlowFixMe[incompatible-type] Dirent typing entries = fs.readdirSync(absDir, {withFileTypes: true}); @@ -939,7 +939,7 @@ function scaffoldPackageSwiftForDep( }; } - let model: PodspecModel; + let model; try { model = readPodspec(podspecPath); } catch (e) { @@ -1135,7 +1135,7 @@ function scaffoldAll( directDeps.push({name, root, platforms: {ios: iosPlatform}}); } - let allDeps: Array; + let allDeps /*: Array */ = []; try { allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index cc30c8823b65..75d0ba26b6aa 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -16,6 +16,9 @@ export type SetupArgs = { // `debug/` and `release/` cache slots, each with artifacts.json. artifacts: string | null, skipCodegen: boolean, + // Overrides the autolinking config command; also settable via + // RCT_SPM_AUTOLINKING_CONFIG_COMMAND. + configCommand: Array | null, // Artifact download policy: 'auto' fetches when missing, 'skip' never // fetches, 'force' clears the cache slot and re-downloads. downloadPolicy: 'auto' | 'skip' | 'force', diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js index d0757fcdba75..63770292a848 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js @@ -20,13 +20,14 @@ import TextInputState from '../../../../../../Libraries/Components/TextInput/Tex import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {createRef} from 'react'; -import {ScrollView, Text, TextInput, View} from 'react-native'; +import {Modal, ScrollView, Text, TextInput, View} from 'react-native'; import { NativeText, NativeVirtualText, } from 'react-native/Libraries/Text/TextNativeComponent'; import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; import Event from 'react-native/src/private/webapis/dom/events/Event'; +import ReactNativeDocument from 'react-native/src/private/webapis/dom/nodes/ReactNativeDocument'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement'; import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; @@ -398,6 +399,47 @@ describe('ReactNativeElement', () => { expect(childNodeC.parentNode).toBe(null); expect(childNodeC.parentElement).toBe(null); }); + + it('returns the containing element as the parent of a modal host view, not the document', () => { + const parentRef = createRef(); + const modalRef = createRef(); + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const parentNode = ensureReactNativeElement(parentRef.current); + const modalNode = ensureReactNativeElement(modalRef.current); + const document = ensureInstance( + parentNode.ownerDocument, + ReactNativeDocument, + ); + + // Capture the relations before tearing down, so cleanup runs even if + // the assertions below fail. + const modalParentNode = modalNode.parentNode; + const modalParentElement = modalNode.parentElement; + + // Unmount and drain the queue so the modal's AppContainer passive + // effects (in __DEV__) don't trip the global "MessageQueue is not + // empty" validation hook. + root.destroy(); + Fantom.runWorkLoop(); + + // The host view is a root-kind shadow node, but its parent + // must still be its actual containing element, NOT the document. + // Two-phase event propagation (e.g. focus/blur bubbling to ancestors + // rendered above the modal) walks this parent chain, so returning the + // document here silently severs bubbling at the modal boundary. + expect(modalParentNode).toBe(parentNode); + expect(modalParentElement).toBe(parentNode); + expect(modalParentNode).not.toBe(document); + }); }); describe('compareDocumentPosition / contains', () => { diff --git a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm index 8f023f21f075..1d366e828d93 100644 --- a/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm +++ b/packages/rn-tester/NativeComponentExample/ios/RNTMyNativeViewComponentView.mm @@ -13,7 +13,11 @@ #import #import +#if __has_include() +#import +#else #import "RCTFabricComponentsPlugins.h" +#endif using namespace facebook::react; diff --git a/private/core-cli-utils/src/private/app.js b/private/core-cli-utils/src/private/app.js index 21e9ea297dcc..50733a64a08b 100644 --- a/private/core-cli-utils/src/private/app.js +++ b/private/core-cli-utils/src/private/app.js @@ -187,7 +187,7 @@ const bundleApp = ( 'Check if SourceMap script available', () => { composeSourceMaps = getNodePackagePath( - 'react-native/scripts/compose-source-maps.js', + 'react-native/scripts/compose-source-maps', ); }, ); diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 78719f779281..51245baacdf0 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -11326,11 +11326,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -11423,12 +11429,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index d395957f60b8..70ff9edf923a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -10946,11 +10946,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -11043,12 +11049,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 3fd714c64445..409733580c4e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -11176,11 +11176,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -11273,12 +11279,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 4b13dfdae6f7..e08e639a382e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -13193,11 +13193,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -13290,12 +13296,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 19d442c50c79..34d05dd76833 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -12875,11 +12875,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -12972,12 +12978,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 556c74b08116..da4483867b58 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -13053,11 +13053,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -13150,12 +13156,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index ddd9a6db9130..d49eb20e98c6 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -8318,11 +8318,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -8415,12 +8421,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index e1b04d6e3dd0..f0182c4a07a4 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -8143,11 +8143,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -8240,12 +8246,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 254a75c2d6bc..7154c5ebdba5 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -8309,11 +8309,17 @@ struct facebook::react::jsinspector_modern::tracing::FrameTimingSequence { } struct facebook::react::jsinspector_modern::tracing::HostTracingProfile { + public HostTracingProfile() = default; + public HostTracingProfile(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public HostTracingProfile(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::HighResTimeStamp startTime; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(const facebook::react::jsinspector_modern::tracing::HostTracingProfile&) = delete; + public facebook::react::jsinspector_modern::tracing::HostTracingProfile& operator=(facebook::react::jsinspector_modern::tracing::HostTracingProfile&&) = default; public facebook::react::jsinspector_modern::tracing::ProcessId processId; public std::vector frameTimings; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~HostTracingProfile() = default; } struct facebook::react::jsinspector_modern::tracing::IdGenerator { @@ -8406,12 +8412,17 @@ struct facebook::react::jsinspector_modern::tracing::TraceEventProfileChunk::CPU } struct facebook::react::jsinspector_modern::tracing::TraceRecordingState { + public TraceRecordingState(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; public TraceRecordingState(facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set enabledCategories, std::optional windowSize = std::nullopt); + public TraceRecordingState(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public facebook::react::jsinspector_modern::tracing::Mode mode; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(const facebook::react::jsinspector_modern::tracing::TraceRecordingState&) = delete; + public facebook::react::jsinspector_modern::tracing::TraceRecordingState& operator=(facebook::react::jsinspector_modern::tracing::TraceRecordingState&&) = default; public std::optional windowSize; public std::set enabledCategories; public std::vector instanceTracingProfiles; public std::vector runtimeSamplingProfiles; + public ~TraceRecordingState() = default; } template diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/ensureUndefinedOnOptionalMembers-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/ensureUndefinedOnOptionalMembers-test.js new file mode 100644 index 000000000000..2214361da192 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/ensureUndefinedOnOptionalMembers-test.js @@ -0,0 +1,65 @@ +/** + * 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 strict-local + * @format + */ + +const ensureUndefinedOnOptionalMembersVisitor = require('../ensureUndefinedOnOptionalMembers.js'); +const babel = require('@babel/core'); + +async function translate(code: string): Promise { + const result = await babel.transformAsync(code, { + plugins: [ + '@babel/plugin-syntax-typescript', + ensureUndefinedOnOptionalMembersVisitor, + ], + }); + + return result.code; +} + +describe('ensureUndefinedOnOptionalMembers', () => { + test('should add undefined to optional type members', async () => { + const code = ` + type Foo = { + a?: number, + 'b-key'?: number | string, + c?: () => void, + d: boolean, + e: string | undefined, + f?: (() => void) | number, + }; + `; + const result = await translate(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + a?: number | undefined; + 'b-key'?: number | string | undefined; + c?: (() => void) | undefined; + d: boolean; + e: string | undefined; + f?: (() => void) | number | undefined; + };" + `); + }); + + test('should not add undefined when already present', async () => { + const code = ` + type Foo = { + a?: number | undefined, + b?: undefined, + }; + `; + const result = await translate(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + a?: number | undefined; + b?: undefined; + };" + `); + }); +}); diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js index ab449e877b5a..e678880442b9 100644 --- a/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/removeUndefinedFromOptionalMembers-test.js @@ -42,4 +42,20 @@ describe('removeUndefinedFromOptionalMembers', () => { };" `); }); + + test('should unwrap the lone remaining constituent, dropping redundant parens', async () => { + const code = ` + type Foo = { + a?: (() => void) | undefined, + b?: number | string | undefined, + }; + `; + const result = await translate(code); + expect(result).toMatchInlineSnapshot(` + "type Foo = { + a?: () => void; + b?: number | string; + };" + `); + }); }); diff --git a/scripts/js-api/build-types/transforms/typescript/ensureUndefinedOnOptionalMembers.js b/scripts/js-api/build-types/transforms/typescript/ensureUndefinedOnOptionalMembers.js new file mode 100644 index 000000000000..c1cf97ae2901 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/ensureUndefinedOnOptionalMembers.js @@ -0,0 +1,71 @@ +/** + * 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 strict-local + * @format + */ + +import type {PluginObj} from '@babel/core'; + +import * as t from '@babel/types'; + +/** + * Ensures optional object members carry an explicit `| undefined`, e.g. + * `foo?: number` becomes `foo?: number | undefined` (members that already + * include `undefined` are left unchanged). + * + * This is the format required by TypeScript consumers building with + * `exactOptionalPropertyTypes`, under which `foo?: T` and `foo?: T | undefined` + * are distinct. It applies to the per-module generated types; the API snapshot + * pipeline still strips it back via `removeUndefinedFromOptionalMembers`. + * + * This lives in React Native as a stopgap: flow-api-translator does not yet emit + * this format, and the generated types cannot wait for its next release. + * + * The API snapshot resets this via `removeUndefinedFromOptionalMembers`, which + * also unwraps the resulting single-constituent unions so function-valued + * members remain bare function types for `sortProperties` to group. + */ +const visitor: PluginObj = { + visitor: { + TSPropertySignature(path) { + if (path.node.optional !== true) { + return; + } + + const typeAnnotation = path.node.typeAnnotation; + if (!typeAnnotation || !t.isTSTypeAnnotation(typeAnnotation)) { + return; + } + + const actualTypeAnnotation = typeAnnotation.typeAnnotation; + + if (t.isTSUndefinedKeyword(actualTypeAnnotation)) { + return; + } + + if (t.isTSUnionType(actualTypeAnnotation)) { + if ( + actualTypeAnnotation.types.some(type => t.isTSUndefinedKeyword(type)) + ) { + return; + } + typeAnnotation.typeAnnotation = t.tsUnionType([ + ...actualTypeAnnotation.types, + t.tsUndefinedKeyword(), + ]); + return; + } + + typeAnnotation.typeAnnotation = t.tsUnionType([ + actualTypeAnnotation, + t.tsUndefinedKeyword(), + ]); + }, + }, +}; + +module.exports = visitor; diff --git a/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js b/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js index 195ec9262d85..d4318882cc18 100644 --- a/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js +++ b/scripts/js-api/build-types/transforms/typescript/removeUndefinedFromOptionalMembers.js @@ -29,11 +29,27 @@ const visitor: PluginObj = { return; } - const newTypeAnnotation = t.cloneDeep(actualTypeAnnotation); - newTypeAnnotation.types = newTypeAnnotation.types.filter( + const remainingTypes = actualTypeAnnotation.types.filter( type => t.isTSUndefinedKeyword(type) === false, ); - typeAnnotation.typeAnnotation = newTypeAnnotation; + if (remainingTypes.length === actualTypeAnnotation.types.length) { + return; + } + + // Unwrap to the bare type when a single constituent remains, otherwise a + // wrapper node survives that prints identically but breaks downstream + // transforms (e.g. `sortProperties`) that key on the node type. The lone + // constituent may be parenthesized (a function type inside a union needs + // parens, e.g. `(() => void) | undefined`); those parens are redundant + // once it is no longer part of a union. + if (remainingTypes.length === 1) { + const [remaining] = remainingTypes; + typeAnnotation.typeAnnotation = t.isTSParenthesizedType(remaining) + ? remaining.typeAnnotation + : remaining; + } else { + typeAnnotation.typeAnnotation = t.tsUnionType(remainingTypes); + } }, }, }; diff --git a/scripts/js-api/build-types/translateSourceFile.js b/scripts/js-api/build-types/translateSourceFile.js index 94ec10e915b7..089ed11d6f2e 100644 --- a/scripts/js-api/build-types/translateSourceFile.js +++ b/scripts/js-api/build-types/translateSourceFile.js @@ -32,6 +32,7 @@ const preTransforms: Array = [ ]; const postTransforms = (filePath: string): Array> => [ require('./transforms/typescript/convertTypeAliasesToInterfaces'), + require('./transforms/typescript/ensureUndefinedOnOptionalMembers'), require('./transforms/typescript/replaceProtectedConstructors'), require('./transforms/typescript/replaceDefaultExportName')(filePath), ]; From 827d27363b81aad938581f23ad787522c62dd202 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 05:59:32 -0500 Subject: [PATCH 2/5] fix: sync final 0.87 SwiftPM and runtime compatibility corrections Curate eighteen upstream corrections for module names, configuration exports, build settings, plugin phases, prebuilt swaps, Animated types and IntentModule re-entrancy. Preserve fork APIs and the mainline version policy. Independent review verified 1033 tests, 131 snapshots, strict/legacy type consumers and fourteen Ruby tests. The Android runtime regression test remains unexecuted pending Android validation. --- __docs__/README.md | 1 + .../Libraries/Animated/AnimatedEvent.js | 2 +- .../Animated/AnimatedImplementation.js | 8 +- .../Libraries/Animated/nodes/AnimatedNode.js | 2 +- .../Libraries/Animated/nodes/AnimatedValue.js | 4 +- .../react/modules/intent/IntentModule.kt | 10 +- .../react/modules/intent/IntentModuleTest.kt | 143 +++ packages/react-native/ReactNativeApi.d.ts | 29 +- .../__typetests__/macos-public-api.ts | 17 + packages/react-native/react-native.config.js | 6 + .../__tests__/replace-rncore-version-test.js | 79 ++ .../__tests__/rndependencies-test.rb | 89 ++ .../scripts/cocoapods/rndependencies.rb | 56 +- .../scripts/ios-prebuild/__docs__/README.md | 8 +- .../scripts/replace-rncore-version.js | 20 +- .../react-native/scripts/setup-apple-spm.js | 40 +- .../spm/__doc__/rfc-spm-xcframework.md | 707 --------------- .../spm/__doc__/spm-autolinking-plugins.md | 244 ----- .../spm/__doc__/spm-header-paths-contract.md | 97 -- .../spm/__doc__/spm-plugins-assessment.md | 128 --- .../scripts/spm/__doc__/spm-scripts.md | 486 ---------- .../scripts/spm/__docs__/README.md | 109 +++ .../spm/__docs__/spm-autolinking-plugins.md | 359 ++++++++ .../spm/__docs__/spm-header-paths-contract.md | 100 ++ .../scripts/spm/__docs__/spm-scripts.md | 646 +++++++++++++ .../spm/__tests__/autolinking-plugins-test.js | 200 ++++ .../spm/__tests__/cli-config-command-test.js | 29 + .../__tests__/expand-spm-dependencies-test.js | 598 +++++++++++- .../generate-spm-autolinking-test.js | 551 ++++++++++- .../__tests__/generate-spm-package-test.js | 53 ++ .../__tests__/generate-spm-xcodeproj-test.js | 215 +++++ .../__tests__/inject-spm-xcodeproj-test.js | 765 +++++++++++++++- .../scripts/spm/__tests__/pbxproj-oracles.js | 33 + .../__tests__/remove-spm-injection-test.js | 856 +++++++++++++++++- .../__tests__/scaffold-package-swift-test.js | 183 ++++ .../spm/__tests__/setup-apple-spm-test.js | 89 ++ .../scripts/spm/__tests__/spm-pbxproj-test.js | 203 ++++- .../scripts/spm/__tests__/spm-utils-test.js | 94 ++ .../scripts/spm/autolinking-plugins.js | 80 ++ .../scripts/spm/expand-spm-dependencies.js | 279 +++++- .../scripts/spm/generate-spm-autolinking.js | 197 +++- .../scripts/spm/generate-spm-package.js | 48 +- .../scripts/spm/generate-spm-xcodeproj.js | 666 ++++++++++++-- .../scripts/spm/scaffold-package-swift.js | 89 +- .../react-native/scripts/spm/spm-pbxproj.js | 186 +++- .../react-native/scripts/spm/spm-types.js | 32 +- .../react-native/scripts/spm/spm-utils.js | 119 ++- .../animated/__tests__/AnimatedNative-test.js | 2 +- packages/rn-tester/react-native.config.js | 1 - 49 files changed, 6961 insertions(+), 1997 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt create mode 100644 packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb delete mode 100644 packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md delete mode 100644 packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md delete mode 100644 packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md delete mode 100644 packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md delete mode 100644 packages/react-native/scripts/spm/__doc__/spm-scripts.md create mode 100644 packages/react-native/scripts/spm/__docs__/README.md create mode 100644 packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md create mode 100644 packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md create mode 100644 packages/react-native/scripts/spm/__docs__/spm-scripts.md create mode 100644 packages/react-native/scripts/spm/__tests__/cli-config-command-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js diff --git a/__docs__/README.md b/__docs__/README.md index dd25a3fd248d..6ce5cdddef3e 100644 --- a/__docs__/README.md +++ b/__docs__/README.md @@ -80,6 +80,7 @@ TODO: Explain the different components of React Native at a high level. - Build system - Android - iOS + - [SwiftPM](../packages/react-native/scripts/spm/__docs__/README.md) - C++ - JavaScript - Metro diff --git a/packages/react-native/Libraries/Animated/AnimatedEvent.js b/packages/react-native/Libraries/Animated/AnimatedEvent.js index 3f1245e3fd27..29647ce76051 100644 --- a/packages/react-native/Libraries/Animated/AnimatedEvent.js +++ b/packages/react-native/Libraries/Animated/AnimatedEvent.js @@ -199,7 +199,7 @@ export class AnimatedEvent { this._attachedEvent && this._attachedEvent.detach(); } - __getHandler(): any | ((...args: any) => void) { + __getHandler(): (...args: any) => void { if (this.__isNative) { if (__DEV__) { let validatedMapping = false; diff --git a/packages/react-native/Libraries/Animated/AnimatedImplementation.js b/packages/react-native/Libraries/Animated/AnimatedImplementation.js index 4a9d76a4806a..7fc01bb27249 100644 --- a/packages/react-native/Libraries/Animated/AnimatedImplementation.js +++ b/packages/react-native/Libraries/Animated/AnimatedImplementation.js @@ -576,10 +576,14 @@ function unforkEventImpl( } } -const eventImpl = function ( +// NOTE: With `useNativeDriver: true` this returns an `AnimatedEvent` instance +// rather than a callable handler. That object is only ever meant to be handed +// straight back to an animated component's event prop, so the declared type +// describes the handler shape both branches are consumed as. +const eventImpl: ( argMapping: ReadonlyArray, config: EventConfig, -): any { +) => (...args: Array) => void = function (argMapping, config): any { const animatedEvent = new AnimatedEvent(argMapping, config); if (animatedEvent.__isNative) { return animatedEvent; diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js index b10fe9da8bee..d4e2d7db8d8b 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js @@ -13,7 +13,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; import invariant from 'invariant'; -type ValueListenerCallback = (state: {value: number, ...}) => unknown; +export type ValueListenerCallback = (state: {value: number}) => unknown; export type AnimatedNodeConfig = Readonly<{ debugID?: string, diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index 76dba2196f48..c393e27cf3ea 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -17,7 +17,7 @@ import type { InterpolationConfigType, } from './AnimatedInterpolation'; import type AnimatedNode from './AnimatedNode'; -import type {AnimatedNodeConfig} from './AnimatedNode'; +import type {AnimatedNodeConfig, ValueListenerCallback} from './AnimatedNode'; import type AnimatedTracking from './AnimatedTracking'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; @@ -138,7 +138,7 @@ export default class AnimatedValue extends AnimatedWithChildren { } } - addListener(callback: (value: any) => unknown): string { + addListener(callback: ValueListenerCallback): string { const id = super.addListener(callback); this._listenerCount++; if (this.__isNative) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt index 0288966c6fa7..b767f1657e8f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt @@ -87,11 +87,15 @@ public open class IntentModule(reactContext: ReactApplicationContext) : override fun onHostResume() { reactApplicationContext.removeLifecycleEventListener(this) synchronized(this@IntentModule) { - for (pendingPromise in pendingOpenURLPromises) { + // getInitialURL can re-enter and re-add to pendingOpenURLPromises when the activity + // is still null at resume, so drain a snapshot (after clearing the list and listener) + // to avoid mutating the list being iterated (ConcurrentModificationException). + val pendingPromises = ArrayList(pendingOpenURLPromises) + pendingOpenURLPromises.clear() + initialURLListener = null + for (pendingPromise in pendingPromises) { getInitialURL(pendingPromise) } - initialURLListener = null - pendingOpenURLPromises.clear() } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt new file mode 100644 index 000000000000..6528449969bd --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/intent/IntentModuleTest.kt @@ -0,0 +1,143 @@ +/* + * 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. + */ + +package com.facebook.react.modules.intent + +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IntentModuleTest { + + private lateinit var context: ReactApplicationContext + private lateinit var intentModule: IntentModule + + @Before + fun setUp() { + context = mock() + intentModule = IntentModule(context) + } + + /** + * Regression test for the ConcurrentModificationException thrown from onHostResume. When the + * current activity is still null at resume time, draining pendingOpenURLPromises re-enters + * getInitialURL -> waitForActivityAndGetInitialURL, which adds back into the same list. Before + * the fix this mutated the list mid-iteration and crashed. The synchronized guard does not help: + * the re-entrancy is on the same thread and already holds the lock. + */ + @Test + fun getInitialURL_onHostResumeWithNullActivity_doesNotThrowAndPreservesPromise() { + // No current activity: getInitialURL queues the promise and registers a lifecycle listener. + whenever(context.currentActivity).thenReturn(null) + + val promise = SimplePromise() + intentModule.getInitialURL(promise) + + val listenerCaptor = argumentCaptor() + verify(context).addLifecycleEventListener(listenerCaptor.capture()) + + // Resume while the activity is still null (e.g. a deep link landing mid activity-transition). + // The drain re-queues the promise; before the fix this threw ConcurrentModificationException. + assertThatCode { listenerCaptor.firstValue.onHostResume() }.doesNotThrowAnyException() + + // The promise was re-queued rather than silently dropped: a fresh listener is registered for + // the next resume, and the promise is left pending (neither resolved nor rejected). + verify(context, times(2)).addLifecycleEventListener(any()) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.rejected).isEqualTo(0) + } + + internal class SimplePromise : Promise { + companion object { + private const val ERROR_DEFAULT_CODE = "EUNSPECIFIED" + private const val ERROR_DEFAULT_MESSAGE = "Error not specified." + } + + var resolved = 0 + private set + + var rejected = 0 + private set + + var value: Any? = null + private set + + var errorCode: String? = null + private set + + var errorMessage: String? = null + private set + + override fun resolve(value: Any?) { + resolved++ + this.value = value + } + + override fun reject(code: String?, message: String?) { + reject(code, message, null, null) + } + + override fun reject(code: String?, throwable: Throwable?) { + reject(code, null, throwable, null) + } + + override fun reject(code: String?, message: String?, throwable: Throwable?) { + reject(code, message, throwable, null) + } + + override fun reject(throwable: Throwable) { + reject(null, null, throwable, null) + } + + override fun reject(throwable: Throwable, userInfo: WritableMap) { + reject(null, null, throwable, userInfo) + } + + override fun reject(code: String?, userInfo: WritableMap) { + reject(code, null, null, userInfo) + } + + override fun reject(code: String?, throwable: Throwable?, userInfo: WritableMap) { + reject(code, null, throwable, userInfo) + } + + override fun reject(code: String?, message: String?, userInfo: WritableMap) { + reject(code, message, null, userInfo) + } + + override fun reject( + code: String?, + message: String?, + throwable: Throwable?, + userInfo: WritableMap?, + ) { + rejected++ + + errorCode = code ?: ERROR_DEFAULT_CODE + errorMessage = message ?: throwable?.message ?: ERROR_DEFAULT_MESSAGE + } + + @Deprecated("Method deprecated", ReplaceWith("reject(code, message)")) + override fun reject(message: String) { + reject(null, message, null, null) + } + } +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index e21c029dd30a..add9788a5ac2 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -243,7 +243,7 @@ declare const event: typeof $$AnimatedImplementation.event declare const eventImpl: ( argMapping: ReadonlyArray, config: EventConfig, -) => any +) => (...args: Array) => void declare const findNodeHandle: typeof $$ReactFabric.findNodeHandle declare const flatten: typeof $$flattenStyle declare const forkEvent: typeof $$AnimatedImplementation.forkEvent @@ -1463,7 +1463,7 @@ declare class AnimatedTracking_default extends AnimatedNode_default { update(): void } declare class AnimatedValue_default extends AnimatedWithChildren_default { - addListener(callback: (value: any) => unknown): string + addListener(callback: ValueListenerCallback): string animate( animation: Animation_default, callback: EndCallback | null | undefined, @@ -5758,6 +5758,7 @@ declare type UTFSequence = typeof UTFSequence declare type Value = null | { horizontal: boolean } +declare type ValueListenerCallback = (state: { value: number }) => unknown declare type ValueOfUnion = T extends any ? K extends keyof T ? T[K] @@ -6002,7 +6003,7 @@ export { AlertOptions, // 702d616d AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // bc2c3899 + Animated, // a4a0149e AppConfig, // e4e0157b AppRegistry, // b1f0909a AppState, // 12012be5 @@ -6016,8 +6017,8 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // 4ba4f941 BoxShadowValue, // b679703f - Button, // f9e54915 - ButtonInstance, // 516c48f2 + Button, // dbbcc9b2 + ButtonInstance, // 5f7f9dc3 ButtonProps, // d0ebae51 Clipboard, // 41addb89 CodegenTypes, // ab4986cc @@ -6203,10 +6204,10 @@ export { ScrollEvent, // 10f01ee3 ScrollResponderType, // d9133bdb ScrollToLocationParamsType, - ScrollView, // 0d767930 + ScrollView, // 41883e5c ScrollViewImperativeMethods, // 288bc3d3 ScrollViewInstance, // 970f388f - ScrollViewProps, // 0b7e6b60 + ScrollViewProps, // a3eabe47 ScrollViewPropsAndroid, // 02f3df2e ScrollViewPropsIOS, // aa75a790 ScrollViewScrollToOptions, // 3313411e @@ -6231,7 +6232,7 @@ export { StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea StyleProp, // fa0e9b4a - StyleSheet, // d8d99bbd + StyleSheet, // 3d36407f SubmitBehavior, // c4ddf490 Switch, // d22e6b0f SwitchChangeEvent, // 899635b1 @@ -6269,9 +6270,9 @@ export { TouchableNativeFeedback, // 406004d7 TouchableNativeFeedbackInstance, // 1319896f TouchableNativeFeedbackProps, // 0b603dd4 - TouchableOpacity, // 54ca206c + TouchableOpacity, // 6e8ffc07 TouchableOpacityInstance, // b186055b - TouchableOpacityProps, // 8dd85d25 + TouchableOpacityProps, // 18ef30ce TouchableWithoutFeedback, // 8234d9b8 TouchableWithoutFeedbackProps, // 0c126762 TransformsStyle, // 65e70f18 @@ -6301,9 +6302,9 @@ export { processColor, // 6e877698 registerCallableModule, // 839c8cfe requireNativeComponent, // f133b4a3 - useAnimatedColor, // a9dffd9f - useAnimatedValue, // 25733d13 - useAnimatedValueXY, // 04c70878 + useAnimatedColor, // 31a919f9 + useAnimatedValue, // 3eb9d3c0 + useAnimatedValueXY, // b434ca0f useColorScheme, // d585efdb usePressability, // 60418f41 useWindowDimensions, // bb4b683f diff --git a/packages/react-native/__typetests__/macos-public-api.ts b/packages/react-native/__typetests__/macos-public-api.ts index 7c3f1a7e5f03..2397c0fc7f2b 100644 --- a/packages/react-native/__typetests__/macos-public-api.ts +++ b/packages/react-native/__typetests__/macos-public-api.ts @@ -8,6 +8,7 @@ */ // Use the actual package export conditions, without the react-native test alias. +import {Animated} from 'react-native-macos'; import type { SystemEffectMacOS, TextInputMacOSProps, @@ -28,6 +29,22 @@ import type { } from '../Libraries/Types/CoreEventTypes'; type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type AnimatedEventIsNotAny = Assert< + IsAny> extends false ? true : false +>; +const animatedValue = new Animated.Value(0); +const animatedHandler: (...args: any[]) => void = Animated.event( + [{nativeEvent: {value: animatedValue}}], + {useNativeDriver: false}, +); +animatedValue.addListener(state => { + const value: number = state.value; + // @ts-expect-error The callback payload is typed rather than any. + const invalidValue: string = state.value; + // @ts-expect-error The callback exposes only its numeric value. + state.missing; +}); type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true diff --git a/packages/react-native/react-native.config.js b/packages/react-native/react-native.config.js index 17b48efd225e..0528de4431f8 100644 --- a/packages/react-native/react-native.config.js +++ b/packages/react-native/react-native.config.js @@ -192,6 +192,11 @@ const spmCommand /*: Command */ = { name: '--skipCodegen', description: '[advanced] Skip the react-native codegen step.', }, + { + name: '--configCommand ', + description: + '[advanced] JSON argv array for the autolinking config command; persisted for later syncs.', + }, ], func: async (argv, _config, args) => { const passthrough /*: Array */ = []; @@ -204,6 +209,7 @@ const spmCommand /*: Command */ = { ['xcodeproj', '--xcodeproj'], ['artifacts', '--artifacts'], ['download', '--download'], + ['configCommand', '--config-command'], ]; for (const [key, flag] of stringOpts) { if (args[key] != null) { diff --git a/packages/react-native/scripts/__tests__/replace-rncore-version-test.js b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js index f435e2fa1a9d..ad077aa93ddf 100644 --- a/packages/react-native/scripts/__tests__/replace-rncore-version-test.js +++ b/packages/react-native/scripts/__tests__/replace-rncore-version-test.js @@ -19,6 +19,18 @@ const path = require('node:path'); const VERSION = '0.87.0-test'; const SLICE = 'ios-arm64_x86_64-simulator'; const BINARY = path.join(SLICE, 'React.framework', 'React'); +const SCRIPT = require.resolve('../replace-rncore-version.js'); +const MARKER = path.join('React-Core-prebuilt', '.last_build_configuration'); + +// Runs the script the way the "[RNCore] Replace React Native Core for the right +// configuration" build phase does, from Pods/ and through the CLI entry point. +function runScriptPhase(podsRoot, configuration) { + return execFileSync( + process.execPath, + [SCRIPT, '-c', configuration, '-r', VERSION, '-p', podsRoot], + {cwd: podsRoot, encoding: 'utf8'}, + ); +} function writeFile(filePath, contents) { fs.mkdirSync(path.dirname(filePath), {recursive: true}); @@ -119,6 +131,73 @@ describe('replaceRNCoreConfiguration', () => { expect(fs.readFileSync(expoModuleMap, 'utf8')).toBe('module React {}\n'); }); + // Regression tests for #57598. The marker used to be written only after the + // framework had already been replaced, so a build cancelled in between left it + // naming a flavor that was no longer on disk. Every later build for that + // flavor then took the "nothing to do" path and linked against the other + // configuration's core, which fails with undefined C++ symbols and which a + // clean does not undo because the pod directory survives it. + describe('marker bookkeeping', () => { + const marker = () => path.join(podsRoot, MARKER); + const binary = () => + fs.readFileSync(path.join(pod, 'React.xcframework', BINARY), 'utf8'); + + it('records the configuration when it skips a fresh install', () => { + // `pod install` leaves the debug flavor and no marker, so a Debug build + // has nothing to swap. It still has to write down what is on disk, + // otherwise the state stays implicit and stays unverifiable. + expect(fs.existsSync(marker())).toBe(false); + + runScriptPhase(podsRoot, 'Debug'); + + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + expect(binary()).toBe('binary-Debug'); + }); + + it('invalidates the marker before it touches the framework', () => { + fs.writeFileSync(marker(), 'Debug'); + // Drop the tarball so the Release run fails once it is already under way, + // standing in for a build cancelled part way through the swap. + fs.rmSync( + path.join( + podsRoot, + 'ReactNativeCore-artifacts', + `reactnative-core-${VERSION.toLowerCase()}-release.tar.gz`, + ), + ); + + expect(() => runScriptPhase(podsRoot, 'Release')).toThrow(); + + // The marker must no longer claim Debug: the framework may already have + // been swapped, and a Debug build that trusts it would silently skip. + expect(fs.readFileSync(marker(), 'utf8')).not.toBe('Debug'); + }); + + it('replaces the framework when the marker shows an unfinished swap', () => { + buildTarball(podsRoot, 'Debug'); + // A swap that was interrupted: the Release flavor is on disk and the + // marker never got its final value. + replaceRNCoreConfiguration('Release', VERSION, podsRoot); + fs.writeFileSync(marker(), 'in-progress'); + expect(binary()).toBe('binary-Release'); + + runScriptPhase(podsRoot, 'Debug'); + + expect(binary()).toBe('binary-Debug'); + expect(fs.readFileSync(marker(), 'utf8')).toBe('Debug'); + }); + + it('still skips when the marker already matches the configuration', () => { + fs.writeFileSync(marker(), 'Release'); + const before = binary(); + + const output = runScriptPhase(podsRoot, 'Release'); + + expect(output).toContain('No need to replace React-Core-prebuilt'); + expect(binary()).toBe(before); + }); + }); + it('fails when the tarball has no React.xcframework', () => { const stage = fs.mkdtempSync(path.join(podsRoot, 'stage-bad-')); writeFile(path.join(stage, 'unrelated.txt'), 'nope'); diff --git a/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb b/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb new file mode 100644 index 000000000000..854a3ecefa60 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__tests__/rndependencies-test.rb @@ -0,0 +1,89 @@ +# 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. + +require "test/unit" +require "shellwords" +require_relative "../rndependencies.rb" +require_relative "./test_utils/SpecMock.rb" + +class RNDependenciesTests < Test::Unit::TestCase + + # A pod that exports a Swift compatibility header ships this path, and the + # directory name contains spaces. + SWIFT_HEADER = "${PODS_CONFIGURATION_BUILD_DIR}/MyPod/Swift Compatibility Header" + + def teardown + ReactNativeDependenciesUtils.class_variable_set(:@@build_from_source, true) + end + + # Xcode joins an array setting with spaces, then splits it back on + # whitespace while honouring quotes. This is what the compiler ends up with. + def resolved_paths(xcconfig) + value = xcconfig["HEADER_SEARCH_PATHS"] + Shellwords.shellsplit(value.is_a?(Array) ? value.join(" ") : value) + end + + # ================================== # + # TEST - append_header_search_paths # + # ================================== # + + def test_appendHeaderSearchPaths_whenUnset_quotesTheAddedPaths + xcconfig = {} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["\"$(PODS_ROOT)/glog\""], xcconfig["HEADER_SEARCH_PATHS"]) + end + + def test_appendHeaderSearchPaths_whenStringHasQuotedPathWithSpaces_keepsItIntact + xcconfig = {"HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/DoubleConversion\" \"#{SWIFT_HEADER}\""} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["$(PODS_ROOT)/DoubleConversion", SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + def test_appendHeaderSearchPaths_whenArrayHasQuotedPathWithSpaces_keepsItIntact + xcconfig = {"HEADER_SEARCH_PATHS" => ["\"$(PODS_ROOT)/DoubleConversion\"", "\"#{SWIFT_HEADER}\""]} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal(["$(PODS_ROOT)/DoubleConversion", SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + def test_appendHeaderSearchPaths_whenCalledTwice_doesNotDuplicateEntries + xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + ReactNativeDependenciesUtils.append_header_search_paths(xcconfig, ["$(PODS_ROOT)/glog"]) + + assert_equal([SWIFT_HEADER, "$(PODS_ROOT)/glog"], resolved_paths(xcconfig)) + end + + # ======================================= # + # TEST - add_rn_third_party_dependencies # + # ======================================= # + + def test_addRNThirdPartyDependencies_whenBuildingFromSource_keepsQuotedPathWithSpaces + spec = SpecMock.new + spec.pod_target_xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + add_rn_third_party_dependencies(spec) + + paths = resolved_paths(spec.pod_target_xcconfig) + assert_equal(SWIFT_HEADER, paths.first) + assert(paths.include?("$(PODS_ROOT)/RCT-Folly")) + end + + def test_addRNThirdPartyDependencies_whenUsingPrebuiltDeps_keepsQuotedPathWithSpaces + ReactNativeDependenciesUtils.class_variable_set(:@@build_from_source, false) + spec = SpecMock.new + spec.pod_target_xcconfig = {"HEADER_SEARCH_PATHS" => "\"#{SWIFT_HEADER}\""} + + add_rn_third_party_dependencies(spec) + + assert_equal([SWIFT_HEADER, "$(PODS_ROOT)/ReactNativeDependencies/Headers"], resolved_paths(spec.pod_target_xcconfig)) + end +end diff --git a/packages/react-native/scripts/cocoapods/rndependencies.rb b/packages/react-native/scripts/cocoapods/rndependencies.rb index a88fa6e483f9..e5ce5a6551fa 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -6,7 +6,6 @@ require "json" require 'net/http' require 'rexml/document' -require 'shellwords' require_relative './utils.rb' @@ -34,38 +33,26 @@ def add_rn_third_party_dependencies(s) s.dependency "RCT-Folly/Fabric" end - header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] - - if header_search_paths.is_a?(String) - header_search_paths = Shellwords.shellsplit(header_search_paths) - end - - header_search_paths << "$(PODS_ROOT)/glog" - header_search_paths << "$(PODS_ROOT)/boost" - header_search_paths << "$(PODS_ROOT)/DoubleConversion" - header_search_paths << "$(PODS_ROOT)/fast_float/include" - header_search_paths << "$(PODS_ROOT)/fmt/include" - header_search_paths << "$(PODS_ROOT)/SocketRocket" - header_search_paths << "$(PODS_ROOT)/RCT-Folly" - - # uniq so a second call on the same spec can't duplicate entries. - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq + ReactNativeDependenciesUtils.append_header_search_paths(current_pod_target_xcconfig, [ + "$(PODS_ROOT)/glog", + "$(PODS_ROOT)/boost", + "$(PODS_ROOT)/DoubleConversion", + "$(PODS_ROOT)/fast_float/include", + "$(PODS_ROOT)/fmt/include", + "$(PODS_ROOT)/SocketRocket", + "$(PODS_ROOT)/RCT-Folly", + ]) else # Prebuilt-deps mode: this pod SELF-SERVES the third-party headers from its # own xcframework (incl. SocketRocket - sole supplier in this mode). See # scripts/cocoapods/__docs__/prebuilt-deps.md for the full contract. s.dependency "ReactNativeDependencies" - header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] - if header_search_paths.is_a?(String) - header_search_paths = Shellwords.shellsplit(header_search_paths) - end # Artifact headers are flattened into the pod-local Headers/ by the podspec # prepare_command (see __docs__/prebuilt-deps.md). - header_search_paths << "$(PODS_ROOT)/ReactNativeDependencies/Headers" - - # uniq so a second call on the same spec can't duplicate entries. - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq + ReactNativeDependenciesUtils.append_header_search_paths(current_pod_target_xcconfig, [ + "$(PODS_ROOT)/ReactNativeDependencies/Headers", + ]) end s.pod_target_xcconfig = current_pod_target_xcconfig @@ -148,6 +135,25 @@ def self.setup_react_native_dependencies(react_native_path, react_native_version end end + # Xcode splits HEADER_SEARCH_PATHS on whitespace, so every path we add is + # quoted - PODS_ROOT can expand to a directory with spaces in its name. + # Paths already in the xcconfig are left untouched: they carry the podspec + # author's own quoting, and re-quoting them would break it. + def self.append_header_search_paths(xcconfig, paths) + quoted = paths.map { |path| "\"#{path}\"" } + existing = xcconfig["HEADER_SEARCH_PATHS"] + + # reject so a second call on the same spec can't duplicate entries. + case existing + when nil + xcconfig["HEADER_SEARCH_PATHS"] = quoted + when Array + xcconfig["HEADER_SEARCH_PATHS"] = existing + quoted.reject { |path| existing.include?(path) } + else + xcconfig["HEADER_SEARCH_PATHS"] = ([existing] + quoted.reject { |path| existing.include?(path) }).join(" ") + end + end + def self.abort_if_use_local_rndeps_with_no_file() if !File.exist?(ENV["RCT_USE_LOCAL_RN_DEP"]) abort("RCT_USE_LOCAL_RN_DEP is set to #{ENV["RCT_USE_LOCAL_RN_DEP"]} but the file does not exist!") diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index c411934c1742..70411582212b 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -138,7 +138,13 @@ 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. + module map). CocoaPods consumes that layout directly, through + `FRAMEWORK_SEARCH_PATHS`. SwiftPM consumes the same headers indirectly: the + XCFramework is not a member of the Swift package graph, so the consumer side + stages a copy of `React.framework/Headers` into + `ReactHeadersTarget/include/React` and rewrites `framework module React` to a + plain `module React`, vended as the `ReactHeaders` target (see + `spm-header-paths-contract.md` in the SwiftPM docs). - `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). diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index d0810efee5a4..0d0e93da8927 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -18,6 +18,9 @@ const yargs = require('yargs'); const LAST_BUILD_FILENAME = 'React-Core-prebuilt/.last_build_configuration'; +// Not a valid configuration, so finding it means the swap did not finish. +const REPLACEMENT_IN_PROGRESS = 'in-progress'; + function validateBuildConfiguration(configuration /*: string */) { if (!['Debug', 'Release'].includes(configuration)) { throw new Error(`Invalid configuration ${configuration}`); @@ -42,10 +45,12 @@ function shouldReplaceRnCoreConfiguration(configuration /*: string */) { ); return false; } + return true; } - // Assumption: if there is no stored last build, we assume that it was build for debug. - if (!fileExists && configuration === 'Debug') { + // With no marker the on-disk flavor is Debug: the podspec installs the debug + // tarball (see resolve_podspec_source in scripts/cocoapods/rncore.rb). + if (configuration === 'Debug') { console.log( 'No previous build detected, but Debug Configuration. No need to replace React-Core-prebuilt', ); @@ -130,6 +135,10 @@ function updateLastBuildConfiguration(configuration /*: string */) { fs.writeFileSync(LAST_BUILD_FILENAME, configuration); } +function markReplacementInProgress() /*: void */ { + fs.writeFileSync(LAST_BUILD_FILENAME, REPLACEMENT_IN_PROGRESS); +} + function main( configuration /*: string */, version /*: string */, @@ -139,9 +148,16 @@ function main( validateVersion(version); if (!shouldReplaceRnCoreConfiguration(configuration)) { + // A fresh install leaves no marker; record the flavor we skipped on. + if (!fs.existsSync(LAST_BUILD_FILENAME)) { + updateLastBuildConfiguration(configuration); + } return; } + // Invalidate before touching the framework so an interrupted swap is + // detectable on the next run. + markReplacementInProgress(); replaceRNCoreConfiguration(configuration, version, podsRoot); updateLastBuildConfiguration(configuration); console.log('Done replacing React Native prebuilt'); diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 8aebcf0700c5..0ad73872420f 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -44,8 +44,10 @@ * directing you to `--deintegrate`). * * Options: - * --version React Native version (default: the resolved - * node_modules/react-native version). + * --version React Native version. Pinned into + * .spm-injected.json and reused by later runs + * until a new one is passed (default: the + * resolved node_modules/react-native version). * --yes Skip the dirty-pbxproj confirmation prompt. * [add] --xcodeproj Which .xcodeproj to inject into (when several). * [add] --product-name Which app target to inject into (when several). @@ -96,6 +98,7 @@ const { cleanupLeftoverPodsGroup, findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, + readArtifactsVersionOverride, readPinnedConfigCommand, removeSpmInjection, } = require('./spm/generate-spm-xcodeproj'); @@ -153,7 +156,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { .option('version', { type: 'string', describe: - 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json', + 'React Native version (e.g. 0.80.0). Sticks: later runs reuse it until you pass a new one. Defaults to the version in node_modules/react-native/package.json', }) .option('yes', { type: 'boolean', @@ -377,19 +380,31 @@ function resolveReactNativeRoot( return reactNativeRoot; } +// Explicit `--version` → the version an earlier `--version` pinned into the +// injection marker → node_modules/react-native/package.json. The pin makes +// `--version` stick for later flagless runs, which would otherwise re-point the +// project at a different artifact slot than the one it was wired to. function determineVersion( args /*: SetupArgs */, reactNativeRoot /*: string */, + appRoot /*: string */, ) /*: string */ { - let version = args.version; - if (version == null) { - // $FlowFixMe[incompatible-type] JSON.parse returns any - const pkgJson /*: {version: string} */ = JSON.parse( - fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + if (args.version != null) { + return args.version; + } + const pinned = readArtifactsVersionOverride(appRoot); + if (pinned != null) { + log( + `Using version ${pinned} pinned in ${SPM_INJECTED_MARKER} by an earlier ` + + '--version. Pass --version to change it.', ); - version = pkgJson.version; + return pinned; } - return version; + // $FlowFixMe[incompatible-type] JSON.parse returns any + const pkgJson /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(reactNativeRoot, 'package.json'), 'utf8'), + ); + return pkgJson.version; } function runCodegenStep( @@ -442,7 +457,7 @@ async function runScaffold( // a comment — that's how SPM's manifest hash bumps on slot transitions. let cacheSlotLabel /*: ?string */ = null; try { - const rawVersion = args.version ?? determineVersion(args, reactNativeRoot); + const rawVersion = determineVersion(args, reactNativeRoot, appRoot); const slotVersion = await resolveCacheSlotVersion(rawVersion); cacheSlotLabel = `${slotVersion}/dual-flavor`; } catch { @@ -1094,7 +1109,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { autolinkingConfigResult, projectRoot, ); - const version = determineVersion(args, reactNativeRoot); + const version = determineVersion(args, reactNativeRoot, appRoot); log(`React Native version: ${version}`); // Resolve remote SPM mode ONCE up front. remotePackageConfig throws @@ -1256,6 +1271,7 @@ if (require.main === module) { module.exports = { main, detectStandardRnLayoutRedirect, + determineVersion, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, diff --git a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md deleted file mode 100644 index 86116637e3f7..000000000000 --- a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md +++ /dev/null @@ -1,707 +0,0 @@ ---- -title: Swift Package Manager Support for React Native iOS -author: -- Christian Falch -date: 2026-03-17 ---- - -# RFC: Swift Package Manager Support for React Native iOS - -## Summary - -Add Swift Package Manager (SPM) as an officially supported build system for -React Native iOS apps, alongside CocoaPods. The approach uses **prebuilt -XCFrameworks** published to Maven, eliminating the need for source compilation -of React Native internals and enabling fast, reproducible builds. - -## Basic example - -### New project - -```bash -npx react-native init MyApp -cd MyApp -npx react-native spm # auto-detects first-run → init; prompts to rename legacy CocoaPods xcodeproj -npm run ios -``` - -A future CLI integration (e.g., an `--ios-build-system spm` flag on -`react-native init`) could run `react-native spm init` automatically as part -of project creation, eliminating the manual step. - -### Existing project - -```bash -cd MyApp -npx react-native spm -# Prompted: rename CocoaPods MyApp.xcodeproj → MyApp.xcodeproj.legacy? -# Accept (Y) — the SPM xcodeproj writes to the now-free MyApp.xcodeproj slot, -# `npm run ios` resolves to it unambiguously. The legacy stays on disk -# (git mv tracks the rename cleanly) for rollback via `spm clean --project`. -``` - -After initial setup, day-to-day development requires no extra commands. Adding -or removing JS dependencies that include native code is handled automatically -by a build-phase sync step (see [Auto-sync build phase](#auto-sync-build-phase)). - -## Motivation - -### Apple is moving away from CocoaPods - -SPM is Apple's endorsed dependency manager. Xcode's SPM integration improves -with every release — package resolution, build caching, and IDE features all -assume SPM as the primary workflow. CocoaPods is community-maintained and has -been officially sunsetted — the CocoaPods trunk will become permanently -read-only on **December 2, 2026**, after which no new pods or updates can be -published ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)). -Existing builds will continue to work, but the ecosystem is moving on. - -### Build speed - -Prebuilt XCFrameworks skip compilation of ~2000 C++/Objective-C files. A clean -SPM build of rn-tester compiles only app sources and codegen output. This is a -significant improvement for CI pipelines and developer iteration speed. - -### Reduced onboarding friction - -CocoaPods requires Ruby, Bundler, and a working gem environment — a frequent -source of setup issues, especially on new machines or in CI. SPM requires only -Xcode. Removing the Ruby toolchain dependency simplifies onboarding and reduces -the surface area for environment-related build failures. - -### Adoption barrier - -Many organizations mandate SPM for iOS dependencies. Teams in these -environments are currently blocked from adopting React Native, or must maintain -custom workarounds. First-class SPM support might help overcoming this barrier. - -### Compatibility - -The SPM workflow generates an `AppName.xcodeproj` that takes the same -filename slot as the legacy CocoaPods xcodeproj. On `init`, the script -prompts to rename the existing CocoaPods project to `AppName.xcodeproj.legacy` -— preserving it for rollback while letting the community CLI's -`findXcodeProject` resolve `npm run ios` to the SPM project unambiguously. -Teams can migrate at their own pace before CocoaPods trunk goes read-only -in December 2026, and `spm clean --project` reverses the migration when -needed. - -## Detailed design - -### Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ Maven (artifacts) │ -│ ├── React.xcframework (~200 MB, debug) │ -│ ├── ReactNativeDependencies.xcframework │ -│ └── hermes-engine.xcframework │ -└──────────────────┬──────────────────────────────┘ - │ download + cache - ▼ -┌─────────────────────────────────────────────────┐ -│ ~/Library/Caches/com.facebook.ReactNative/ │ -│ └── spm-artifacts/{version}/{flavor}/ │ -└──────────────────┬──────────────────────────────┘ - │ symlink - ▼ -┌──────────────────────────────────────────────────┐ -│ App ios/ │ -│ ├── AppName.xcodeproj/ (committed) │ -│ │ └── .spm-managed (marker file) │ -│ ├── AppName.xcodeproj.legacy/ (committed if │ -│ │ rename was │ -│ │ accepted) │ -│ └── build/ │ -│ ├── generated/ │ -│ │ ├── autolinking/ (generated) │ -│ │ │ ├── Package.swift │ -│ │ │ ├── autolinking.json │ -│ │ │ ├── packages/ (synth wrappers) │ -│ │ │ └── libs/ (alias symlinks │ -│ │ │ for self-managed│ -│ │ │ deps; basename │ -│ │ │ = SwiftName) │ -│ │ └── ios/ (codegen) │ -│ └── xcframeworks/ (symlinks) │ -│ ├── Package.swift │ -│ ├── React.xcframework -> cache │ -│ ├── ReactNativeDependencies.xcframework │ -│ └── hermes-engine.xcframework │ -└──────────────────────────────────────────────────┘ -``` - -### Pipeline - -`react-native spm` orchestrates six steps (the underlying script is -`scripts/setup-apple-spm.js`): - -| # | Step | Script | Output | -|---|------|--------|--------| -| 1 | CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | -| 2 | Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | -| 3 | Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` + source symlinks | -| 4 | Download | `spm/download-spm-artifacts.js` | Cached xcframeworks | -| 5 | Package | `spm/generate-spm-package.js` | `build/xcframeworks/Package.swift` + symlinks | -| 6 | Xcodeproj | `spm/generate-spm-xcodeproj.js` | `AppName.xcodeproj` + `.spm-managed` marker (`init` only; create-if-missing on subsequent runs) | -| — | Sync (build-time) | `spm/sync-spm-autolinking.js` | Re-runs steps 1–5 when inputs change (downloads artifacts if missing) | - -The `init` action additionally (a) prompts to rename any existing -CocoaPods `.xcodeproj` to `.xcodeproj.legacy` before step 6, and -(b) appends SPM-specific entries to `.gitignore` -(`build/generated/`, `build/xcframeworks/`, `.build/`, `Package.resolved`). -Existing entries are not duplicated. - -### Auto-sync build phase - -After initial setup, developers shouldn't need to re-run `react-native spm` -manually when dependencies change. The generated `.xcodeproj` includes a -**Sync SPM Autolinking** pre-build phase (ordered first, before VFS overlay) -that: - -1. Checks whether xcframework artifacts are missing (`artifacts.json` or - `React.xcframework` absent). This covers fresh clones where no setup - script has been run yet. -2. Compares timestamps of `package.json`, `react-native.config.js`, and the - `node_modules` directory against `autolinked/.spm-sync-stamp`. In - monorepos where `node_modules` is hoisted, the parent directory is also - checked. -3. If any check triggers (or the stamp is missing): sources `with-environment.sh` - for node PATH, then runs `spm/sync-spm-autolinking.js` which re-executes - codegen, artifact download (if needed), autolinking, and package generation. -4. If all inputs are fresh: exits immediately (~1ms shell check). - -Failures emit `warning:` and exit 0 — the existing autolinking may still be -valid. The stamp file is written on successful sync. - -The sync step handles React Native version changes automatically: after -`npm install` pulls a new version, the `node_modules` mtime changes, the sync -step regenerates autolinking and recreates xcframework symlinks pointing to the -new version's cache directory. - -The sync step is **self-healing**: if xcframework artifacts are missing (e.g., -the local cache at `~/Library/Caches/com.facebook.ReactNative/` was deleted, -or the project was freshly cloned), it automatically downloads them before -proceeding with autolinking and package generation. This means `react-native spm` -is only strictly required for initial project scaffolding (`init`); subsequent -builds recover automatically. - -### Cleaning generated SPM state - -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch the project's `build/` or `.build/` directories. Xcode provides no -hook to run custom scripts during GUI clean actions. - -`react-native spm clean` is scoped by opt-in flags. The default removes only -generated dirs under `appRoot`: - -```bash -react-native spm clean # build/xcframeworks/, build/generated/, .build/ -react-native spm clean --project # also: delete SPM xcodeproj, restore .legacy backup -react-native spm clean --derived-data # also: this app's Xcode DerivedData entries -react-native spm clean --cache # also: cached xcframework slot for current version -react-native spm clean --all # = --project --derived-data --cache -``` - -Destructive scopes (`--project`, `--derived-data`, `--cache`, `--all`) prompt -for confirmation (bypass with `--yes`). `--project` is the reverse of the -init-time rename migration — deleting the SPM xcodeproj and restoring -`.xcodeproj.legacy` to its original filename if a backup exists. - -After a plain `clean`, run `react-native spm update` (or open the checked-in -`.xcodeproj` and build) to regenerate state. SPM package resolution is locked -for the duration of a build — if only stubs were left in place, Xcode would -resolve stubs and never pick up the real packages generated by the sync build -phase. - -### Stub packages for fresh clones - -Xcode resolves SPM packages **before** any build phase runs. On a fresh clone, -the referenced package directories (`build/xcframeworks`, `autolinked`, -`build/generated/ios`) may not exist yet, causing package resolution to fail. - -To solve this, `generate-spm-xcodeproj.js` writes **stub `Package.swift` -files** into each referenced sub-package directory that doesn't already have -one. Each stub defines the expected library products backed by a minimal -placeholder target (`.stub/Stub.swift`). This lets Xcode resolve packages -successfully even before the first build. On the first build, the auto-sync -build phase overwrites the stubs with real Package.swift files generated from -downloaded artifacts and autolinking output. - -### Caching and CI - -Xcframeworks are cached at -`~/Library/Caches/com.facebook.ReactNative/spm-artifacts/{version}/{flavor}/` -by default. The download step accepts a `--output` flag to write xcframeworks -to an explicit directory. - -For CI pipelines (GitHub Actions, CircleCI, etc.), cache the default path -keyed by the React Native version and flavor to avoid re-downloading -xcframeworks on every build. - -The Maven base URL can be overridden via the `ENTERPRISE_REPOSITORY` -environment variable for teams that mirror artifacts to an internal registry. - -**Planned:** A `RN_SPM_CACHE_DIR` environment variable to override the default -cache directory. This is not yet implemented in the current POC but is needed -for CI environments where a specific path must be persisted across builds. - -### Package graph - -The generated `.xcodeproj` references three local packages directly -via `XCLocalSwiftPackageReference` — no app-level `Package.swift` is required: - -``` -AppName.xcodeproj - ├── XCLocalSwiftPackageReference → build/xcframeworks/Package.swift - │ ├── ReactNative (product, wraps React binaryTarget) - │ ├── ReactNativeDependencies (binaryTarget) - │ └── hermes-engine (binaryTarget) - ├── XCLocalSwiftPackageReference → build/generated/ios/Package.swift - │ ├── ReactCodegen (target — codegen output) - │ └── ReactAppDependencyProvider (target) - └── XCLocalSwiftPackageReference → build/generated/autolinking/Package.swift - └── ... (targets — symlinked sources) -``` - -These three sub-package paths are **stable**: adding or removing community -deps changes the contents of `build/generated/autolinking/Package.swift` -(gitignored) but never the xcodeproj's references. That's why the -`.xcodeproj` is committed once and not regenerated on subsequent runs. - -The xcodeproj generation is **create-if-missing** on `update` (use -`--force-xcodeproj` for an explicit overwrite). This protects user-side -Xcode edits — signing, capabilities, Build Phases, scheme settings — from -being clobbered. Teammates can clone the repo and open Xcode immediately: -stub `Package.swift` files in each sub-package directory let SPM resolution -succeed before the first build, and the auto-sync build phase downloads -artifacts and writes the real sub-packages on first compile. - -### Header resolution - -React Native uses CocoaPods-style imports (`#import `) that -SPM does not natively support. Two mechanisms solve this: - -1. **XCFramework `Headers/` layout.** The prebuild step organizes headers by - `header_dir` (e.g., `Headers/React/`, `Headers/react/renderer/core/`). - Adding `-I Headers` to search paths resolves most imports directly. - -2. **VFS overlay.** A Clang virtual filesystem overlay (`React-VFS.yaml`) - remaps remaining edge cases — headers that appear in multiple pods or have - platform variants. The overlay is generated as a template at prebuild time - and resolved with local paths at setup time. - -### Local native modules - -Modules not discovered via autolinking (e.g., app-specific native modules) are -declared in `react-native.config.js`: - -```js -// react-native.config.js -module.exports = { - spmModules: [ - { - name: 'MyNativeModule', // SPM target name - path: 'ios/MyNativeModule', // path to source files - exclude: ['*.podspec'], // files to exclude from the target - publicHeadersPath: '.', // header search path for consumers - }, - ], -}; -``` - -Each entry becomes a target in `autolinked/Package.swift`. Sources outside the -autolinked directory are mirrored with **file-level symlinks** (SPM rejects -directory symlinks that resolve outside the package root). - -### Self-managed deps and package identity - -A community library that ships its own `Package.swift` (instead of being -wrapped by the autolinker) is referenced directly. SPM derives the package -identity for a `.package(path:)` dependency from the path's basename — and -a common convention is to ship the manifest inside an `ios/` subdir -(`/ios/Package.swift`). Two libs following that convention would both -have identity `"ios"`, and SPM rejects with `Conflicting identity for ios`. - -To make every reference globally unique by construction, the autolinker -materializes each self-managed dep as a symlink at -`build/generated/autolinking/libs//` pointing at the dep's real -manifest dir. The aggregator `Package.swift` then references the symlink -(`path: "libs/"`), and SPM uses the symlink basename — the -library's Swift module name — as the package identity. Swift module names -are already unique per dep (deriving from the npm package name), so this -sidesteps the collision in all cases, including against the codegen -package at `build/generated/ios/`. - -The `libs/` directory is wiped and recreated on every autolinker run, so -stale aliases for uninstalled deps disappear automatically. - -### Third-party library support - -The current implementation handles React Native's own frameworks and app-local -native modules. The primary goal for third-party libraries is to **build using -SPM**. Shipping prebuilt xcframeworks is the recommended approach for faster -builds, but it is not a requirement — libraries can also be compiled from -source via SPM targets. This ensures that library authors with limited -resources can support SPM without needing to set up a prebuild CI pipeline. - -#### Library metadata in `react-native.config.js` - -`react-native.config.js` is the canonical place for library SPM metadata. The -autolinking pipeline already scans `node_modules` for this file to discover -iOS and Android native modules. Adding SPM config alongside the existing -`dependency.platforms.ios` keeps a single source of truth, requires no new -discovery mechanism, and can express things `Package.swift` cannot — such as -Maven URL templates with version and flavor placeholders for downloading -prebuilt xcframeworks. Libraries may still ship a `Package.swift` for direct -SPM consumers outside the React Native ecosystem, but React Native autolinking -reads `react-native.config.js`. - -#### Prebuilt xcframeworks (primary path) - -React Native already prebuilds its core into xcframeworks and publishes them to -Maven. This is the model we want every library to follow. Libraries declare SPM -metadata in `react-native.config.js`: - -```js -// react-native-maps/react-native.config.js -module.exports = { - dependency: { - platforms: { - ios: { /* existing autolinking config */ }, - }, - }, - spm: { - // Primary: prebuilt xcframework (downloaded at setup time) - xcframework: { - name: 'ReactNativeMaps', - // URL template — {version}, {rn-version}, {flavor} resolved at download time - url: 'https://maven.example.com/.../react-native-maps-{version}-xcframework-{flavor}.tar.gz', - }, - // Fallback: source compilation (used during local development or when - // xcframework is unavailable) - source: { - name: 'ReactNativeMaps', - path: 'ios', - publicHeadersPath: '.', - exclude: ['*.podspec', 'Tests/**'], - dependencies: ['MapKit'], - resources: ['ios/Resources/**'], - }, - }, -}; -``` - -**Planned (Phase 2):** When `react-native spm` gains third-party library -support, it will: -1. If `spm.xcframework` is declared, download the prebuilt binary (fast path). -2. If the download fails or the `--source` flag is passed, fall back to - `spm.source` and compile from symlinked sources. -3. If neither is declared, the library requires a manual `spmModules` entry. - -Currently, only `spmModules` entries (see [Local native modules](#local-native-modules)) -are supported. The `spm.xcframework` and `spm.source` config fields — including -the `dependencies` field shown above — are not yet implemented. - -#### Source compilation (fallback) - -Source-level autolinking (`spmModules` / `spm.source`) remains available for: -- **Local development** — library authors iterating on native code -- **Libraries without prebuilt xcframeworks** — transitional state -- **App-specific native modules** — code that lives in the app repo - -This reuses the existing `spmModules` mechanism: sources are mirrored with -file-level symlinks into `autolinked/`, compiled as SPM targets with -appropriate header search paths. - -#### `react-native-prebuild` CLI - -React Native already has a mature prebuild pipeline (`scripts/ios-prebuild/`) -that produces signed, packaged xcframeworks published to Maven. Rather than -asking library authors to reinvent this, we can expose the same tooling as a -reusable CLI: - -```bash -npx react-native-prebuild \ - --podspec ios/MyLibrary.podspec \ - --react-native-version 0.80.0 \ - --platforms ios,ios-simulator \ - --flavor release \ - --output dist/ - -# Output: -# dist/MyLibrary.xcframework.tar.gz -# dist/MyLibrary.framework.dSYM.tar.gz -``` - -The tool would: - -1. **Download React Native xcframeworks** for the specified version. -2. **Parse the library's podspec** to discover source files, headers, - `header_dir`, dependencies, and compiler flags. -3. **Generate a temporary Package.swift** declaring the library as a target - with dependencies on the RN xcframeworks. -4. **Build** using `xcodebuild` for each platform slice. -5. **Compose** the xcframework with organized headers, module map, and - optional VFS overlay. -6. **Sign** the xcframework with the developer's code signing identity. -7. **Package** as `.tar.gz` with dSYM symbols. - -The tool includes code signing as a built-in step. Library authors provide -their own signing identity (Apple Developer certificate); the tool handles -the `codesign` invocation. Unsigned xcframeworks trigger macOS Gatekeeper -warnings, so signing is strongly recommended for distributed artifacts. -Documentation will cover how to create and manage a signing identity for -this purpose. - -Library authors can integrate this into CI to publish prebuilt artifacts on -every release, targeting a matrix of React Native versions and build flavors. - -#### Version compatibility - -A library's xcframework must be built against a compatible React Native -version. The prebuild tool embeds metadata (React Native version, library -version, build flavor, minimum iOS version) inside the xcframework. The -download step verifies compatibility at setup time, warning if a library was -built against a different React Native version than the app is using. - -## Drawbacks - -### Transition period: supporting both CocoaPods and SPM - -With CocoaPods trunk going read-only in December 2026, the migration to SPM is -necessary rather than optional. During the transition period, both build -systems must be supported in parallel. Bug fixes, new features, and build-phase -changes need to be tested against both CocoaPods and SPM until CocoaPods -support is eventually removed. - -### Download size - -Prebuilt xcframeworks for React Native core are compressed as tar.gz archives. -Individual library xcframeworks are typically 1–15 MB in debug mode including -dSYM bundles. Both debug and release flavors are needed, which doubles the -total. While artifacts are cached locally after the first download, CI -environments without persistent caches will re-download on every build. - -### Ecosystem adoption takes time - -Third-party libraries must opt in to the prebuild workflow. During the -transition period, many libraries will only support CocoaPods. Apps that depend -on these libraries cannot fully migrate to SPM until the libraries catch up. -This creates a chicken-and-egg problem that may slow adoption. - -### SPM limitations require `.xcodeproj` generation - -SPM does not support build script phases, `post_install` hooks, or the kind of -build-time customization that CocoaPods provides via its Podfile DSL. The -current design works around this by generating an `.xcodeproj` with explicit -build phases for JS bundling, Hermes engine copying, VFS overlay setup, and -autolinking sync. This is a known limitation of the current approach. If Apple -expands SPM's plugin API to support arbitrary script execution with file I/O -and network access, the `.xcodeproj` could be eliminated in favor of a purely -SPM-native workflow — but this is a future direction that depends on Apple's -roadmap, not something this proposal can resolve. - -### Committed xcodeproj edits - -The generated `.xcodeproj` is committed and may carry user edits — -signing, capabilities, Build Phases, custom schemes. The `update` action is -**create-if-missing** to protect those edits, which means the script does -not propagate generator improvements into existing projects automatically. -Bug fixes that change the emitted pbxproj need an explicit -`--force-xcodeproj` run to take effect. A future improvement could -preserve user-side edits through a merge step rather than full overwrite — -see "Hardening `update --force-xcodeproj`" in unresolved questions. - -## Alternatives - -### Compile React Native from source as SPM targets - -Compiling React Native's C++/Objective-C sources from source as SPM targets is -not the default path due to the ~2000 source files and complex header layout, -which makes clean build times significantly longer. However, source -compilation support is a goal for specific use cases: - -- **Debugging React Native internals** — developers investigating bugs or - contributing fixes to React Native itself need to build from source with - debug symbols. -- **Apps requiring source patches** — projects like Expo Go that need to modify - React Native source code to build successfully, or apps that apply patches - via tools like `patch-package`. - -The source compilation path would reuse the same SPM package structure but -replace binary xcframework targets with source targets. This is planned as a -`--source` flag to `react-native spm`. - -### SPM build tool plugins - -SPM plugins were evaluated as a way to eliminate the `.xcodeproj` (see -[SPM Plugins Assessment](spm-plugins-assessment.md) for details). The key -findings: - -- **Post-build phases are impossible.** JS bundling and Hermes engine copying - run after linking to place artifacts in the `.app` bundle. SPM has no - post-build plugin capability — this is a deliberate design choice for build - reproducibility. -- **Sandbox restrictions.** Build tool plugins cannot write to the source tree, - run `node`, or access `node_modules`. Pre-build phases like autolinking sync - require all of these. -- **No Xcode build settings.** SPM plugins do not receive `CONFIGURATION`, - `BUILT_PRODUCTS_DIR`, or other settings that the JS bundling script relies on. - -A hybrid approach (some SPM plugins + some Xcode build phases) would be harder -to reason about than the current uniform approach of all Xcode build phases. -SPM plugins are not a viable alternative today. - -## Adoption strategy - -This proposal introduces SPM as an **additional** build system. It is not a -breaking change. CocoaPods continues to work exactly as before. The two -workflows coexist — an app can have both `Podfile` and `Package.swift` in the -same directory. - -### Phase 1: React Native core (current) - -SPM works for React Native core frameworks and app-local native modules -declared as `spmModules` in `react-native.config.js`. No third-party library -support. This phase validates the architecture and developer experience with -rn-tester and the helloworld template. - -### Phase 2: Library ecosystem tooling - -Ship the `react-native-prebuild` CLI. Library authors can prebuild and publish -xcframeworks for their libraries. The autolinking step reads `spm.xcframework` -from installed libraries and downloads artifacts automatically. Libraries -without xcframeworks fall back to `spm.source` (source compilation) or manual -`spmModules` entries. - -### Phase 3: Ecosystem-wide adoption - -Popular libraries ship prebuilt xcframeworks from CI. App developers get -near-zero-compilation iOS builds — only app code and codegen output are -compiled. React Native provides clear documentation and tooling -(`react-native-prebuild`) to help library authors build and publish -xcframeworks — for example, CI workflow templates and guidance on publishing to -Maven or GitHub Releases. Prebuilt xcframeworks are recommended but not -required; libraries that don't provide them fall back to source compilation. - -### Migration path for existing apps - -1. Run `npx react-native spm` from the project root (auto-redirects into - `ios/`). -2. Accept the rename prompt — your existing `AppName.xcodeproj` becomes - `AppName.xcodeproj.legacy` (preserved for rollback). -3. Commit the new `AppName.xcodeproj/` (SPM-managed) and the renamed - `AppName.xcodeproj.legacy/`. `git mv` tracks the rename cleanly. -4. Run `npm run ios` and verify the SPM build. -5. Once validated, optionally delete the `.legacy` backup, `Podfile`, - `Pods/`, and `.xcworkspace`. - -To roll back: `npx react-native spm clean --project` deletes the SPM -xcodeproj and renames `.legacy` back to the canonical filename. - -No changes to JavaScript code, Metro configuration, or Android setup are -required. - -### Upgrading React Native - -After upgrading `react-native` in `package.json` and running `npm install`, -the auto-sync build phase detects the `node_modules` mtime change on the next -Xcode build and re-runs the sync step automatically. This downloads the new -version's xcframeworks, regenerates the sub-packages, and updates autolinking. -No manual edits are needed — the xcodeproj's sub-package references are -stable, and those sub-packages are fully regenerated each run. Developers -can also run `react-native spm` manually to trigger the update before -building. - -## How we teach this - -### Documentation - -- Add a **"Building with SPM"** guide to the React Native docs, parallel to the - existing CocoaPods setup guide. -- Update the **"Getting Started"** guide to present SPM as an option alongside - CocoaPods, with SPM as the recommended path for new projects once Phase 2 is - stable. -- Add a **library author guide** explaining how to use `react-native-prebuild` - and publish xcframeworks. - -### CLI discoverability - -- `react-native spm --help` should provide clear usage instructions and - explain each step. -- Error messages should include actionable suggestions (e.g., "Run - `react-native spm init` for first-time setup"). -- The auto-sync build phase should surface warnings in Xcode's issue navigator - when autolinking state is stale. - -### Community template - -- The `react-native init` template should include SPM as an option (e.g., - `--pm spm` flag or interactive prompt). -- The template should generate the initial `Package.swift` and `.xcodeproj` so - that new projects work with SPM out of the box. - -### Naming and terminology - -- **"SPM build"** or **"Swift Package Manager build"** to distinguish from the - CocoaPods-based workflow. -- **"xcframeworks"** when referring to the prebuilt binary artifacts. -- Avoid the term "pods" when discussing the SPM workflow to prevent confusion. - -## Unresolved questions - -1. **How should version compatibility be enforced?** A library's xcframework - must be built against a compatible React Native version. Should the download - step enforce strict version matching, accept semver-compatible ranges, or - simply warn on mismatch? - -2. **Where should library xcframeworks be hosted?** Maven Central (consistent - with React Native core), GitHub Releases (simpler for library authors), or a - dedicated registry (better discovery and compatibility metadata). Each has - different trade-offs for discoverability, reliability, and maintenance - burden. - -3. **Debug symbol (dSYM) distribution.** The prebuild pipeline produces dSYM - bundles alongside xcframeworks, but the best way to distribute and consume - them is not yet defined. Open questions include: should dSYMs be downloaded - alongside xcframeworks automatically or on demand? How should they integrate - with crash reporting services (Sentry, Crashlytics) that need dSYM UUIDs - for symbolication? Should the download step place dSYMs in a location that - Xcode's archive workflow picks up automatically? - -4. **How should library authors validate SPM compatibility?** A validation - command (`react-native-prebuild --validate`) could verify that a library's - sources compile as an SPM target without producing a full release artifact. - This would be useful for CI checks on pull requests. - -5. **Hardening `update --force-xcodeproj`.** The default `update` is - create-if-missing, which preserves user edits but means generator - improvements don't propagate to existing projects automatically. Passing - `--force-xcodeproj` clobbers everything. A future improvement could read - the existing pbxproj, merge changes (signing, capabilities, custom Build - Phases), and write back — likely via a proper Xcode project - parser/generator (e.g., `@bacons/xcode`) rather than the current - template-based approach. Planned work for production readiness. - -6. **Auto-sync failure visibility.** The sync build phase currently emits - `warning:` and exits 0 on failure, which means a broken autolinking state - can persist silently across builds. Planned improvements include a strict - mode (e.g., `RN_SPM_STRICT_SYNC=1` that exits non-zero on failure) and - generating a `#warning` directive in a source file when sync fails, so - Xcode surfaces the issue in the issue navigator even when build log - warnings are missed. - -7. **Monorepo and package manager compatibility.** The auto-sync build phase - uses `node_modules` mtime to detect dependency changes. This has been - tested with npm in the React Native monorepo but not yet with Yarn - workspaces (hoisted or PnP), pnpm (symlinked `node_modules`), or Bun. - These package managers structure `node_modules` differently and may require - adjustments to the mtime detection logic. Validating and fixing - compatibility across package managers is planned work. - -## References - -- [RFC0508: Out-of-NPM Artifacts](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0508-out-of-npm-artifacts.md) — established the Maven-based artifact distribution pattern this proposal builds on -- [Apple: Creating Swift Packages](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode) -- [SE-0272: Package Manager Binary Dependencies](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0272-swiftpm-binary-dependencies.md) diff --git a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md deleted file mode 100644 index c8ac204ca06d..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md +++ /dev/null @@ -1,244 +0,0 @@ -# SwiftPM Autolinking Plugins (Preview) - -> **Preview / unstable contract.** The discovery mechanism and the plugin -> function's context/return shape may change while the first consumers (Expo) -> validate it. Pin to a React Native version if you depend on it. - -How a framework with its own module system — Expo is the first consumer — -contributes to the SwiftPM autolinking graph that `npx react-native spm` -generates. See [spm-scripts.md](./spm-scripts.md) for the base tool. - -## Why a plugin (not a static list or a post-process) - -The documented extension points don't cover a framework: - -- `spm.modules` in `react-native.config.js` is a **static** list of simple - source modules. A framework discovers its modules **dynamically** (scanning - `node_modules`), generates a **module registry**, and ships mixed - Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't - handle. -- A one-shot **post-process** of the generated `Package.swift` is **clobbered - on the next sync**: the Xcode [auto-sync build phase](./spm-scripts.md#auto-sync-build-phase) - re-runs autolinking on every dependency change. A framework's contribution - must run *whenever autolinking runs*. - -A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s -`main()` — the single function that both `add` / `update` **and** the -build-time `sync` call — so the contribution is regenerated on every build and -never goes stale. - -(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, -`use_expo_modules!`, and `react_native_post_install` hooks.) - -## Discovery — transitive, zero app config - -A dependency opts in from its **own** `react-native.config.js`, so installing -the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!` -transitively): - -```js -// node_modules/expo/react-native.config.js -module.exports = { - spm: {autolinkingPlugin: './spm/autolinking-plugin.js'}, -}; -``` - -The autolinker already walks every dependency's `react-native.config.js`; any -that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level -registration or allowlist is required. - -**Opt-out escape hatch.** An app can exclude a plugin from its own -`react-native.config.js`: - -```js -module.exports = { - spm: {denyPlugins: ['some-framework']}, // npm names to skip -}; -``` - -## The contract - -A plugin is a function exported from the module named above -(`module.exports = fn`, or `default` / `plugin` named exports also work): - -```js -module.exports = function plugin(context) { - return { - packageDependencies: [ - // Local package (e.g. a scanned module dir) … - {name: 'ExpoModulesCore', path: '../../../node_modules/expo-modules-core/ios'}, - // … or a remote/published package: - // {name: 'SomePkg', url: 'https://…/SomePkg.git', version: '1.2.3'}, - ], - productDependencies: [ - // Linked by the app's AutolinkedAggregate target: - {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, - ], - generatedSources: [ - // e.g. the generated module registry, registered with codegen: - {path: 'build/generated/expo/ExpoModulesProvider.swift'}, - ], - flavoredFrameworks: [ - // Precompiled dynamic XCFrameworks that come in mandatory Debug/Release - // pairs. RN validates and stages both outside the SwiftPM graph. - { - id: 'expo-modules-core', - frameworkName: 'ExpoModulesCore', - linkage: 'dynamic', - flavors: { - debug: '/…/output/debug/xcframeworks/ExpoModulesCore.xcframework', - release: '/…/output/release/xcframeworks/ExpoModulesCore.xcframework', - }, - }, - ], - watchPaths: [ - // Inputs whose edits must re-trigger the auto-sync — the plugin's own - // manifest and per-module config (absolute paths, dirs or files): - '/…/node_modules/expo/Package.swift', - '/…/node_modules/expo/expo-module.config.json', - ], - }; -}; -``` - -#### `flavoredFrameworks` — per-configuration precompiled frameworks - -Each entry is -`{id, frameworkName, linkage: 'dynamic', flavors: {debug, release}}`. -Both flavor paths must be absolute and present when `spm add` or `spm update` -runs. The framework and executable names, public headers, and platform slices -must agree across flavors. Static binaries, nested frameworks, duplicate IDs, -and duplicate embedded framework names are fatal. - -The declarations are recorded to -`/.spm-plugin-flavored-frameworks.json`, normalized into the same -immutable app-local slots as React Native, and added to Xcode's exact linker and -embed settings. They are not emitted as SwiftPM product dependencies. Adding or -removing one requires `spm update`; the build-time `spm sync` intentionally does -not mutate runtime framework settings. - -#### `watchPaths` — plugin staleness inputs - -`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode -auto-sync build phase watches to decide whether it must re-sync. RN already -watches each module's source dir plus every npm dep's checked-in `Package.swift` -and `.react-native/` dir; a plugin adds the inputs only it knows about — e.g. -`packages/expo/Package.swift`, `expo-module.config.json`, and per-module -manifests. On the next build the phase re-syncs when a watched **file** is newer -than the last sync, a watched **dir** has a newer child, or a watched path has -**vanished** (a rename forces a re-sync so the config error surfaces). - -Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored with a warning -(never fatal), and each non-string / empty / **relative** entry is dropped with a -warning. Absolute-only, because the generated phase tests these paths with no cwd -context. The kept paths are folded into `/.spm-sync-watch-paths` -alongside RN's own, then deduped and sorted. - -### Context (input) - -| Field | Meaning | -|---|---| -| `appRoot` | The Xcode project directory (`/ios`) being injected — **not** the app package root. Deriving package-root-relative paths from it (e.g. `path.join(appRoot, 'node_modules')`) silently breaks; use `projectRoot` for that. | -| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. | -| `reactNativeRoot` | Resolved `react-native` package root. | -| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. | -| `outputDir` | `build/generated/autolinking` — where generated artifacts land. | -| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. | - -#### `context.react` — depending on React - -A plugin that emits its own `Package.swift` must declare React as a dependency. -Rather than re-deriving React Native's package path, identity, and product -names — which differ between local and remote mode and **move as RN -repackages** — take them from `context.react`: - -```js -react: { - packageRef: - {name: 'ReactNative', path: '', relPath: ''} // local - | {name: '', url: '', version: ''}, // remote (SPM-resolved) - products: [ - {name: 'ReactHeaders', package: 'ReactNative'}, - {name: 'ReactNativeHeaders', package: 'ReactNative'}, - {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, - {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, // ← separate, per-app package - ], -} -``` - -Local vs remote is signalled by which `packageRef` keys are present (`path` xor -`url`+`version`). `packageRef.path` is **absolute** — always correct no matter -which subdirectory of `outputDir` the plugin writes its own manifest into (the -generated manifests are gitignored and regenerated every sync, so there's no -portability cost); `relPath` (relative to `outputDir`) is provided as a -convenience. `products` is the set React Native wires into **its own** autolinked -targets (so a plugin's target compiles against exactly RN's React surface), -filtered to those resolvable this run — every listed product is safe to -reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in -the separate `React-GeneratedCode` package (per-app codegen), which a -hand-rolled plugin would miss, and which is omitted when that package is absent. -Because RN derives this list from one source of truth alongside its own product -wiring, it stays correct across repackaging. - -### Return (contributions, all optional) - -| Field | Merged into | -|---|---| -| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). | -| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | -| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | -| `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | - -The plugin returns **data** — it never writes into React Native's generated -tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` -byte-for-byte (idempotent). Package and product contributions are **deduped by -name** across plugins. - -## Lifecycle - -``` -react-native spm add / update ─┐ - ├─► generate-spm-autolinking main() -Xcode "Sync SPM Autolinking" ──┘ │ -(build phase, every build) ├─ 1. discover plugins (dep configs) - ├─ 2. RN builds its own dep graph - ├─ 3. invoke plugins (context in) - └─ 4. merge results → aggregator Package.swift -``` - -Because steps 1–4 run in the one `main()`, everything above shares the same -seam — there is no separate hook to wire for the build-time path. - -## Failure behavior - -Fail-closed and **named**: a plugin that fails to load, doesn't export a -function, throws, or returns a malformed contribution aborts the run with a -message identifying the framework. A framework silently dropping its modules -(a green build missing native code) is worse than a loud stop. - -## Status & open items (Preview) - -- **Implemented & tested:** discovery (transitive + deny-list), invocation, - package + product merge, fail-closed validation, and dual-flavor framework - normalization/link/embed outside SwiftPM. -- **Implemented & tested:** `generatedSources` **app-target wiring**. The - merge writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` - xcodeproj injector (generate-spm-xcodeproj.js) reads it and wires each source - **into the app target** — a `PBXFileReference` + `PBXBuildFile` + a - Sources-build-phase entry, parented under one "SPM Generated Sources" - navigator group. This is what makes an `@objc` class (e.g. Expo's - `ExpoModulesProvider`) reach the ObjC classlist: a class inside the static - Autolinked aggregate never does, so `NSClassFromString` discovery would fail. - Paths are stored SRCROOT-relative when under the app root (the usual - `build/generated/…` case), else absolute (`sourceTree = ""`). All - UUIDs are namespaced on the normalized path (deterministic/idempotent) and - recorded in the `.spm-injected.json` marker's `generatedSources` map, so - `deinit` reverts them and `update` reconciles entries that left the manifest. - A target without a Sources phase logs loudly and skips the wiring (injection - otherwise succeeds). v1 targets only the injected app target and assumes - `.swift` in practice (`.m`/`.mm` are mapped as future-proofing). -- **Co-design with Expo (not final):** codegen **provider ordering** — codegen - must consume the same discovered module set the plugin contributes — is - intentionally left for the first real plugin to drive to a stable shape. -- Contract to be ratified via RFC once Expo's plugin proves it (framed as a - generic hook, not Expo-specific code in RN). diff --git a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md deleted file mode 100644 index c62c46e0b781..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md +++ /dev/null @@ -1,97 +0,0 @@ -# SPM headers & package references — how they resolve - -React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search -paths and no `unsafeFlags` in any generated manifest. Headers are served by -SPM products/binary targets, and every generated `Package.swift` references -the React Native + codegen packages with plain, fixed-relative paths computed -at generation time (no runtime discovery). This document is the single source -of truth for how that resolves. - -> History: earlier iterations materialized two header trees and fed them to -> consumers as `-I` flags read from `spm-paths.json` / `.react-native/paths.json` -> via an inlined Swift loader. That whole mechanism (the loader -> `renderRNPathsLoader`, the `writeAppPathsJson` / `writeSharedPathsJson` -> writers, and both JSON files) has been **deleted** — manifests are now -> declarative. If you find a reference to those files, it is stale. - -## How headers resolve (no search paths) - -| Namespace | Served by | Mechanism | -|-----------|-----------|-----------| -| Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | -| Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | -| ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | - -The one remaining materialized header tree is the per-app farm at -`/build/generated/ios/ReactAppHeaders` (built by -`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It -is vended as the `ReactAppHeaders` SPM target — consumers reach it through a -product dependency, never through `-I`. - -`autolinking.json` (the `@react-native-community/cli config` output) is an -INPUT used to generate the manifests; it is never read by a manifest. - -## How each manifest references the React + codegen packages - -Every generated manifest sits at a known depth inside the app and is -regenerated on every `react-native spm` run, so package references are plain -fixed-relative paths — no walk-up, no JSON, no `import Foundation`. - -| Manifest | Location | How it references the React + codegen packages | -|----------|----------|-------------------------------------------------| -| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | -| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | -| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | -| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | -| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | - -## Remote-package mode - -Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted -`url`). When set, the whole app graph flips to a single remote React Native -package identity: `.package(path: build/xcframeworks)` becomes -`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), -and the local artifact download + compose is skipped. SPM's -one-version-per-package rule then unifies app + every library on one resolved -React Native. The package identity is derived from the URL tail (swift-tools 6 -dropped `.package(name:url:)`) — nothing hardcodes a repo name. - -**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version -is not a free parameter: the SPM graph must compile against the same React -Native the JS/native code uses, so the app (graph root) pins EXACT to the -*installed* RN version, read from `node_modules/react-native/package.json`. -`RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, -not the source of truth — they're only needed when the installed version isn't -publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote -tag). A *derived* version is never persisted, so an `npm install` that upgrades -RN auto-re-pins the SPM graph on the next `spm` run; an *override* is persisted -as `versionOverride` so it survives Xcode-phase re-syncs without the env. - -Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still -read, with `version` honored as an override (back-compat). If remote mode is on -but no usable version can be resolved — react-native isn't installed, or it's a -non-publishable dev placeholder and no override is set — the tooling errors -(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` -or install a released react-native, rather than silently pinning an unpublished -tag. - -## Hand-authored community library contract - -A library that ships its own `Package.swift` (no scaffolder/autolinker marker) -is left untouched by the tooling. It needs only two things, and **no discovery -code**: - -1. Depend on the React Native SPM package and its products — in remote mode - `.package(url: "", exact: "")` + `.product(name: "ReactNative", …)` - and `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a - version RANGE in production; the consuming app pins EXACT.) -2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` - and generate with `generate-codegen-artifacts.js --path . --targetPlatform - ios --source library`. Output lands at - `/build/generated/ios/ReactCodegen/`, reachable from the manifest - with one safe `.headerSearchPath(...)` into the library's own tree. The - app-side codegen then skips the lib's spec (no duplicate symbols). - -This makes the library self-contained — it carries no app-layout knowledge and -needs no per-app codegen headers from the consuming app. Proven with -`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). diff --git a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md deleted file mode 100644 index e20e4c93f672..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md +++ /dev/null @@ -1,128 +0,0 @@ -# SPM Build Plugins Assessment - -An evaluation of whether Swift Package Manager plugins can replace the Xcode build -phase scripts currently injected by `generate-spm-xcodeproj.js`. - -## Current Build Phases (6 total) - -| # | Phase | Timing | SPM Plugin Feasible? | -|---|-------|--------|----------------------| -| 1 | Sync SPM Autolinking | Pre-build | Partially | -| 2 | Prepare VFS Overlay | Pre-build | Partially | -| 3 | Sources (compile) | Build | N/A (standard) | -| 4 | Frameworks (link) | Build | N/A (standard) | -| 5 | Resources (copy) | Build | N/A (standard) | -| 6 | Build JS Bundle | **Post-build** | **See below** | - -> **Removed:** The "Copy Hermes Framework" phase was removed — it was a no-op. -> The underlying `copy-hermes-xcode.sh` script has been empty since Dec 2022. -> Hermes is already properly linked as an xcframework SPM dependency. - -## SPM Plugin Types - -SPM offers two plugin types: - -1. **Build Tool Plugins** (`BuildToolPlugin`) — run pre-build, can generate source - files/resources via `prebuildCommands` or per-file `buildCommands`. -2. **Command Plugins** (`CommandPlugin`) — run on-demand via - `swift package `. - -## Key Constraints - -### Sandbox restrictions - -SPM plugins run sandboxed by default — no network access, limited filesystem access. -The current scripts need to: - -- Run `node` (not on the sandbox-allowed path) -- Write to the source tree (`autolinked/`, `build/`) -- Access `node_modules/` -- Read git state - -Command plugins can request `--allow-writing-to-package-directory`, but build tool -plugins can only write to a designated plugin work directory, not the source tree. - -### No Xcode build settings - -SPM plugins do not receive Xcode build settings such as `CONFIGURATION`, -`PLATFORM_NAME`, `BUILT_PRODUCTS_DIR`, or `DERIVED_FILE_DIR`. The JS bundling script -relies heavily on these to decide debug-vs-release behavior and output paths. - -## JS Bundle Phase — Could Move to Pre-build - -The JS bundle has no dependency on native compilation. It only needs JS source files, -Metro, and knowledge of debug vs release. The current post-build placement is -historical — the script writes directly into `BUILT_PRODUCTS_DIR`. - -A potential restructuring: - -1. **Generate the bundle pre-build** into a known location (e.g. `build/jsbundle/`) -2. **Declare it as an SPM resource** so it gets copied into the app automatically - -Challenges: -- **Debug builds skip bundling** (app loads from Metro dev server). The script checks - `CONFIGURATION == Debug`, which is unavailable to SPM plugins. -- **Hermes bytecode compilation** also happens in this phase for release builds. -- Making it a command plugin (`swift package bundle-js --configuration release`) would - lose the automatic behavior — developers would need to run it explicitly. - -## What Could Theoretically Work - -### Codegen as a Command Plugin - -A Swift command plugin could shell out to `node` to run codegen: - -```swift -@main struct CodegenPlugin: CommandPlugin { - func performCommand(context: PluginContext, arguments: [String]) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["node", "scripts/codegen/generate-codegen-artifacts.js"] - try process.run() - process.waitUntilExit() - } -} -``` - -Invoked as `swift package codegen`. This is essentially wrapping a shell script in -Swift with no real benefit over the current approach. - -### Autolinking sync as a Prebuild Command - -A `prebuildCommand` runs before every build, similar to Phase 1. But: - -- Output can only go to the plugin work directory (not `autolinked/`) -- Would need to restructure the package graph to consume generated files from the - plugin work directory -- Still needs to shell out to `node` - -This is a significant architectural rework for marginal benefit. - -## Recommendation - -**Do not invest in SPM plugins for this use case.** Reasons: - -1. **Pre-build phases already work well** as Xcode build phase scripts. Moving them - to SPM plugins adds Swift boilerplate around `Process()` calls to `node`, while - losing access to Xcode build settings. - -2. **The ROI is poor** — a hybrid (some SPM plugins + some Xcode build phases) is - harder to reason about than the current uniform approach of all Xcode build phases. - -3. **SPM plugins shine for pure Swift source generation** (SwiftGen, SwiftProtobuf) - where the plugin generates `.swift` files that feed into compilation. React - Native's build steps are fundamentally different — they orchestrate a JS toolchain - and copy runtime artifacts. - -4. **The JS bundle phase could move pre-build** but would lose automatic - debug/release detection without Xcode build settings. Worth revisiting if SPM - gains access to build configuration in a future Swift version. - -## Alternatives Worth Exploring - -- **Xcode Build Tool Plug-ins** (the Xcode-specific variant, not SPM) have access to - build settings and can run post-build, but require a different packaging model. -- **Move auto-sync to a `prepare` script** in `package.json` so it runs at - `yarn install` time instead of every build, reducing build-time overhead. -- **Pre-build JS bundling** with the bundle declared as an SPM resource, removing the - need for a post-build phase entirely (release builds only). diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md deleted file mode 100644 index abe6df11fc71..000000000000 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ /dev/null @@ -1,486 +0,0 @@ -# SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) - -> **Preview.** SwiftPM support is an early preview: the commands, flags, -> generated layout, and distribution model may change in future releases, and -> it is not yet recommended for production. Feedback is welcome. CocoaPods -> remains the supported default. - -Build React Native iOS apps using **Swift Package Manager** with prebuilt -XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — -CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` -in place and is fully reversible. - -## Quick Start - -```bash -cd ios - -# First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, -# in place. `npx react-native spm` with no action auto-resolves to `add` (or -# `update` once injected); on a fresh CocoaPods app it converts in one command -# (implies --deintegrate). To do it explicitly: -npx react-native spm add --deintegrate - -# Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. -open MyApp.xcodeproj -``` - -After the initial run, the `.xcodeproj` includes an **auto-sync build phase** -that detects dependency changes and re-runs autolinking before compilation -(see [Auto-Sync](#auto-sync-build-phase)) — you don't re-invoke -`react-native spm` manually for day-to-day dependency changes. **On a fresh -clone or CI checkout, run `npx react-native spm` once before building** (see -[Fresh clones & CI](#fresh-clones--ci)). - -> **Note:** `react-native spm` is a thin wrapper over -> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI -> alias is unavailable in your environment, invoke the script directly with -> the same actions and the kebab-case flag equivalents (e.g. -> `--skip-codegen`). - -## CocoaPods → SwiftPM migration - -`spm add` injects into a project that is **not** CocoaPods-integrated. On a -CocoaPods app it fails loud and points you at `--deintegrate`, which: - -1. runs `pod deintegrate` — removes CocoaPods integration from the - `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your - `Podfile` is left on disk. -2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — - every other line, **including your own `pod '…'` entries, is preserved**. -3. injects SwiftPM into the `.xcodeproj`. - -React Native now comes from SwiftPM; no pods are linked yet (deintegrate -removed the integration). - -### Keeping non-RN pods - -Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile -still lists them (only the RN directives were removed) — re-integrate them -with a normal install: - -```bash -pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace -``` - -Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes -the SwiftPM-injected project, so React Native resolves through SwiftPM and your -other pods through CocoaPods, together. - -> **Do not re-add `use_react_native!`.** React Native must be provided by -> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, -> so a dual-managed RN does not build. `spm add` refuses to run while the -> Podfile still declares `use_react_native!`. - -The migration is fully reversible — see -[Removing / resetting](#removing--resetting). - -## Brownfield apps - -`spm add` injects into your existing `.xcodeproj` in place, so an app that -embeds React Native works the same way — point it at the right project and -target: - -```bash -npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp -``` - -**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** -— i.e. the app's `package.json` is a parent directory of the project. Both -setup and the build-time sync locate React Native by walking up from the -project to the nearest `package.json`. The common "native project at the repo -root with the RN JS in a sibling/child subfolder" layout is **not supported -yet** — there is no way to point at a JS root outside the project's ancestors. - -Brownfield apps that keep CocoaPods for their other native dependencies follow -the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, -everything else from CocoaPods, and no `use_react_native!` in the Podfile. - -## CLI Actions - -```bash -react-native spm [action] [options] -``` - -With no action, the command **auto-resolves**: if SwiftPM has been injected -(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. -On a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the -zero-arg path additionally implies `--deintegrate` (the safe-gate), so -`npx react-native spm` converts a brand-new app to SwiftPM in one command. - -When invoked from the JS root of a standard RN app (sibling `ios/` subdir), -the command auto-redirects into `ios/` with a banner. - -| Action | Description | -|---|---| -| `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | -| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The exact inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. | -| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | -| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync build phase. Regenerates invariant codegen and autolinking output only. Not for humans. | -| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | -| `download` (advanced) | Download/check xcframework artifacts only. | - -## CLI Options - -Flags below use the `react-native spm` (camelCase) form. The raw script -accepts kebab-case equivalents (e.g. `--skip-codegen`). - -| Option | Description | -|---|---| -| `--version ` | RN version (default: from package.json) | -| `--yes` | Skip the dirty-pbxproj confirmation prompt | -| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | -| `--productName ` | [add] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | -| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | -| `--download ` | [advanced] Artifact download policy (default: auto) | -| `--skipCodegen` | [advanced] Skip the codegen step | -| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | - -### The autolinking config command is remembered - -An app that replaces `@react-native-community/cli` autolinking (an Expo app, -for example) has to tell `spm` how to produce `autolinking.json`. Pass the -command once, on `add` or `update`: - -```bash -npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' -``` - -Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and -the build-time `sync` — resolves the command in this order: - -1. `--configCommand` -2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` -3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an - earlier `add`/`update` -4. the default `@react-native-community/cli config` - -`add`/`update` pin whichever of the first two routes supplied the command, -validated as an argv array; a later run that passes neither keeps the existing -pin, and passing `--configCommand` again replaces it. The pin exists because -the **Sync SPM Autolinking** build phase inherits neither your flag nor the -shell that exported the env var — without it, a successful `add` is followed by -failing builds, because the phase re-derives `autolinking.json` with the -default command. A pin never shadows the env var, so an override in your shell -still takes effect, and a pin that no longer parses is ignored in favor of the -default. - -`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` -therefore falls back to the default command unless you pass `--configCommand` -(or export the env var) again. - -### Debug/Release flavor is automatic - -React Native ships **flavored** prebuilt binaries: the *debug* `React.framework` -(and `hermesvm` / `ReactNativeDependencies`) carry the dev experience — dev menu, -assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while *release* strips them for -production. A Debug build must embed the debug binaries and a Release/archive the -release ones. - -SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime -frameworks are deliberately kept out of the package graph. `spm add` downloads -and validates **both** flavors into immutable app-local slots. It injects -SDK/architecture-qualified Xcode settings that link the exact selected binaries, -plus one phase that copies and signs the selected frameworks into the app. -Configurations containing `debug` or `development` select Debug; every other -configuration selects Release. Selection uses only generated build settings and -standard macOS tools: builds do not run Node, mutate symlinks, regenerate the -package graph, or require a second build. - -## What to commit - -| Path | Commit? | Why | -|------|---------|-----| -| `MyApp.xcodeproj/` | Yes | Your project, with SwiftPM injected in place. Holds your signing, capabilities, Build Phases — `add` only adds SwiftPM refs/settings, additively. | -| `MyApp.xcodeproj/.spm-injected.json` | Yes | Marker recording every edit `add` made, so `deinit` can surgically reverse it and re-runs stay idempotent. Also pins settings later runs and Xcode builds must reuse, such as the [autolinking config command](#the-autolinking-config-command-is-remembered). | -| `build/generated/` | No | Codegen/autolinking output; regenerated | -| `build/xcframeworks/` | No | Symlinks to the machine-local artifact cache | -| `Package.resolved` | No | SwiftPM resolution file; machine-specific | - -Injection is **purely additive** and **idempotent**: `add`/`update` insert only -SwiftPM package refs, the React build settings, the Sync build phase, and a scheme -pre-action — every other byte (your signing / capabilities / Build Phases) -stays untouched, and a re-run is a no-op. The injected refs point at three -stable sub-package paths under `build/`; adding or removing community deps -changes the sub-package contents (gitignored) and never re-injects. `deinit` -removes exactly what was injected (using the marker), leaving the project -byte-identical to its pre-`add` state. - -Because everything under `build/` is gitignored, a clean checkout has no -resolvable Swift packages until they are regenerated — see the next section. - -## Fresh clones & CI - -Xcode resolves the Swift package graph **before any build phase runs**, so on a -clean checkout (where the gitignored `build/` packages don't exist yet) the -auto-sync build phase can't regenerate them in time — a bare `xcodebuild` -fails at *"Resolve Package Graph … build/generated/autolinking doesn't exist"*. - -Run the setup command once after cloning, before building — the SwiftPM analog -of `pod install`: - -```bash -npx react-native spm # downloads artifacts (if missing) + regenerates build/ -``` - -On an already-injected project this routes to `update`: it fetches the -xcframework artifacts into the shared cache if they aren't present and -regenerates `build/xcframeworks` + `build/generated`. After this first run, -incremental dependency changes are picked up automatically by the auto-sync -build phase. - -**Automate it** so nobody has to remember — add a `postinstall` hook, which -runs as part of the `npm install` / `yarn install` your CI already does before -`xcodebuild`: - -```json -{ - "scripts": { - "postinstall": "react-native spm" - } -} -``` - -`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook -works from the app root; in CI (non-interactive) it proceeds without prompting. -It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op -when nothing changed), so it is slightly heavier than the internal `sync` the -build phase calls — a fine trade for not having to remember a command. - -> A future remote-package distribution (a tagged `Package.swift` repo + -> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves and -> fetches the artifacts itself during normal package resolution. Until then, -> the one-time setup run is required on clean machines. - -## Local Native Modules - -Modules not discovered via autolinking can be declared in `react-native.config.js`: - -```js -module.exports = { - spm: { - modules: [ - { - name: 'MyNativeModule', - path: 'ios/MyNativeModule', // relative to app root - exclude: ['*.podspec'], // optional - publicHeadersPath: '.', // optional - }, - ], - }, -}; -``` - -Each entry becomes a target in `build/generated/autolinking/Package.swift`. -Sources outside `build/generated/autolinking/` are automatically mirrored with -file-level symlinks. - -## Self-managed community packages - -A community library that ships its own `Package.swift` is referenced -directly by the autolinker instead of being wrapped. To keep SwiftPM's -package identity (which it derives from the path basename) unique across -deps — even when several libs put their manifest inside an `ios/` subdir -— each self-managed dep is exposed through a uniquely-named symlink at -`build/generated/autolinking/libs//`. The aggregator -`Package.swift` references that path, so two libs both shipping -`/ios/Package.swift` never collide on identity `"ios"`. - -The `libs/` directory is wiped and recreated on every autolinker run, -so deleting a dep via `npm uninstall` cleans up the alias automatically -on the next build. - -## Community packages without a Package.swift - -If an autolinked library ships **no `Package.swift`**, the build fails with a -clear per-dep error (`Package.swift is missing for library ""`). Generate -one from the library's podspec: - -```bash -npx react-native spm scaffold # writes Package.swift into node_modules// -``` - -Because `node_modules/` isn't committed, persist it so it survives the next -install: - -```bash -npx patch-package # then commit the generated patch -``` - -**Better: contribute the manifest upstream.** The generated `Package.swift` is -a normal, committable manifest — the ideal fix is for the library to ship it -itself, so every consumer gets SwiftPM support without a local patch. Please -**file an issue or open a PR on the library** with the scaffolded -`Package.swift` (mention it was generated by `react-native spm scaffold` for -React Native SwiftPM support). Until it lands upstream, the `patch-package` -workaround keeps your app building. - -> A library whose sources mix Swift **and** Objective-C/C++ in one target, or -> that ships neither a `Package.swift` nor a podspec, can't be scaffolded -> automatically — the error says so. Opt it out via `react-native.config.js` -> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. - -## Framework plugins (Preview) - -Frameworks with their own module system (e.g. Expo) contribute to the -autolinking graph through a **plugin** — a function invoked on every -regeneration (including the build-time sync) that adds SwiftPM package refs, -product dependencies, and generated sources. Discovery is transitive -(installing the framework is enough), and the plugin returns data that RN -merges idempotently. - -See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the -discovery mechanism, the full context/return contract, lifecycle, and failure -behavior. - -## Removing / resetting - -To remove SwiftPM entirely, use `deinit` (the inverse of `add`): - -```bash -react-native spm deinit # surgically removes everything `add` injected -pod install # then, to restore CocoaPods -``` - -To reset the regenerable build state (without un-injecting), just delete the -gitignored dirs and re-run: - -```bash -rm -rf build/xcframeworks build/generated .build -react-native spm update -``` - -Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does -not touch SwiftPM-generated directories. The cached xcframework slot is shared -across apps; refresh it with `react-native spm update --download force`. - -## Troubleshooting - -| Problem | Fix | -|---------|-----| -| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | -| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | -| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | -| Missing headers | Re-run `react-native spm` | -| "not contained in target" | Re-run setup (regenerates file-level symlinks) | -| Codegen fails | Use `--skipCodegen` to iterate on other parts | -| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | -| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | -| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | -| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | - ---- - -# Reference / internals - -## Pipeline - -`react-native spm add` and `react-native spm update` orchestrate these steps: - -| Step | Script | Output | -|------|--------|--------| -| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | -| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | -| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | -| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | -| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | -| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | -| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | - -## Directory Layout - -``` -my-app/ios/ - MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) - Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) - build/ - generated/ - autolinking/ <-- gitignored (regenerated at build time) - Package.swift - autolinking.json - packages/ <-- synth wrappers for autolinker-managed deps - libs/ <-- symlinks to self-managed deps' Package.swift - dirs, named by Swift module so SwiftPM - package identity stays unique - headers/ <-- generated header symlinks - ios/ <-- gitignored, codegen output - xcframeworks/ <-- gitignored, immutable runtime flavor slots + invariant package - debug/ - React.xcframework -> ~/Library/Caches/.../debug/React.xcframework - ReactNativeDependencies.xcframework -> ... - hermes-engine.xcframework -> ... - release/ - React.xcframework -> ~/Library/Caches/.../release/React.xcframework - ReactNativeDependencies.xcframework -> ... - hermes-engine.xcframework -> ... - ReactHeadersTarget/ <-- canonical Objective-C React headers + module map - ReactNativeHeaders.xcframework -> ... - ReactNativeDependenciesHeaders.xcframework -> ... - flavored-frameworks.json - .artifact-stamp -``` - -## Header Resolution - -React Native uses CocoaPods-style imports (`#import `) that -SwiftPM doesn't natively support. The prebuilt artifacts serve them through SwiftPM -package products — no `-I` search-path flags, and no clang VFS overlay: - -1. **`` and `import React`** resolve through the invariant - **`ReactHeaders` Clang target**. It stages one canonical header copy after - proving Debug and Release expose identical public headers, and uses a plain - `module React` module map with `React/`-prefixed paths. -2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, - `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a - headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM - auto-serves to dependents. -3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, - `double-conversion/`, `fast_float/`, `SocketRocket/`) come from - **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only - sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` - is framework-type and can't expose those headers to SwiftPM). - -Targets that compile against React take these as product dependencies -(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus the -app's `ReactAppHeaders`), so all of the above resolve with zero search-path -flags. - -## Auto-Sync Build Phase - -The generated `.xcodeproj` includes a **Sync SPM Autolinking** shell script -build phase. It keeps `build/generated/autolinking/Package.swift` up to date -without requiring manual re-runs of `react-native spm` for incremental -dependency changes. (It cannot bootstrap a fresh clone — Xcode resolves the -package graph before any phase runs; see [Fresh clones & CI](#fresh-clones--ci).) - -**How it works:** - -1. Compares timestamps of staleness inputs against `build/generated/autolinking/.spm-sync-stamp`: - - `package.json` — dependency declarations - - `react-native.config.js` — `spm.modules` config - - `node_modules/` directory mtime — updated by any package manager (npm, yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups - - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks stale -2. If any input is newer (or the stamp is missing): runs `npx react-native spm sync`, - which re-executes autolinking + package generation (downloading artifacts if - the cache slot is incomplete) and writes the stamp file. -3. If all inputs are fresh: exits immediately (~1ms). - -**Build phase ordering:** - -| # | Phase | -|---|-------| -| 0 | Resolve Package Graph (Xcode — runs before all build phases) | -| 1 | Sync SPM Autolinking | -| 2 | Sources (compile) | -| 3 | Frameworks (link) | -| 4 | Embed React Native Flavored Frameworks | -| 5 | Resources (copy) | -| 6 | Build JS Bundle | - -Failures in the sync phase are non-fatal — it emits a `warning:` and exits 0, -so an already-generated package graph can still produce a successful build. diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md new file mode 100644 index 000000000000..038c36078c37 --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -0,0 +1,109 @@ +# SwiftPM (Apple platforms) — Preview + +[🏠 Home](../../../../../__docs__/README.md) + +> **Preview.** SwiftPM support is an early preview: the commands, flags, +> generated layout, and distribution model may change in future releases, and it +> is not yet recommended for production. CocoaPods remains the supported +> default. + +The scripts in `scripts/spm/` let a React Native iOS app consume React Native +through **Swift Package Manager** instead of CocoaPods, using prebuilt +XCFrameworks. Support is opt-in and additive: `npx react-native spm` injects +package references into the app's existing `.xcodeproj` in place, and `deinit` +reverses exactly what it injected. + +The motivation, staged migration plan, and open questions live in +[RFC0994](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0994-swift-package-manager-support-for-react-native-ios-projects.md). +The documents here describe how the implementation actually works. + +### macOS fork integration + +The fork preserves the `RCTUIKit` module and its React compatibility imports. +Prebuilt core mode uses a dependency-only `React-RCTUIKit` facade: the header +sidecar owns its headers, and the React binary product includes its +implementation. Header sidecars follow the binary's platform slices. Generated +linker defaults select AppKit on macOS and UIKit on the supported iOS-family +platforms. + +These foundations do not yet make the app setup flow platform-neutral. The +project injector still selects mobile/Catalyst slices and uses iOS codegen +directories; end-to-end macOS app setup requires the later platform integration. + +The React Native CLI accepts `--configCommand`; direct invocation of +`scripts/setup-apple-spm.js` uses `--config-command`. Both reach the same JSON +argv parser and persisted command. + +## 🚀 Usage + +```bash +cd ios +npx react-native spm # add on first run, update thereafter +``` + +**If any autolinked dependency ships no `Package.swift`, this stops with +`error: Package.swift is missing for library ""` and exit code 2.** That +is deliberate — `add` and `update` never scaffold silently, so a missing +manifest is visible and fixed on purpose. Generate the manifests first, then +re-run setup: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +npx react-native spm # then inject as usual +``` + +Because `node_modules` isn't committed, persist each scaffolded manifest with +`npx patch-package ` and commit the patch — otherwise the same error +returns on every fresh install and in CI. Better still, contribute the manifest +upstream. See +[Community packages without a Package.swift](./spm-scripts.md#community-packages-without-a-packageswift). + +See **[spm-scripts.md](./spm-scripts.md)** for the CLI actions and flags, +CocoaPods migration, brownfield apps, what to commit, fresh clones and CI, and +troubleshooting. + +## 📐 Design + +Three documents cover the design, each owning one area: + +| Document | Covers | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [spm-scripts.md](./spm-scripts.md) | The tool itself: CLI surface, the six-step pipeline, [every file it creates or modifies](./spm-scripts.md#files-the-tool-touches), the two auto-sync hooks, and how Debug/Release flavor selection works. | +| [spm-header-paths-contract.md](./spm-header-paths-contract.md) | How headers and package references resolve. The contract is **zero-`-I`**: no header search paths and no `unsafeFlags` in any generated manifest. Also covers remote mode. | +| [spm-autolinking-plugins.md](./spm-autolinking-plugins.md) | The extension seam for frameworks with their own module system (Expo is the first consumer): discovery, the full context/return contract including `flavoredFrameworks`, `watchPaths` and `scriptPhases`, and failure behavior. | + +Two ideas explain most of the architecture: + +- **Headers go through SwiftPM; runtime binaries do not.** A `binaryTarget` + cannot vary by build configuration, but React Native ships flavored binaries + (a debug `React.framework` carries the dev menu and assertions; release strips + them). So the package graph vends headers only, and the flavored frameworks + are linked and embedded through generated Xcode build settings instead. +- **Generated state is regenerable, and the injection is reversible.** + Everything under `build/` is gitignored and rebuilt from the app's + `package.json`; everything written into the `.xcodeproj` is recorded in a + `.spm-injected.json` marker so `deinit` can undo precisely that. + +## 🔗 Relationship with other systems + +### Part of + +- iOS build system — the alternative to the CocoaPods integration in + [`scripts/cocoapods/`](../../cocoapods). + +### Used by this + +- **Prebuilt XCFrameworks** from [`scripts/ios-prebuild/`](../../ios-prebuild) — + produces the `React`, `ReactNativeDependencies`, `hermes-engine`, and + headers-only artifacts that these scripts download, stage, and link. +- **Codegen** (`generate-codegen-artifacts.js`) — its output is installed as a + local `React-GeneratedCode` package rather than a Pod. +- **`@react-native-community/cli config`** — supplies the autolinking metadata + (`autolinking.json`) that the SwiftPM autolinker turns into a `Package.swift`. + Overridable via `--configCommand`. + +### Uses this + +- Apps opting into SwiftPM, via the `spm` React Native CLI command. +- Frameworks layering their own module system on top of React Native, via + [autolinking plugins](./spm-autolinking-plugins.md). diff --git a/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md new file mode 100644 index 000000000000..3a23c378f91e --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-autolinking-plugins.md @@ -0,0 +1,359 @@ +# SwiftPM Autolinking Plugins (Preview) + +> **Preview / unstable contract.** The discovery mechanism and the plugin +> function's context/return shape may change while the first consumers (Expo) +> validate it. Pin to a React Native version if you depend on it. + +How a framework with its own module system — Expo is the first consumer — +contributes to the SwiftPM autolinking graph that `npx react-native spm` +generates. See [spm-scripts.md](./spm-scripts.md) for the base tool. + +## Why a plugin (not a static list or a post-process) + +The documented extension points don't cover a framework: + +- `spm.modules` in `react-native.config.js` is a **static** list of simple + source modules. A framework discovers its modules **dynamically** (scanning + `node_modules`), generates a **module registry**, and ships mixed + Swift/ObjC/C++ modules (e.g. `ExpoModulesCore`) that `spm scaffold` can't + handle. +- A one-shot **post-process** of the generated `Package.swift` is **clobbered on + the next sync**: the Xcode [auto-sync hooks](./spm-scripts.md#auto-sync) + re-run autolinking on every dependency change. A framework's contribution must + run _whenever autolinking runs_. + +A plugin is exactly that. It is invoked from `generate-spm-autolinking.js`'s +`main()` — the single function that both `add` / `update` **and** the build-time +`sync` call — so the contribution is regenerated on every build and never goes +stale. + +(This is the SwiftPM analog of the seams CocoaPods gave Expo: the Podfile, +`use_expo_modules!`, and `react_native_post_install` hooks.) + +## Discovery — transitive, zero app config + +A dependency opts in from its **own** `react-native.config.js`, so installing +the framework is enough (mirrors how CocoaPods pulls in `use_expo_modules!` +transitively): + +```js +// node_modules/expo/react-native.config.js +module.exports = { + spm: {autolinkingPlugin: './spm/autolinking-plugin.js'}, +}; +``` + +The autolinker already walks every dependency's `react-native.config.js`; any +that declares `spm.autolinkingPlugin` is `require`d and invoked. No app-level +registration or allowlist is required. + +**Opt-out escape hatch.** An app can exclude a plugin from its own +`react-native.config.js`: + +```js +module.exports = { + spm: {denyPlugins: ['some-framework']}, // npm names to skip +}; +``` + +## The contract + +A plugin is a function exported from the module named above +(`module.exports = fn`, or `default` / `plugin` named exports also work): + +```js +module.exports = function plugin(context) { + return { + packageDependencies: [ + // Local package (e.g. a scanned module dir) … + { + name: 'ExpoModulesCore', + path: '../../../node_modules/expo-modules-core/ios', + }, + // … or a remote/published package: + // {name: 'SomePkg', url: 'https://…/SomePkg.git', version: '1.2.3'}, + ], + productDependencies: [ + // Linked by the app's AutolinkedAggregate target: + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ], + generatedSources: [ + // e.g. the generated module registry, registered with codegen: + {path: 'build/generated/expo/ExpoModulesProvider.swift'}, + ], + flavoredFrameworks: [ + // Precompiled dynamic XCFrameworks that come in mandatory Debug/Release + // pairs. RN validates and stages both outside the SwiftPM graph. + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/…/output/debug/xcframeworks/ExpoModulesCore.xcframework', + release: '/…/output/release/xcframeworks/ExpoModulesCore.xcframework', + }, + }, + ], + watchPaths: [ + // Inputs whose edits must re-trigger the auto-sync — the plugin's own + // manifest and per-module config (absolute paths, dirs or files): + '/…/node_modules/expo/Package.swift', + '/…/node_modules/expo/expo-module.config.json', + ], + scriptPhases: [ + // Build-time shell phases on the app target — SwiftPM's missing + // `script_phase`: + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: '"$NODE_BINARY" .../createExpoConfig.js', + position: 'beforeCompile', // default: 'end' + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/EXConstants.bundle/app.config'], + alwaysOutOfDate: true, + }, + ], + }; +}; +``` + +### `flavoredFrameworks` — per-configuration precompiled frameworks + +Each entry is +`{id, frameworkName, linkage: 'dynamic', flavors: {debug, release}}`. Both +flavor paths must be absolute and present when `spm add` or `spm update` runs. +The framework and executable names, public headers, and platform slices must +agree across flavors. Static binaries, nested frameworks, duplicate IDs, and +duplicate embedded framework names are fatal. + +The declarations are recorded to +`/.spm-plugin-flavored-frameworks.json`, normalized into the same +immutable app-local slots as React Native, and added to Xcode's exact linker and +embed settings. They are not emitted as SwiftPM product dependencies. Adding or +removing one requires `spm update`; the build-time `spm sync` intentionally does +not mutate runtime framework settings. + +### `watchPaths` — plugin staleness inputs + +`watchPaths` is an array of **absolute** paths (dirs **or** files) the Xcode +auto-sync hooks watch to decide whether they must re-sync. RN already watches +each module's source dir plus every npm dep's checked-in `Package.swift` and +`.react-native/` dir; a plugin adds the inputs only it knows about — e.g. +`packages/expo/Package.swift`, `expo-module.config.json`, and per-module +manifests. On the next build the phase re-syncs when a watched **file** is newer +than the last sync, a watched **dir** has a newer child, or a watched path has +**vanished** (a rename forces a re-sync so the config error surfaces). + +Unlike `flavoredFrameworks`, watch paths are best-effort: a non-array is ignored +with a warning (never fatal), and each non-string / empty / **relative** entry +is dropped with a warning. Absolute-only, because the generated phase tests +these paths with no cwd context. The kept paths are folded into +`/.spm-sync-watch-paths` alongside RN's own, then deduped and sorted. + +### `scriptPhases` — build-time shell phases on the app target + +SwiftPM has no equivalent of CocoaPods' `script_phase`, so a framework that must +run a script during the app's build — `expo-constants` writing +`EXConstants.bundle/app.config` is the first consumer — declares it here. Each +entry is recorded to `/.spm-plugin-script-phases.json`, which +`spm add` / `spm update` reads to emit one `PBXShellScriptBuildPhase` per entry +on the injected app target: + +| Key | Meaning | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | **Stable key** — the ledger entry and the deterministic UUID seed. Charset `/^[@A-Za-z0-9_./-]+$/`, so a scoped npm name like `@expo/log-box` is a valid id; a `:` is not, because the id is hashed into the UUID seed as `plugin:` and the separator must stay unambiguous. Renaming it is a _remove + add_, not a rename. | +| `name` | The phase's display name in Xcode. Any non-empty single-line string — see **Hostile names** below. | +| `script` | The shell body. | +| `position` | `'beforeCompile'` or `'end'`. Optional, default `'end'` — see **Placement** below. | +| `inputPaths` / `outputPaths` | Optional Xcode input/output file lists, which is what lets Xcode skip an up-to-date phase. | +| `alwaysOutOfDate` | Optional; when `true` the phase runs on every build regardless of its file lists. | + +**Placement.** `'end'` appends at the true end of the target's `buildPhases` — +after the app's own JS-bundle phase. `'beforeCompile'` lands directly after the +"Sync SPM Autolinking" phase, which stays first because it regenerates the +content everything else reads, and always **before Sources**: React Native never +re-seats its own sync phase, so if you have dragged that below Sources your +`beforeCompile` phases are seated ahead of Sources instead of following it. +Phases sharing a position keep their declared order. + +**Position is enforced on every sync.** `add`/`update` compares where the plugin +phases actually sit in `buildPhases` against the declared placement and, **only +when the two differ**, lifts their membership lines and re-seats them in +declared order. So changing `position` — or swapping two phases that share one — +takes effect on the next `spm add`/`update`, with no remove + re-add. When they +agree nothing is rewritten, which is what keeps an unchanged declaration +re-syncing to a byte-identical project. The consequence worth knowing: a phase +you **drag somewhere else in Xcode is moved back** to its declared position on +the next sync, because the plugin's declaration is the source of truth. Only the +`id` behaves differently — it is a key, not a label, so renaming it is a remove + +- add. + +Phases are injected by `spm add` / `spm update` **only**. The build-time `sync` +rewrites the sidecar but never touches the `.xcodeproj`, so a newly declared +phase appears on the next `add`/`update`, not on the next build. Each phase's +UUID is derived from its `id` and recorded in the `.spm-injected.json` marker's +`scriptPhases` map, so a re-run refreshes the phase's `name`, `script`, path +lists, `alwaysOutOfDate` and placement in place, `update` removes phases that +left the sidecar, and `deinit` reverts all of them. + +Validation is **fatal**, like `flavoredFrameworks` and unlike `watchPaths`: a +non-array `scriptPhases`, a malformed entry, or a duplicate `id` (within one +plugin or across plugins) aborts the run. A silently dropped phase would produce +a green build whose generated content was never written — a runtime failure with +no build-time signal — and two phases sharing an `id` would collapse onto one +ledger key. `__proto__`, `constructor`, and `prototype` are rejected as ids even +though the charset admits them: as keys of that ledger they never become own +properties, so the phase would look recorded, disappear when the marker is +serialized, and be unremovable by `deinit`. + +**Hostile names.** A `name` reaches the project file twice. In the `name` field +— what Xcode displays — it lands verbatim, escaped as an OpenStep string, so any +single-line string is expressible. Beside the phase's UUID, on the object's +definition line and on its `buildPhases` member line, it also becomes a +`/* … */` comment; those comments are cosmetic (Xcode regenerates them from the +`name` field) but the text around them is scanned by delimiter, so the name is +**normalized** there: `{}(),;="*/`, tabs and whitespace runs collapse to single +spaces (`spm-pbxproj.js`'s `commentSafe`), falling back to the phase `id` — +normalized the same way — and then to no comment at all if nothing survives +either. Without that, a `{` in a comment would make the injector read the next +object's body as this one's, and a `,` would make `deinit` delete the wrong line +— corruption with no error. Only a line break is therefore rejected outright; a +name is a display name, and no Xcode phase name spans lines. + +The injector's read of the sidecar is deliberately lenient — the file does not +exist yet on a first `spm add`, and a stale or hand-edited copy must not break +injection. An absent file yields no phases silently, an unparseable one warns, +and a single entry failing the same checks (bad or reserved `id`, empty or +multi-line `name`, missing `script`, unknown `position`, duplicate `id`) is +**skipped, never coerced** — the sidecar is the only gate on a hand edit, so it +enforces exactly the rules the plugin contract does. + +**Gating is the script's job.** The phase runs for every configuration and +platform the target builds; if it should be a no-op for some of them (Release +only, simulator only, …), the script must check `$CONFIGURATION` / +`$PLATFORM_NAME` and exit early. + +### Context (input) + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `appRoot` | The Xcode project directory (`/ios`) being injected — **not** the app package root. Deriving package-root-relative paths from it (e.g. `path.join(appRoot, 'node_modules')`) silently breaks; use `projectRoot` for that. | +| `projectRoot` | The JS root (nearest `package.json`) — where the framework scans `node_modules`. | +| `reactNativeRoot` | Resolved `react-native` package root. | +| `autolinking` | Parsed `autolinking.json` — RN's already-discovered deps, so the plugin can react to them. | +| `outputDir` | `build/generated/autolinking` — where generated artifacts land. | +| `react` | How to depend on React (see below). `null` when there is no resolvable React dependency. | + +#### `context.react` — depending on React + +A plugin that emits its own `Package.swift` must declare React as a dependency. +Rather than re-deriving React Native's package path, identity, and product names +— which differ between local and remote mode and **move as RN repackages** — +take them from `context.react`: + +```js +react: { + packageRef: + {name: 'ReactNative', path: '', relPath: ''} // local + | {name: '', url: '', version: ''}, // remote (SPM-resolved) + products: [ + {name: 'ReactHeaders', package: 'ReactNative'}, + {name: 'ReactNativeHeaders', package: 'ReactNative'}, + {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, + {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, // ← separate, per-app package + ], +} +``` + +Local vs remote is signalled by which `packageRef` keys are present (`path` xor +`url`+`version`). `packageRef.path` is **absolute** — always correct no matter +which subdirectory of `outputDir` the plugin writes its own manifest into (the +generated manifests are gitignored and regenerated every sync, so there's no +portability cost); `relPath` (relative to `outputDir`) is provided as a +convenience. `products` is the set React Native wires into **its own** +autolinked targets (so a plugin's target compiles against exactly RN's React +surface), filtered to those resolvable this run — every listed product is safe +to reference without guarding. Note the fourth entry: `ReactAppHeaders` lives in +the separate `React-GeneratedCode` package (per-app codegen), which a +hand-rolled plugin would miss, and which is omitted when that package is absent. +Because RN derives this list from one source of truth alongside its own product +wiring, it stays correct across repackaging. + +### Return (contributions, all optional) + +| Field | Merged into | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `packageDependencies` | The aggregator's `.package(…)` list (`path`, or `url` + `version`). | +| `productDependencies` | The `AutolinkedAggregate` target's `dependencies:` (`.product(name:package:)`). | +| `generatedSources` | Recorded for the codegen step to register (e.g. a module-registry `.swift`). | +| `flavoredFrameworks` | Mandatory Debug/Release dynamic XCFramework pairs normalized outside SwiftPM. Malformed or incomplete entries are fatal. | +| `scriptPhases` | Recorded for `spm add` / `update` to emit one `PBXShellScriptBuildPhase` per entry on the app target. Malformed entries and duplicate `id`s are fatal. | + +The plugin returns **data** — it never writes into React Native's generated +tree. RN owns the merge, so a re-sync reproduces the same `Package.swift` +byte-for-byte (idempotent). Package and product contributions are **deduped by +name** across plugins. + +## Lifecycle + +```text +react-native spm add / update ─┐ + ├─► generate-spm-autolinking main() +Xcode "Sync SPM Autolinking" ──┘ │ +(build phase, every build) ├─ 1. discover plugins (dep configs) + ├─ 2. RN builds its own dep graph + ├─ 3. invoke plugins (context in) + └─ 4. merge results → aggregator Package.swift +``` + +Because steps 1–4 run in the one `main()`, everything above shares the same seam +— there is no separate hook to wire for the build-time path. + +## Failure behavior + +Fail-closed and **named**: a plugin that fails to load, doesn't export a +function, throws, or returns a malformed contribution aborts the run with a +message identifying the framework. A framework silently dropping its modules (a +green build missing native code) is worse than a loud stop. + +## Status & open items (Preview) + +- **Implemented & tested:** discovery (transitive + deny-list), invocation, + package + product merge, fail-closed validation, and dual-flavor framework + normalization/link/embed outside SwiftPM. +- **Implemented & tested:** `generatedSources` **app-target wiring**. The merge + writes `.spm-plugin-generated-sources.json`; the `spm add`/`update` xcodeproj + injector (generate-spm-xcodeproj.js) reads it and wires each source **into the + app target** — a `PBXFileReference` + `PBXBuildFile` + a Sources-build-phase + entry, parented under one "SPM Generated Sources" navigator group. This is + what makes an `@objc` class (e.g. Expo's `ExpoModulesProvider`) reach the ObjC + classlist: a class inside the static Autolinked aggregate never does, so + `NSClassFromString` discovery would fail. Paths are stored SRCROOT-relative + when under the app root (the usual `build/generated/…` case), else absolute + (`sourceTree = ""`). All UUIDs are namespaced on the normalized path + (deterministic/idempotent) and recorded in the `.spm-injected.json` marker's + `generatedSources` map, so `deinit` reverts them and `update` reconciles + entries that left the manifest. A target without a Sources phase logs loudly + and skips the wiring (injection otherwise succeeds). v1 targets only the + injected app target and assumes `.swift` in practice (`.m`/`.mm` are mapped as + future-proofing). +- **Implemented & tested:** `scriptPhases`, contract through injection. + `invokePlugins` validates every entry fatally (`id` charset plus the reserved + `__proto__`/`constructor`/`prototype` names, a single-line `name`, a required + `script`, the `position` enum, optional path lists and `alwaysOutOfDate`, plus + duplicate `id`s), and the merge always rewrites + `.spm-plugin-script-phases.json` — `[]` when no plugin declares any, so + removing a plugin clears stale entries. The `spm add`/`update` xcodeproj + injector reads that sidecar and emits one `PBXShellScriptBuildPhase` per entry + on the app target at the requested position, recording the id→UUID map in the + `.spm-injected.json` marker so a re-run refreshes each phase's content in + place, re-seats it when its declared position or order changed, `update` + removes phases that left the sidecar, and `deinit` reverts them. Like + `flavoredFrameworks`, the build-time `sync` only rewrites the sidecar; it + never mutates the project. +- **Co-design with Expo (not final):** codegen **provider ordering** — codegen + must consume the same discovered module set the plugin contributes — is + intentionally left for the first real plugin to drive to a stable shape. +- Contract to be ratified via RFC once Expo's plugin proves it (framed as a + generic hook, not Expo-specific code in RN). diff --git a/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md new file mode 100644 index 000000000000..ff467c249b92 --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-header-paths-contract.md @@ -0,0 +1,100 @@ +# SPM headers & package references — how they resolve + +React Native's SPM consumption is **zero-I**: no `-I` / `-F` header search paths +and no `unsafeFlags` in any generated manifest. Headers are served by SPM +products/binary targets, and every generated `Package.swift` references the +React Native + codegen packages with plain, fixed-relative paths computed at +generation time (no runtime discovery). This document is the single source of +truth for how that resolves. + +> History: earlier iterations materialized two header trees and fed them to +> consumers as `-I` flags read from `spm-paths.json` / +> `.react-native/paths.json` via an inlined Swift loader. That whole mechanism +> (the loader `renderRNPathsLoader`, the `writeAppPathsJson` / +> `writeSharedPathsJson` writers, and both JSON files) has been **deleted** — +> manifests are now declarative. If you find a reference to those files, it is +> stale. + +## How headers resolve (no search paths) + +| Namespace | Served by | Mechanism | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Objective-C `` / Swift `import React` | `ReactHeaders` Clang source target | Canonical Debug/Release-identical React headers staged under `ReactHeadersTarget/include/React`, with a plain `module React` module map. | +| Lowercase C++ `` and everything else: ``, ``, ``, ``, folly/glog/boost/fmt/double-conversion | `ReactNativeHeaders.xcframework` plus `ReactNativeDependenciesHeaders.xcframework` | Header-only invariant binary targets keep lowercase `react` separate from Objective-C `React` and propagate their search paths through product dependencies. | +| ``, `ReactAppDependencyProvider`, this app's generated specs | `ReactAppHeaders` SPM target in the codegen package | SPM `publicHeadersPath` propagation — a real target dependency, not a flag. | + +The one remaining materialized header tree is the per-app farm at +`/build/generated/ios/ReactAppHeaders` (built by +`buildPerAppHeaderTree` in `spm-utils.js`, called from the orchestrators). It is +vended as the `ReactAppHeaders` SPM target — consumers reach it through a +product dependency, never through `-I`. + +`autolinking.json` (the `@react-native-community/cli config` output) is an INPUT +used to generate the manifests; it is never read by a manifest. + +## How each manifest references the React + codegen packages + +Every generated manifest sits at a known depth inside the app and is regenerated +on every `react-native spm` run, so package references are plain fixed-relative +paths — no walk-up, no JSON, no `import Foundation`. + +| Manifest | Location | How it references the React + codegen packages | +| ------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Autolinked aggregator | `build/generated/autolinking/Package.swift` | `.package(path: "../../xcframeworks")` + `"../ios"` (only when it has inline `spmModule` targets) | +| Per-dep synth wrapper | `build/generated/autolinking/packages//` | `.package(path: "../../../../xcframeworks")` + `"../../../ios"` | +| Codegen template | `build/generated/ios/Package.swift` | `.package(path: "../../xcframeworks")` (or the remote url) | +| App target (pbxproj) | `.xcodeproj` | local `XCLocalSwiftPackageReference` (or `XCRemoteSwiftPackageReference` in remote mode) | +| Scaffolded community lib | `node_modules//Package.swift` | scaffold-time relative paths to the app's xcframeworks + codegen packages (or `.package(url:exact:)` in remote mode) | + +## Remote-package mode + +Remote mode is gated by a **URL alone** — `RN_SPM_REMOTE_URL` (or the persisted +`url`). When set, the whole app graph flips to a single remote React Native +package identity: `.package(path: build/xcframeworks)` becomes +`.package(url:exact:)` everywhere (aggregator/synth/codegen template/pbxproj), +and the local artifact download + compose is skipped. SPM's +one-version-per-package rule then unifies app + every library on one resolved +React Native. The package identity is derived from the URL tail (swift-tools 6 +dropped `.package(name:url:)`) — nothing hardcodes a repo name. + +**Version is derived from npm, not pinned by hand.** The SPM-pinned RN version +is not a free parameter: the SPM graph must compile against the same React +Native the JS/native code uses, so the app (graph root) pins EXACT to the +_installed_ RN version, read from `node_modules/react-native/package.json`. +`RN_SPM_REMOTE_VERSION` and the persisted `versionOverride` are **overrides**, +not the source of truth — they're only needed when the installed version isn't +publishable (e.g. the monorepo `1000.0.0` dev placeholder, which has no remote +tag). A _derived_ version is never persisted, so an `npm install` that upgrades +RN auto-re-pins the SPM graph on the next `spm` run; an _override_ is persisted +as `versionOverride` so it survives Xcode-phase re-syncs without the env. + +Persisted schema is `{url, versionOverride?}`. Legacy `{url, version}` is still +read, with `version` honored as an override (back-compat). If remote mode is on +but no usable version can be resolved — react-native isn't installed, or it's a +non-publishable dev placeholder and no override is set — the tooling errors +(exit 2, a hard Xcode build error) directing you to set `RN_SPM_REMOTE_VERSION` +or install a released react-native, rather than silently pinning an unpublished +tag. + +## Hand-authored community library contract + +A library that ships its own `Package.swift` (no scaffolder/autolinker marker) +is left untouched by the tooling. It needs only two things, and **no discovery +code**: + +1. Depend on the React Native SPM package and its products — in remote mode + `.package(url: "", exact: "")` + + `.product(name: "ReactNative", …)` and + `.product(name: "ReactNativeHeaders", …)`. (Libraries should declare a + version RANGE in production; the consuming app pins EXACT.) +2. Ship its own generated code: set `codegenConfig.includesGeneratedCode: true` + and generate with + `generate-codegen-artifacts.js --path . --targetPlatform ios --source library`. + Output lands at `/build/generated/ios/ReactCodegen/`, reachable + from the manifest with one safe `.headerSearchPath(...)` into the library's + own tree. The app-side codegen then skips the lib's spec (no duplicate + symbols). + +This makes the library self-contained — it carries no app-layout knowledge and +needs no per-app codegen headers from the consuming app. Proven with +`@chrfalch/react-native-calculator` (a hand-authored Fabric/TurboModule lib). diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md new file mode 100644 index 000000000000..af1c6e8a463c --- /dev/null +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -0,0 +1,646 @@ +# SwiftPM Scripts – React Native iOS via Swift Package Manager (Preview) + +> **Preview.** SwiftPM support is an early preview: the commands, flags, +> generated layout, and distribution model may change in future releases, and it +> is not yet recommended for production. Feedback is welcome. CocoaPods remains +> the supported default. + +Build React Native iOS apps using **Swift Package Manager** with prebuilt +XCFrameworks, as an alternative to CocoaPods. It is **opt-in and additive** — +CocoaPods remains the default; `spm` injects into your existing `.xcodeproj` in +place and is fully reversible. + +## Quick Start + +```bash +cd ios + +# First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, +# in place. `npx react-native spm` with no action auto-resolves to `add` (or +# `update` once injected); on a fresh CocoaPods app it converts in one command +# (implies --deintegrate). To do it explicitly: +npx react-native spm add --deintegrate + +# Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. +open MyApp.xcodeproj +``` + +After the initial run, the project carries **auto-sync hooks** that detect +dependency changes and re-run autolinking before compilation (see +[Auto-Sync](#auto-sync)) — you don't re-invoke `react-native spm` manually for +day-to-day dependency changes. **On a fresh clone or CI checkout, run +`npx react-native spm` once before building** (see +[Fresh clones & CI](#fresh-clones--ci)). + +> **Note:** `react-native spm` is a thin wrapper over +> `node node_modules/react-native/scripts/setup-apple-spm.js`. If the CLI alias +> is unavailable in your environment, invoke the script directly with the same +> actions and the kebab-case flag equivalents (e.g. `--skip-codegen`). + +## CocoaPods → SwiftPM migration + +`spm add` injects into a project that is **not** CocoaPods-integrated. On a +CocoaPods app it fails loud and points you at `--deintegrate`, which: + +1. runs `pod deintegrate` — removes CocoaPods integration from the `.xcodeproj` + (Pods references, `[CP]` build phases, xcconfig links). Your `Podfile` is + left on disk. +2. strips **only** the React Native directives (`use_react_native!`, + `use_native_modules!`, `prepare_react_native_project!`) from the Podfile — + every other line, **including your own `pod '…'` entries, is preserved**. +3. injects SwiftPM into the `.xcodeproj`. + +React Native now comes from SwiftPM; no pods are linked yet (deintegrate removed +the integration). + +### Keeping non-RN pods + +Non-RN pods can stay side-by-side. After `spm add --deintegrate` your Podfile +still lists them (only the RN directives were removed) — re-integrate them with +a normal install: + +```bash +pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace +``` + +Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes +the SwiftPM-injected project, so React Native resolves through SwiftPM and your +other pods through CocoaPods, together. + +> **Do not re-add `use_react_native!`.** React Native must be provided by +> _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, +> so a dual-managed RN does not build. `spm add` refuses to run while the +> Podfile still declares `use_react_native!`. + +The migration is fully reversible — see +[Removing / resetting](#removing--resetting). + +## Brownfield apps + +`spm add` injects into your existing `.xcodeproj` in place, so an app that +embeds React Native works the same way — point it at the right project and +target: + +```bash +npx react-native spm add --xcodeproj MyApp.xcodeproj --productName MyApp +``` + +**Requirement:** the `.xcodeproj` must live **inside the React Native JS tree** +— i.e. the app's `package.json` is a parent directory of the project. Both setup +and the build-time sync locate React Native by walking up from the project to +the nearest `package.json`. The common "native project at the repo root with the +RN JS in a sibling/child subfolder" layout is **not supported yet** — there is +no way to point at a JS root outside the project's ancestors. + +Brownfield apps that keep CocoaPods for their other native dependencies follow +the [coexistence rules above](#keeping-non-rn-pods): React Native from SwiftPM, +everything else from CocoaPods, and no `use_react_native!` in the Podfile. + +## CLI Actions + +```bash +react-native spm [action] [options] +``` + +With no action, the command **auto-resolves**: if SwiftPM has been injected +(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. On +a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile) the +zero-arg path additionally implies `--deintegrate` (the safe-gate), so +`npx react-native spm` converts a brand-new app to SwiftPM in one command. + +When invoked from the JS root of a standard RN app (sibling `ios/` subdir), the +command auto-redirects into `ios/` with a banner. + +| Action | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Inject SwiftPM packages (package refs, build settings, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | +| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Three things it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | +| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | +| `download` (advanced) | Download/check xcframework artifacts only. | + +## CLI Options + +Flags below use the `react-native spm` (camelCase) form. The raw script accepts +kebab-case equivalents (e.g. `--skip-codegen`). + +| Option | Description | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--version ` | RN version. Resolved in this order: this flag, then the version a previous `--version` pinned into `.spm-injected.json`, then `node_modules/react-native/package.json`. Pass it once — later runs reuse the pin (see [Pinning the React Native version](#pinning-the-react-native-version)) | +| `--yes` | Skip the dirty-pbxproj confirmation prompt | +| `--xcodeproj ` | [add] Which `.xcodeproj` to inject into (when several exist) | +| `--productName ` | [add] Which app target to inject into (when several exist) | +| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | +| `--download ` | [advanced] Artifact download policy (default: auto) | +| `--skipCodegen` | [advanced] Skip the codegen step | +| `--configCommand ` | [advanced] JSON array of the argv used to generate `autolinking.json`, overriding the default `@react-native-community/cli config` command. Also settable via the `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` env var. Either way the value is remembered, so you pass it once. Example: `'["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]'` | + +### The autolinking config command is remembered + +An app that replaces `@react-native-community/cli` autolinking (an Expo app, for +example) has to tell `spm` how to produce `autolinking.json`. Pass the command +once, on `add` or `update`: + +```bash +npx react-native spm add --configCommand '["npx","expo-modules-autolinking","react-native-config","--json","--platform","ios"]' +``` + +Every action that needs `autolinking.json` — `add`, `update`, `scaffold`, and +the build-time `sync` — resolves the command in this order: + +1. `--configCommand` +2. `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` +3. the `configCommand` pinned in `MyApp.xcodeproj/.spm-injected.json` by an + earlier `add`/`update` +4. the default `@react-native-community/cli config` + +`add`/`update` pin whichever of the first two routes supplied the command, +validated as an argv array; a later run that passes neither keeps the existing +pin, and passing `--configCommand` again replaces it. The pin exists because the +**Sync SPM Autolinking** build phase inherits neither your flag nor the shell +that exported the env var — without it, a successful `add` is followed by +failing builds, because the phase re-derives `autolinking.json` with the default +command. A pin never shadows the env var, so an override in your shell still +takes effect, and a pin that no longer parses is ignored in favor of the +default. + +`deinit` deletes `.spm-injected.json`, and the pin with it. A later `add` +therefore falls back to the default command unless you pass `--configCommand` +(or export the env var) again. + +### Pinning the React Native version + +The resolved version selects **which artifact slots the project is wired to**, +so it has to stay the same from one run to the next. `--version` is therefore +recorded in the `.spm-injected.json` marker (as `artifactsVersionOverride`) and +read back by later runs, which resolve the version in this order: + +1. an explicit `--version `, +2. the version a previous `--version` pinned into the marker, +3. `node_modules/react-native/package.json`. + +So you pass the flag once, and a later flagless `add`/`update` stays on the +slots it selected. Without the pin, that flagless run falls back to +`package.json` and re-points the project at different artifact slots while the +marker still advertises the pinned version. + +`deinit` deletes the marker, and with it the pin — a later `add` resolves +`node_modules/react-native/package.json` again unless you pass `--version`. + +### Debug/Release flavor is automatic + +React Native ships **flavored** prebuilt binaries: the _debug_ `React.framework` +(and `hermes-engine` / `ReactNativeDependencies`) carry the dev experience — dev +menu, assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while _release_ strips them +for production. A Debug build must embed the debug binaries and a +Release/archive the release ones. + +SwiftPM `binaryTarget`s can't branch on the build configuration, so runtime +frameworks are deliberately kept out of the package graph. `spm add` downloads +and validates **both** flavors into immutable app-local slots. It injects +SDK/architecture-qualified Xcode settings that link the exact selected binaries, +plus one phase that copies and signs the selected frameworks into the app. +Configurations containing `debug` or `development` select Debug; every other +configuration selects Release. Selection uses only generated build settings and +standard macOS tools: builds do not run Node, mutate symlinks, regenerate the +package graph, or require a second build. + +Those same debug-flavored configurations also get +`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"` — the only thing +that makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches +C/ObjC/C++ only), and what `AppDelegate.swift`'s `bundleURL()` branches on to +load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at +`pod install` time, so this keeps SwiftPM apps at parity. An existing value is +left alone. + +## Files the tool touches + +Paths are relative to the Xcode project directory (`ios/`) unless noted. + +### In your repo — committed + +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — the scheme is deleted if `add` created it, otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | +| `Podfile` | `add --deintegrate` | Only the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | + +The two pinned settings are the `--version` pin (`artifactsVersionOverride`, see +[Pinning the React Native version](#pinning-the-react-native-version)) and the +[autolinking config command](#the-autolinking-config-command-is-remembered). +Because `deinit` drops the marker, it drops both. + +### In your repo — generated, gitignored + +| Path | Written by | Contents | +| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build/generated/ios/` | `add`, `update`, `sync`, `codegen` | Codegen output plus the SwiftPM codegen manifest (the `React-GeneratedCode` package). | +| `build/generated/autolinking/` | `add`, `update`, `sync` | `Package.swift`, `autolinking.json`, `packages/`, `libs/`, `headers/`, the `.spm-sync-stamp`, `.spm-sync-watch-paths`, and any `.spm-plugin-*.json` plugin manifests. | +| `build/xcframeworks/` | `add`, `update`, `sync`, `download` | The `debug/` and `release/` flavor slots (symlinks into the cache), `ReactHeadersTarget/`, the headers-only xcframeworks, `Package.swift`, `flavored-frameworks.json`, `.artifact-stamp`. | +| `.build/`, `Package.resolved` | Xcode / SwiftPM | SwiftPM's own build directory and resolution file. Machine-specific. | + +`deinit` leaves all of the above in place — it is regenerable, and removing it +is `rm -rf build/ .build/` (see [Removing / resetting](#removing--resetting)). + +### Outside your repo + +| Path | Written by | Notes | +| ---------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node_modules//Package.swift` | `scaffold` | A generated manifest for a dep that ships none. Not committed — persist with `patch-package` (see [Community packages without a Package.swift](#community-packages-without-a-packageswift)). | +| `~/Library/Caches/ReactNative/spm-artifacts///` | `add`, `update`, `sync`, `download` | The immutable artifact slots the `build/xcframeworks/` symlinks point at. Shared across apps on the machine. | +| `~/Library/Caches/ReactNative/` | `download` | Downloaded tarballs, shared with CocoaPods. `RCT_SKIP_CACHES=1` bypasses the cache. | + +Injection is **purely additive** and **idempotent**: every other byte of your +project — signing, capabilities, your own Build Phases — stays untouched, and a +re-run is a no-op. The injected refs point at three stable sub-package paths +under `build/`, so adding or removing community deps changes the sub-package +contents (gitignored) and never re-injects. `deinit` removes exactly what was +injected, leaving the project byte-identical to its pre-`add` state — with the +exceptions called out above, and one more described next. + +**Build settings that already exist** are edited in place. The four array +settings `add` merges into — `HEADER_SEARCH_PATHS`, `OTHER_LDFLAGS`, +`FRAMEWORK_SEARCH_PATHS`, `LD_RUNPATH_SEARCH_PATHS` — keep the shape they were +written in: Xcode's multi-line form as well as the compact one-line form hand +edits and other generators (XcodeGen, Tuist) emit. One that exists as a plain +_scalar_ is promoted to a `( … )` array — the shape an Xcode-authored target can +carry, e.g. a +`LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";` written +as a scalar rather than a list. `add` records the pre-injection value in the +marker and `deinit` restores it by rewriting the whole field — once folded +together, the injected members and your own are indistinguishable — so **members +you add to a promoted array by hand afterwards are lost**. That applies to +`update` too, which reverts to the recorded baseline before re-injecting. + +## Fresh clones & CI + +Everything under `build/` is gitignored, so a clean checkout has no resolvable +Swift packages until they are regenerated. Xcode resolves the package graph +before build phases **and** before scheme pre-actions, so neither +[auto-sync hook](#auto-sync) can rescue this: with `build/generated/autolinking` +missing, the build stops at _"Resolve Package Graph … doesn't exist"_ having run +neither hook. + +Verified on Xcode 26.6 against a freshly-injected app with `build/` deleted: +`xcodebuild -scheme … build` fails in nine lines of log, with +`Resolve Package Graph` as the first step and no trace of the pre-action; +`xcodebuild -resolvePackageDependencies` fails identically. Opening the project +in Xcode also resolves the graph on load, before you press Build. + +So run the setup command once after cloning, before building — the SwiftPM +analog of `pod install`: + +```bash +npx react-native spm # downloads artifacts (if missing) + regenerates build/ +``` + +On an already-injected project this routes to `update`: it fetches the +xcframework artifacts into the shared cache if they aren't present and +regenerates `build/xcframeworks` + `build/generated`. After this first run, +incremental dependency changes are picked up automatically by the auto-sync +hooks. + +**Automate it** so nobody has to remember — add a `postinstall` hook, which runs +as part of the `npm install` / `yarn install` your CI already does before +`xcodebuild`: + +```json +{ + "scripts": { + "postinstall": "react-native spm" + } +} +``` + +`npx react-native spm` auto-redirects from the JS root into `ios/`, so the hook +works from the app root; in CI (non-interactive) it proceeds without prompting. +It re-runs the full pipeline (codegen + an idempotent re-inject that is a no-op +when nothing changed), so it is slightly heavier than the internal `sync` the +build phase calls — a fine trade for not having to remember a command. + +> A future remote-package distribution (a tagged `Package.swift` repo + +> `binaryTarget(url:checksum:)`) removes this step entirely: SwiftPM resolves +> and fetches the artifacts itself during normal package resolution. Until then, +> the one-time setup run is required on clean machines. + +## Local Native Modules + +Modules not discovered via autolinking can be declared in +`react-native.config.js`: + +```js +module.exports = { + spm: { + modules: [ + { + name: 'MyNativeModule', + path: 'ios/MyNativeModule', // relative to app root + exclude: ['*.podspec'], // optional + }, + ], + }, +}; +``` + +Each entry becomes a target in `build/generated/autolinking/Package.swift`. +Sources outside `build/generated/autolinking/` are automatically mirrored with +file-level symlinks. + +## Dependencies between libraries + +SwiftPM has no equivalent of a podspec's `s.dependency`, so a library that needs +another native library declares it explicitly with `spm.dependencies` in its +**own** `react-native.config.js` — a list of npm names: + +```js +// react-native-reanimated/react-native.config.js +module.exports = { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['react-native-worklets']}, +}; +``` + +The autolinker starts from the directly-autolinked deps, follows each one's +`spm.dependencies` **recursively**, and dedupes the result, so a transitive +dependency is pulled into the package graph even when the app never depends on +it directly. Declared names are mapped to Swift target names, so the dependent +library's target can import it. + +This is a **library-author** surface, like the podspec dependency it replaces — +apps don't normally set it. + +### Config module format + +`react-native.config.js` may be CommonJS or ESM, and both named and default +exports are read. A key defined twice — as a named export and on the default +export — resolves to the named one. Avoid that shape anyway: the Community CLI +has two loaders that disagree about it, a sync one that sees named exports and +an async one that takes only the default export. For maximum compatibility, +prefer the one-line CommonJS form: + +```js +module.exports = {dependency: {platforms: {ios: {}}}, spm: {name: 'worklets'}}; +``` + +If the config fails to load, a warning names the file and the reason — the `spm` +settings in it are ignored rather than silently applied. + +## Self-managed community packages + +A community library that ships its own `Package.swift` is referenced directly by +the autolinker instead of being wrapped. To keep SwiftPM's package identity +(which it derives from the path basename) unique across deps — even when several +libs put their manifest inside an `ios/` subdir — each self-managed dep is +exposed through a uniquely-named symlink at +`build/generated/autolinking/libs//`. The aggregator `Package.swift` +references that path, so two libs both shipping `/ios/Package.swift` never +collide on identity `"ios"`. + +The `libs/` directory is wiped and recreated on every autolinker run, so +deleting a dep via `npm uninstall` cleans up the alias automatically on the next +build. + +## Community packages without a Package.swift + +If an autolinked library ships **no `Package.swift`**, `spm add`/`update` stops +with a per-dep error (`Package.swift is missing for library ""`) and exits +**2** — a distinct code from a generic failure, so CI and the Xcode sync hooks +can treat it as a hard error while staying lenient about transient sync +failures. + +`add` and `update` deliberately **never** scaffold on your behalf: +auto-scaffolding would hide a real gap in the dependency's SPM support. Generate +the manifest from the library's podspec explicitly, then re-run setup: + +```bash +npx react-native spm scaffold # writes Package.swift into node_modules// +npx react-native spm # then inject/update as usual +``` + +(`scaffold` also runs codegen and regenerates the autolinking package, but it +does not inject into the `.xcodeproj` — so on a first-time setup you still +follow it with `npx react-native spm`.) + +Because `node_modules/` isn't committed, persist it so it survives the next +install: + +```bash +npx patch-package # then commit the generated patch +``` + +**Better: contribute the manifest upstream.** The generated `Package.swift` is a +normal, committable manifest — the ideal fix is for the library to ship it +itself, so every consumer gets SwiftPM support without a local patch. Please +**file an issue or open a PR on the library** with the scaffolded +`Package.swift` (mention it was generated by `react-native spm scaffold` for +React Native SwiftPM support). Until it lands upstream, the `patch-package` +workaround keeps your app building. + +> A library whose sources mix Swift **and** Objective-C/C++ in one target, or +> that ships neither a `Package.swift` nor a podspec, can't be scaffolded +> automatically — the error says so. Opt it out via `react-native.config.js` +> (`platforms.ios = null`) or ask the maintainer for a prebuilt xcframework. + +## Framework plugins (Preview) + +Frameworks with their own module system (e.g. Expo) contribute to the +autolinking graph through a **plugin** — a function invoked on every +regeneration (including the build-time sync) that adds SwiftPM package refs, +product dependencies, and generated sources. Discovery is transitive (installing +the framework is enough), and the plugin returns data that RN merges +idempotently. + +See **[spm-autolinking-plugins.md](./spm-autolinking-plugins.md)** for the +discovery mechanism, the full context/return contract, lifecycle, and failure +behavior. + +## Removing / resetting + +To remove SwiftPM entirely, use `deinit` (the inverse of `add`): + +```bash +react-native spm deinit # surgically removes everything `add` injected +pod install # then, to restore CocoaPods +``` + +To reset the regenerable build state (without un-injecting), just delete the +gitignored dirs and re-run: + +```bash +rm -rf build/xcframeworks build/generated .build +react-native spm update +``` + +Xcode's "Clean Build Folder" (Cmd+Shift+K) only removes DerivedData — it does +not touch SwiftPM-generated directories. The cached xcframework slot is shared +across apps; refresh it with `react-native spm update --download force`. + +## Troubleshooting + +| Problem | Fix | +| ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | +| `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--product-name ` if multiple app targets). | +| `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | +| Missing headers | Re-run `react-native spm` | +| "not contained in target" | Re-run setup (regenerates file-level symlinks) | +| Codegen fails | Use `--skipCodegen` to iterate on other parts | +| "SPM sync failed" warning | Check Xcode build log for details; node may not be in PATH — ensure `with-environment.sh` is present | +| "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `--configCommand` (or with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | +| Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | +| Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | +| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | + +--- + +## Reference / internals + +### Pipeline + +`react-native spm add` and `react-native spm update` orchestrate these steps: + +| Step | Script | Output | +| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 1. CLI config | `spm/generate-spm-autolinking-config.js` | `build/generated/autolinking/autolinking.json` | +| 2. Codegen | `generate-codegen-artifacts.js` | `build/generated/ios/` | +| 3. Autolinking | `spm/generate-spm-autolinking.js` | `build/generated/autolinking/Package.swift` | +| 4. Download | `spm/download-spm-artifacts.js` | Complete Debug and Release cache slots | +| 5. Package | `spm/generate-spm-package.js` | Immutable flavor slots, central manifest, canonical `ReactHeaders`, and invariant `Package.swift` | +| 6. Inject | `spm/generate-spm-xcodeproj.js` | Invariant SwiftPM products plus configuration-qualified linker settings and the embed/sign phase | +| Auto-sync | `spm/sync-spm-autolinking.js` | Re-runs invariant codegen/autolinking output only at Xcode build time | + +### Directory Layout + +```text +my-app/ios/ + MyApp.xcodeproj/ <-- committed (your project; SwiftPM injected in place, carries .spm-injected.json) + Podfile <-- present until `pod deintegrate` (CocoaPods coexistence is best-effort) + build/ + generated/ + autolinking/ <-- gitignored (regenerated at build time) + Package.swift + autolinking.json + packages/ <-- synth wrappers for autolinker-managed deps + libs/ <-- symlinks to self-managed deps' Package.swift + dirs, named by Swift module so SwiftPM + package identity stays unique + headers/ <-- generated header symlinks + ios/ <-- gitignored, codegen output + xcframeworks/ <-- gitignored, immutable runtime flavor slots + invariant package + debug/ + React.xcframework -> ~/Library/Caches/.../debug/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + release/ + React.xcframework -> ~/Library/Caches/.../release/React.xcframework + ReactNativeDependencies.xcframework -> ... + hermes-engine.xcframework -> ... + ReactHeadersTarget/ <-- canonical Objective-C React headers + module map + ReactNativeHeaders.xcframework -> ... + ReactNativeDependenciesHeaders.xcframework -> ... + flavored-frameworks.json + .artifact-stamp +``` + +### Header Resolution + +React Native uses CocoaPods-style imports (`#import `) that +SwiftPM doesn't natively support. The prebuilt artifacts serve them through +SwiftPM package products — no `-I` search-path flags, and no clang VFS overlay: + +1. **`` and `import React`** resolve through the invariant + **`ReactHeaders` Clang target**. It stages one canonical header copy after + proving Debug and Release expose identical public headers, and uses a plain + `module React` module map with `React/`-prefixed paths. +2. **Lowercase C++ `react/` and every other RN namespace** (`yoga/`, `jsi/`, + `jsinspector-modern`, …) comes from **`ReactNativeHeaders.xcframework`**, a + headers-only (LIBRARY-type) binaryTarget whose per-slice `Headers/` SwiftPM + auto-serves to dependents. +3. **Third-party dependency namespaces** (`folly/`, `glog/`, `boost/`, `fmt/`, + `double-conversion/`, `fast_float/`, `SocketRocket/`) come from + **`ReactNativeDependenciesHeaders.xcframework`**, the deps headers-only + sidecar (same mechanism — the binary `ReactNativeDependencies.xcframework` is + framework-type and can't expose those headers to SwiftPM). + +Targets that compile against React take these as product dependencies +(`ReactHeaders`, `ReactNativeHeaders`, `ReactNativeDependenciesHeaders`, plus +the app's `ReactAppHeaders`), so all of the above resolve with zero search-path +flags. + +### Auto-Sync + +Autolinking is kept up to date without manual re-runs of `react-native spm` by +**two hooks running the same sync script**, injected by `add`/`update`: + +| Hook | Where | Role | +| ---------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Scheme pre-action | The app's **shared** scheme (`xcshareddata/xcschemes/`), under `BuildAction` → `PreActions` | Fires earlier in the build than a build phase can, so it is the one that normally does the re-sync. | +| `Sync SPM Autolinking` build phase | `.xcodeproj`, prepended before `Sources` | **Safety net** for builds that bypass the scheme (and for a scheme whose pre-action was stripped). | + +Neither hook can bootstrap a clean checkout. Xcode resolves the Swift package +graph before build phases **and** before scheme pre-actions, so if the generated +packages are missing entirely, resolution fails and the build stops before +either hook runs — see [Fresh clones & CI](#fresh-clones--ci). The hooks keep an +_existing_ set of generated packages current; they do not create the first one. + +**How the sync script works:** + +1. Compares timestamps of staleness inputs against + `build/generated/autolinking/.spm-sync-stamp`: + - `package.json` — dependency declarations + - `react-native.config.js` — `spm.modules` config + - `node_modules/` directory mtime — updated by any package manager (npm, + yarn, pnpm, bun); also checks parent `node_modules` for monorepo setups + - a missing `build/xcframeworks/` (e.g. after a manual clean) also marks + stale + - every path in `.spm-sync-watch-paths` — RN's own inputs plus any + [plugin](./spm-autolinking-plugins.md#watchpaths--plugin-staleness-inputs) + `watchPaths`; a watched file that is newer, a watched dir with a newer + child, or a watched path that has **vanished** all mark stale +2. If any input is newer (or the stamp is missing): runs + `npx react-native spm sync`, which re-executes autolinking + package + generation (downloading artifacts if the cache slot is incomplete) and writes + the stamp file. +3. If all inputs are fresh: exits immediately (~1ms). + +**Ordering.** As observed in an `xcodebuild -scheme … build` log on Xcode 26.6: + +| Step | Owner | +| ----------------------------------------------- | ----------------- | +| Resolve Package Graph | Xcode | +| **Sync SPM Autolinking** | scheme pre-action | +| Prepare packages / ComputeTargetDependencyGraph | Xcode | +| CreateBuildDescription | Xcode | +| **Sync SPM Autolinking** (safety net) | build phase 1 | +| Sources (compile) | build phase 2 | +| Frameworks (link) | build phase 3 | +| Embed React Native Flavored Frameworks | build phase 4 | +| Resources (copy) | build phase 5 | +| Build JS Bundle | build phase 6 | + +Resolution coming first is what makes the one-time setup run necessary on a +clean checkout; it is not something either hook can work around. + +A sync failure is lenient by default but **not unconditionally**. The generated +script branches on the exit code: + +- **Exit 2** — an autolinked dependency ships no `Package.swift`. This **fails + the build** (`exit 1`), deliberately: the autolinker has already printed an + `error:` line per dep, and the fix needs a terminal (see + [Community packages without a Package.swift](#community-packages-without-a-packageswift)). +- **Any other non-zero exit** — emits + `warning: SPM sync failed — build may use stale codegen/autolinking` and lets + the build continue, so an already-generated package graph can still produce a + successful build. + +That split is the whole reason the missing-manifest case has its own exit code: +a transient sync hiccup should not break a build that could still succeed, while +a genuinely missing manifest should not pass silently. diff --git a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js index f45cc5b04a81..5671fbeb7c1e 100644 --- a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js +++ b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js @@ -340,4 +340,204 @@ describe('invokePlugins', () => { expect(res.watchPaths).toEqual([]); expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true); }); + + it('merges scriptPhases across plugins, preserving optional fields', () => { + const res = invokePlugins( + [ + mk('expo-constants', () => ({ + scriptPhases: [ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + ], + })), + mk('b', () => ({ + scriptPhases: [{id: 'b.stamp', name: 'Stamp', script: 'echo hi'}], + })), + ], + ctx, + ); + expect(res.scriptPhases).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + // A package's own npm name is the obvious stable id, and the scoped form is + // the common one (`@expo/log-box` is a named consumer). + it.each([['@expo/log-box'], ['@expo/ui']])( + 'accepts the scoped npm name %s as a scriptPhase id', + id => { + const res = invokePlugins( + [mk('expo', () => ({scriptPhases: [{id, name: 'X', script: 'echo'}]}))], + ctx, + ); + expect(res.scriptPhases).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it('defaults scriptPhases to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.scriptPhases).toEqual([]); + }); + + it('rejects a non-array scriptPhases declaration', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({scriptPhases: {id: 'x', name: 'X', script: 'y'}}))], + ctx, + ), + ).toThrow(/non-array scriptPhases/); + }); + + it.each([ + ['missing id', {name: 'X', script: 'echo'}], + ['empty id', {id: '', name: 'X', script: 'echo'}], + ['non-string id', {id: 7, name: 'X', script: 'echo'}], + ['an id containing a space', {id: 'a b', name: 'X', script: 'echo'}], + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id containing a colon', {id: 'expo:phase', name: 'X', script: 'echo'}], + ['missing name', {id: 'a', script: 'echo'}], + ['empty name', {id: 'a', name: '', script: 'echo'}], + ['missing script', {id: 'a', name: 'X'}], + ['empty script', {id: 'a', name: 'X', script: ''}], + [ + 'unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + [ + 'non-array inputPaths', + {id: 'a', name: 'X', script: 'echo', inputPaths: '/in'}, + ], + [ + 'non-string inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: [7]}, + ], + [ + 'empty inputPaths entry', + {id: 'a', name: 'X', script: 'echo', inputPaths: ['']}, + ], + [ + 'non-array outputPaths', + {id: 'a', name: 'X', script: 'echo', outputPaths: '/out'}, + ], + [ + 'non-string outputPaths entry', + {id: 'a', name: 'X', script: 'echo', outputPaths: [null]}, + ], + [ + 'non-boolean alwaysOutOfDate', + {id: 'a', name: 'X', script: 'echo', alwaysOutOfDate: 'yes'}, + ], + ['null entry', null], + ['a number instead of an entry', 42], + ['a string instead of an entry', 'echo'], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + ])('rejects a scriptPhase with %s', (_label, entry) => { + expect(() => + invokePlugins([mk('expo', () => ({scriptPhases: [entry]}))], ctx), + ).toThrow(/invalid scriptPhase/); + }); + + it('names the offending id in the invalid-scriptPhase error', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'ok.phase', name: 'Fine', script: 'echo'}, + {id: 'bad:phase', name: 'Bad', script: 'echo'}, + ], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase 'bad:phase'/); + }); + + it('rejects a duplicate scriptPhase id within a single plugin', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [ + {id: 'dup.phase', name: 'One', script: 'echo one'}, + {id: 'dup.phase', name: 'Two', script: 'echo two'}, + ], + })), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + it('rejects a duplicate scriptPhase id across plugins, naming it', () => { + const phase = () => ({id: 'dup.phase', name: 'Dup', script: 'echo'}); + expect(() => + invokePlugins( + [ + mk('a', () => ({scriptPhases: [phase()]})), + mk('b', () => ({scriptPhases: [phase()]})), + ], + ctx, + ), + ).toThrow(/duplicate script phase id 'dup\.phase'/); + }); + + // A line break is the one thing an Xcode phase display name can never carry. + // Every other hostile character is safe by construction: the injector + // normalizes the name for the `/* … */` comments and escapes it in the `name` + // field, so nothing structural reaches the project text. + it.each([ + ['a newline', 'Line one\nLine two'], + ['a carriage return', 'Line one\rLine two'], + ])('rejects a scriptPhase name containing %s', (_label, name) => { + expect(() => + invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ), + ).toThrow(/invalid scriptPhase name for 'expo\.phase'/); + }); + + it.each([ + ['pbxproj quoting', 'Bundle "app.config"'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* opener'], + ])('keeps a scriptPhase name needing %s verbatim', (_label, name) => { + const res = invokePlugins( + [ + mk('expo', () => ({ + scriptPhases: [{id: 'expo.phase', name, script: 'echo'}], + })), + ], + ctx, + ); + expect(res.scriptPhases[0].name).toBe(name); + }); }); diff --git a/packages/react-native/scripts/spm/__tests__/cli-config-command-test.js b/packages/react-native/scripts/spm/__tests__/cli-config-command-test.js new file mode 100644 index 000000000000..e2300cd3fe5e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/cli-config-command-test.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +jest.mock('../../setup-apple-spm', () => ({main: jest.fn()})); + +test('the React Native CLI forwards the documented configCommand without splitting its argv', async () => { + const {commands} = require('../../../react-native.config'); + const {main} = require('../../setup-apple-spm'); + const command = commands.find(candidate => candidate.name === 'spm [action]'); + expect(command.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({name: '--configCommand '}), + ]), + ); + const argv = JSON.stringify([ + 'node', + '/path with spaces/config.js', + '--json', + ]); + await command.func(['update'], {}, {configCommand: argv}); + expect(main).toHaveBeenCalledWith(['update', '--config-command', argv]); +}); diff --git a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js index 49e91fdd6404..1e4848930ca1 100644 --- a/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -34,16 +34,29 @@ */ const { + SpmNameCollisionError, + defaultReadConfig, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, } = require('../expand-spm-dependencies'); -const {toSwiftName} = require('../spm-utils'); +const { + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, + toSwiftName, +} = require('../spm-utils'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); function makeReadConfig(configs /*: {[string]: ?Object} */) { return (root /*: string */) => Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; } +// The reserved map its caller builds; these cases only exercise `spm.name`. +const NONE = new Map(); + function makeResolveDep(resolutions /*: {[string]: ?string} */) { return (name /*: string */) => Object.prototype.hasOwnProperty.call(resolutions, name) @@ -326,6 +339,22 @@ describe('expandSpmDependencies', () => { ).toThrow(/ReactNativeWorklets/); }); + it('throws SpmNameCollisionError on a dep-vs-dep collision too', () => { + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-package', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {}, + '/o': {spm: {name: 'ReactNativeWorklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(SpmNameCollisionError); + }); + it('throws on a CASE-INSENSITIVE swiftName collision (worklets vs Worklets)', () => { // Distinct as exact strings, but collide as directories on the default // case-insensitive macOS filesystem. @@ -365,29 +394,566 @@ describe('expandSpmDependencies', () => { }); it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { - expect(() => resolveSwiftName('a', {spm: {name: 'foo bar'}})).toThrow( - /invalid 'spm.name'/, + const resolve = name => () => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('foo bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo/bar')).toThrow(/invalid 'spm.name'/); + expect(resolve('foo.bar')).toThrow(/invalid 'spm.name'/); + }); + + it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + const resolve = name => resolveSwiftName('a', {spm: {name}}, NONE); + expect(resolve('reanimated')).toBe('reanimated'); + expect(resolve('hermes-engine')).toBe('hermes-engine'); + expect(resolve('RNWorklets')).toBe('RNWorklets'); + expect(resolve('react_native_foo')).toBe('react_native_foo'); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation: a derived name that lands on one React Native reserves. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('prepends the scope when the derived name is reserved', () => { + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + ); + expect(dep.swiftName).toBe('PowersyncReactNative'); + }); + + it('logs one line naming the package, the reserved name and the name it got', () => { + const log = jest.fn(); + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {log}, ); - expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow( - /invalid 'spm.name'/, + expect(log).toHaveBeenCalledTimes(1); + const [line] = log.mock.calls[0]; + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + expect(line).toContain("'PowersyncReactNative'"); + }); + + it('says nothing when no disambiguation happens', () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/common', root: '/c', platforms: {ios: {}}}], + {'/c': {}}, + {log}, ); - expect(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow( - /invalid 'spm.name'/, + expect(dep.swiftName).toBe('Common'); + expect(log).not.toHaveBeenCalled(); + }); + + it('title-cases a hyphenated scope', () => { + const [dep] = expand( + [{name: '@my-org/react-native', root: '/o', platforms: {ios: {}}}], + {'/o': {}}, ); + expect(dep.swiftName).toBe('MyOrgReactNative'); }); - it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { - expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe( - 'reanimated', + it('disambiguates a name that matches a reserved one only in case', () => { + // toSwiftName('@scope/reactcodegen') === 'Reactcodegen' — distinct from + // 'ReactCodegen' as a string, the same directory on a case-insensitive + // filesystem. + const [dep] = expand( + [{name: '@scope/reactcodegen', root: '/s', platforms: {ios: {}}}], + {'/s': {}}, + ); + expect(dep.swiftName).toBe('ScopeReactcodegen'); + }); + + it('disambiguates a transitive dep too', () => { + const result = expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['@scope/react-native']}}, + '/s': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@scope/react-native': '/s'}), + }, + ); + expect(result.map(d => d.swiftName)).toEqual(['Top', 'ScopeReactNative']); + }); + + it('disambiguates against a caller-supplied reserved name (remote identity)', () => { + const [dep] = expand( + [{name: '@acme/my-fork', root: '/f', platforms: {ios: {}}}], + {'/f': {}}, + {extraReservedNames: ['MyFork']}, + ); + expect(dep.swiftName).toBe('AcmeMyFork'); + }); + + it("leaves an explicit 'spm.name' alone on a package that would have collided", () => { + const log = jest.fn(); + const [dep] = expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {spm: {name: 'PowerSync'}}}, + {log}, + ); + expect(dep.swiftName).toBe('PowerSync'); + expect(log).not.toHaveBeenCalled(); + }); + + it('throws when the disambiguated name is reserved as well', () => { + const run = () => + expand( + [{name: '@powersync/react-native', root: '/ps', platforms: {ios: {}}}], + {'/ps': {}}, + {extraReservedNames: ['PowersyncReactNative']}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('gives two scoped packages that would take the same reserved name distinct names', () => { + const result = expand( + [ + {name: '@a/react-native', root: '/a', platforms: {ios: {}}}, + {name: '@b/react-native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual([ + 'AReactNative', + 'BReactNative', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Scope disambiguation across deps: two libraries deriving one name. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (scope disambiguation across deps)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + const scoped = (name, root) => ({name, root, platforms: {ios: {}}}); + + it('pulls two scoped deps apart with their scopes', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); + + it('logs one line per rewritten dep, naming the shared name and the new one', () => { + const log = jest.fn(); + expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + { + '/a': {}, + '/b': {}, + }, + {log}, + ); + expect(log).toHaveBeenCalledTimes(2); + const lines = log.mock.calls.map(([line]) => line); + expect(lines[0]).toContain('@a/foo'); + expect(lines[0]).toContain("'Foo'"); + expect(lines[0]).toContain("'AFoo'"); + expect(lines[1]).toContain('@b/foo'); + expect(lines[1]).toContain("'BFoo'"); + }); + + it('leaves an unscoped member alone — it has no scope to borrow', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('foo', '/f')], { + '/a': {}, + '/f': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'Foo']); + }); + + it("leaves a member's explicit 'spm.name' alone and moves the others around it", () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], + {'/a': {spm: {name: 'Foo'}}, '/b': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'BFoo']); + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain('@b/foo'); + }); + + it('groups case-insensitively, so a lowercase override still moves the others', () => { + const result = expand([scoped('@a/foo', '/a'), scoped('@b/foo', '/b')], { + '/a': {spm: {name: 'foo'}}, + '/b': {}, + }); + expect(result.map(d => d.swiftName)).toEqual(['foo', 'BFoo']); + }); + + it('rewrites every scoped member of a three-way collision', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('@c/foo', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'CFoo']); + }); + + it('rewrites the scoped members of a three-way collision and keeps the unscoped one', () => { + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/foo', '/b'), scoped('foo', '/f')], + {'/a': {}, '/b': {}, '/f': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo', 'Foo']); + }); + + it('throws when a borrowed scope lands on a third package instead of producing two of the same name', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const run = () => + expand( + [ + scoped('@a/foo', '/a'), + scoped('@b/foo', '/b'), + scoped('a-foo', '/af'), + ], + {'/a': {}, '/b': {}, '/af': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/both resolve to 'AFoo'/); + }); + + it('throws when a borrowed scope lands on a name React Native reserves', () => { + // Both derive 'Native'; the borrow takes '@react/native' to 'ReactNative'. + const run = () => + expand([scoped('@react/native', '/r'), scoped('@other/native', '/o')], { + '/r': {}, + '/o': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow(/React Native reserves/); + }); + + it('still throws for two unscoped deps deriving the same name', () => { + const run = () => + expand( + [scoped('react-native-foo', '/a'), scoped('react_native_foo', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-native-foo' \('ReactNativeFoo'\) and 'react_native_foo' \('ReactNativeFoo'\) both resolve to 'ReactNativeFoo'\./, + ); + expect(run).toThrow(/Set a distinct 'spm\.name'/); + }); + + it('changes nothing, and says nothing, for a set with no collisions', () => { + const log = jest.fn(); + const result = expand( + [scoped('@a/foo', '/a'), scoped('@b/bar', '/b'), scoped('baz', '/c')], + {'/a': {}, '/b': {}, '/c': {}}, + {log}, + ); + expect(result.map(d => d.swiftName)).toEqual(['Foo', 'Bar', 'Baz']); + expect(log).not.toHaveBeenCalled(); + }); + + it('borrows a second time when an already-borrowed name collides, and the incumbent keeps its name', () => { + // Both land on 'AReactNative': one by borrowing, one by derivation. + const result = expand( + [scoped('@a/react-native', '/a'), scoped('a-react-native', '/b')], + {'/a': {}, '/b': {}}, + ); + expect(result.map(d => d.swiftName)).toEqual([ + 'AAReactNative', + 'AReactNative', + ]); + }); + + it('disambiguates a transitive dep against a direct one', () => { + const result = expandSpmDependencies([scoped('@a/foo', '/a')], { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['@b/foo']}}, + '/b': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'@b/foo': '/b'}), + }); + expect(result.map(d => d.swiftName)).toEqual(['AFoo', 'BFoo']); + }); +}); + +// --------------------------------------------------------------------------- +// Reserved React Native names — the backstop for what a scope cannot resolve. +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies (reserved React Native names)', () => { + function expand(direct, configs, options) { + return expandSpmDependencies(direct, { + readConfig: makeReadConfig(configs), + resolveDep: makeResolveDep({}), + ...options, + }); + } + + it('throws when an unscoped dep auto-derives a reserved product name', () => { + const run = () => + expand([{name: 'react-headers', root: '/rh', platforms: {ios: {}}}], { + '/rh': {}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'react-headers' resolves to 'ReactHeaders', which React Native reserves/, ); - expect(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe( - 'hermes-engine', + expect(run).toThrow( + /Set a different 'spm\.name' in react-headers's react-native\.config\.js\./, ); - expect(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe( - 'RNWorklets', + }); + + it('throws when an explicit spm.name override lands on a reserved name', () => { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'ReactAppHeaders'}}, + }), + ).toThrow( + /'some-lib' resolves to 'ReactAppHeaders', which React Native reserves/, ); - expect(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe( - 'react_native_foo', + }); + + it('throws when a transitive dep lands on a reserved name', () => { + expect(() => + expandSpmDependencies( + [{name: 'top', root: '/top', platforms: {ios: {}}}], + { + readConfig: makeReadConfig({ + '/top': {spm: {dependencies: ['react-native-headers']}}, + '/rnh': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'react-native-headers': '/rnh'}), + }, + ), + ).toThrow( + /'react-native-headers' resolves to 'ReactNativeHeaders', which React Native reserves/, + ); + }); + + it('reserves the caller-supplied extraReservedNames (remote package identity)', () => { + const direct = [{name: 'my-fork', root: '/f', platforms: {ios: {}}}]; + expect(() => + expand(direct, {'/f': {}}, {extraReservedNames: ['MyFork']}), + ).toThrow(/'my-fork' resolves to 'MyFork', which React Native reserves/); + }); + + it('accepts that same name when no extraReservedNames are supplied', () => { + const [dep] = expand( + [{name: 'my-fork', root: '/f', platforms: {ios: {}}}], + { + '/f': {}, + }, + ); + expect(dep.swiftName).toBe('MyFork'); + }); + + it('leaves a non-colliding dep untouched', () => { + const [dep] = expand( + [{name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}], + {'/w': {spm: {name: 'worklets'}}}, + {extraReservedNames: ['SomeRemoteIdentity']}, + ); + expect(dep.swiftName).toBe('worklets'); + }); + + it('reports the reserved-name diagnosis in preference to the dep-vs-dep one', () => { + // Both unscoped deps derive 'ReactNative', so neither can borrow a scope. + expect(() => + expand( + [ + {name: 'react-native', root: '/a', platforms: {ios: {}}}, + {name: 'react_native', root: '/b', platforms: {ios: {}}}, + ], + {'/a': {}, '/b': {}}, + ), + ).toThrow(/React Native reserves/); + }); + + it('rejects every name in RESERVED_SWIFT_NAMES', () => { + expect(RESERVED_SWIFT_NAMES.length).toBeGreaterThan(0); + for (const reserved of RESERVED_SWIFT_NAMES) { + expect(() => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: reserved}}, + }), + ).toThrow(/React Native reserves/); + } + }); + + it('rejects the autolinking aggregator package name', () => { + expect(() => + expand([{name: 'autolinked', root: '/a', platforms: {ios: {}}}], { + '/a': {}, + }), + ).toThrow( + /'autolinked' resolves to 'Autolinked', which React Native reserves/, + ); + }); + + it('accepts the React headers TARGET dir name — it is not a package or product, so nothing collides', () => { + const [dep] = expand( + [{name: 'some-lib', root: '/s', platforms: {ios: {}}}], + { + '/s': {spm: {name: REACT_HEADERS_TARGET_DIR}}, + }, + ); + expect(dep.swiftName).toBe(REACT_HEADERS_TARGET_DIR); + }); + + it('reports a case-only match against a reserved name, naming both spellings', () => { + const run = () => + expand([{name: 'some-lib', root: '/s', platforms: {ios: {}}}], { + '/s': {spm: {name: 'reactnative'}}, + }); + expect(run).toThrow(SpmNameCollisionError); + expect(run).toThrow( + /'some-lib' resolves to 'reactnative', which differs from React Native's reserved 'ReactNative' only in case/, + ); + expect(run).toThrow(/spm\.name/); + }); +}); + +// --------------------------------------------------------------------------- +// isValidSwiftName — the charset rule `spm.name` enforces. +// --------------------------------------------------------------------------- + +describe('isValidSwiftName', () => { + it.each(['worklets', 'ReactNativeFoo', 'hermes-engine', 'react_native_foo'])( + 'accepts %j', + name => { + expect(isValidSwiftName(name)).toBe(true); + }, + ); + + it.each(['', 'foo bar', 'foo/bar', 'foo.bar', '9lives', 42, null])( + 'rejects %j', + name => { + expect(isValidSwiftName(name)).toBe(false); + }, + ); +}); +// defaultReadConfig +// +// The community CLI's own loaders disagree — sync reads named exports, async +// reads the default one — so a config that sets only `export default` must not +// be invisible here. Fixtures are transpiled by babel, so they present the +// `__esModule`/`default` interop shape; a Node namespace object from +// `require(ESM)` has no `__esModule` but exposes `.default` alongside +// enumerable named keys the same way, which is what the merge reads. +// --------------------------------------------------------------------------- + +describe('defaultReadConfig', () => { + let tmpRoot; + + beforeAll(() => { + tmpRoot = fs.mkdtempSync( + path.join(fs.realpathSync(os.tmpdir()), 'spm-read-config-'), + ); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + }); + + function writeConfig(name, source) { + const root = path.join(tmpRoot, name); + fs.mkdirSync(root, {recursive: true}); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({name})); + fs.writeFileSync(path.join(root, 'react-native.config.js'), source); + return root; + } + + it('returns null when the library ships no config', () => { + const root = path.join(tmpRoot, 'no-config'); + fs.mkdirSync(root, {recursive: true}); + expect(defaultReadConfig(root)).toBeNull(); + }); + + it('reads a CommonJS config', () => { + const root = writeConfig('cjs', "module.exports = {spm: {name: 'Cjs'}};\n"); + expect(defaultReadConfig(root).spm.name).toBe('Cjs'); + }); + + it('unwraps an ESM config that only has a default export', () => { + const root = writeConfig( + 'esm-default', + "export default {spm: {name: 'EsmDefault'}};\n", + ); + expect(defaultReadConfig(root).spm.name).toBe('EsmDefault'); + }); + + it('reads an ESM config that only has named exports', () => { + const root = writeConfig( + 'esm-named', + "export const spm = {name: 'EsmNamed'};\n", + ); + expect(defaultReadConfig(root).spm.name).toBe('EsmNamed'); + }); + + it('prefers the named export when a config ships both (the PowerSync shape)', () => { + const root = writeConfig( + 'esm-both', + "export const spm = {name: 'Named'};\n" + + "export default {spm: {name: 'Default'}, dependency: {platforms: {ios: {}}}};\n", + ); + const config = defaultReadConfig(root); + expect(config.spm.name).toBe('Named'); + // Only the merge satisfies this: `dependency` exists on the default export + // alone, so reading the module raw would miss it. + expect(config.dependency.platforms.ios).toEqual({}); + }); + + it('keeps sibling keys of the default export (dependency.platforms.ios)', () => { + const root = writeConfig( + 'esm-siblings', + "export default {dependency: {platforms: {ios: {}}}, spm: {name: 'Siblings'}};\n", + ); + const config = defaultReadConfig(root); + expect(config.dependency.platforms.ios).toEqual({}); + expect(config.spm.name).toBe('Siblings'); + }); + + it('passes a function-style config through unchanged (module.exports = () => ({...}))', () => { + const root = writeConfig( + 'fn-style', + "module.exports = () => ({spm: {name: 'FnStyle'}});\n", + ); + const config = defaultReadConfig(root); + expect(typeof config).toBe('function'); + expect(config().spm.name).toBe('FnStyle'); + }); + + it('warns with the config path and the reason when the config fails to load, and returns null', () => { + const root = writeConfig( + 'broken', + "require('a-dev-dependency-that-is-not-installed');\n", ); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(defaultReadConfig(root)).toBeNull(); + const message = warnSpy.mock.calls.map(call => call.join(' ')).join('\n'); + expect(message).toContain(path.join(root, 'react-native.config.js')); + expect(message).toContain('a-dev-dependency-that-is-not-installed'); + } finally { + warnSpy.mockRestore(); + } }); }); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index e8e790c9335d..bf3ba5ef9b49 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -10,9 +10,11 @@ 'use strict'; +const {SpmNameCollisionError} = require('../expand-spm-dependencies'); const { AUTOGEN_MARKER, MissingManifestError, + autolinkingDepToSpmTarget, collectSpmSources, expandSpmSourceGlobs, findSelfManagedPackageDir, @@ -1087,7 +1089,7 @@ describe('main() — autolinking plugin host exemption', () => { // Builds a minimal app fixture whose ONLY autolinked iOS dep is `expo`, which // ships NO Package.swift. When `withPlugin` is set, expo declares an // autolinking plugin in its own react-native.config.js (transitive opt-in). - function buildFixture({withPlugin}) { + function buildFixture({withPlugin, depName = 'expo'}) { const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugin-host-')); created.push(appRoot); // rnRoot only needs to exist (main() existence-checks it, then passes it @@ -1100,7 +1102,7 @@ describe('main() — autolinking plugin host exemption', () => { JSON.stringify({name: 'app'}), ); // The plugin-host dep: native sources present, but NO Package.swift. - const expoDir = path.join(appRoot, 'node_modules', 'expo'); + const expoDir = path.join(appRoot, 'node_modules', ...depName.split('/')); fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); fs.writeFileSync( path.join(expoDir, 'ios', 'Expo.mm'), @@ -1126,7 +1128,9 @@ describe('main() — autolinking plugin host exemption', () => { fs.writeFileSync( path.join(autolinkDir, 'autolinking.json'), JSON.stringify({ - dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + dependencies: { + [depName]: {root: expoDir, platforms: {ios: {}}}, + }, }), ); return {appRoot, rnRoot}; @@ -1159,6 +1163,212 @@ describe('main() — autolinking plugin host exemption', () => { main(['--app-root', appRoot, '--react-native-root', rnRoot]), ).toThrow(MissingManifestError); }); + + // Adds a dep that declares the plugin host in its own `spm.dependencies`. + // `selfManaged` gives it a Package.swift of its own — which is what decides + // whether React Native emits its package references or the dep does. + function addDependentOfExpo(appRoot, name, {selfManaged = false} = {}) { + const depDir = path.join(appRoot, 'node_modules', name); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'Dep.mm'), '// native source\n'); + if (selfManaged) { + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version: 6.0\n', + ); + } + fs.writeFileSync( + path.join(depDir, 'react-native.config.js'), + 'module.exports = {dependency: {platforms: {ios: {}}}, ' + + "spm: {dependencies: ['expo']}};\n", + ); + const jsonPath = path.join( + appRoot, + 'build/generated/autolinking/autolinking.json', + ); + const json = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + json.dependencies[name] = {root: depDir, platforms: {ios: {}}}; + fs.writeFileSync(jsonPath, JSON.stringify(json)); + } + + it('fails when a dep whose manifest RN generates declares the plugin host in its spm.dependencies', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + addDependentOfExpo(appRoot, 'react-native-y'); + const run = () => + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + // Both packages, so the reader knows which config to edit and why. + expect(run).toThrow(/'react-native-y'/); + expect(run).toThrow(/'expo'/); + expect(run).toThrow(/autolinking plugin/); + expect(run).toThrow(/spm\.dependencies/); + }); + + it('leaves a self-managed dependent alone — its own Package.swift declares its package references, so RN emits none', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + addDependentOfExpo(appRoot, 'react-native-y', {selfManaged: true}); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).not.toThrow(); + }); + + it('leaves the same pair alone when the host ships no plugin — a plain spm.dependency is not a plugin host', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: false}); + addDependentOfExpo(appRoot, 'react-native-y'); + // Both deps ship no manifest, so the missing-manifest error is the expected + // one — the plugin-host diagnosis must not fire for a plain dependency. + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(MissingManifestError); + }); + // `spm scaffold` has no plugin knowledge, so exempting the autolinker alone + // left the two commands disagreeing about the same dep. + it.each([[true], [false]])( + 'rejects a dep deriving a reserved name whether or not it ships a plugin (withPlugin=%s)', + withPlugin => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope. + const {appRoot, rnRoot} = buildFixture({ + withPlugin, + depName: 'react-headers', + }); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(/'react-headers'.*React Native reserves/s); + }, + ); +}); + +// --------------------------------------------------------------------------- +// main() — spm.modules name validation +// +// App-local module names land in the manifest exactly as written, so they need +// the checks an autolinked dep's Swift name gets: a valid identifier, not one +// of React Native's reserved names, and unique across modules and deps. +// --------------------------------------------------------------------------- + +describe('main() — spm.modules names', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // App fixture whose react-native.config.js declares `spm.modules`, plus an + // optional autolinked dep (for the module-vs-dep collision case). + function buildApp({modules, dep}) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-modules-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + for (const mod of modules) { + const modDir = path.join(appRoot, mod.path); + fs.mkdirSync(modDir, {recursive: true}); + fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n'); + } + fs.writeFileSync( + path.join(appRoot, 'react-native.config.js'), + `module.exports = ${JSON.stringify({spm: {modules}})};\n`, + ); + const dependencies = {}; + if (dep != null) { + const depDir = path.join(appRoot, 'node_modules', dep.name); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'ios', 'Dep.mm'), + '// native source\n', + ); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version: 6.0\n', + ); + dependencies[dep.name] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + const run = ({appRoot, rnRoot}) => + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + it('accepts a normal module name', () => { + const app = buildApp({ + modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).not.toThrow(); + }); + + it('rejects a module named after a reserved React Native name', () => { + const app = buildApp({ + modules: [{name: 'ReactNative', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/, + ); + expect(() => run(app)).toThrow(/'spm\.modules'\.$/); + }); + + it('rejects a reserved product name in any casing', () => { + const app = buildApp({ + modules: [{name: 'reactheaders', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/, + ); + }); + + it('rejects a module name that is not a valid Swift identifier', () => { + const app = buildApp({ + modules: [{name: 'My Module', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/); + }); + + it('rejects two modules resolving to the same name', () => { + const app = buildApp({ + modules: [ + {name: 'Shared', path: 'ios/one'}, + {name: 'shared', path: 'ios/two'}, + ], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/, + ); + }); + + it('rejects a module colliding with an autolinked dep', () => { + const app = buildApp({ + modules: [{name: 'ReactNativeFoo', path: 'ios/MyNativeModule'}], + dep: {name: 'react-native-foo'}, + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/, + ); + }); }); // --------------------------------------------------------------------------- @@ -1296,6 +1506,125 @@ describe('main() — flavoredFrameworks sidecar', () => { }); }); +// --------------------------------------------------------------------------- +// main() — plugin scriptPhases sidecar +// +// `.spm-plugin-script-phases.json` records the phases a plugin wants injected +// into the app target (SwiftPM has no `script_phase`). Like the other sidecars +// it is ALWAYS rewritten — `[]` when no plugin declares any — so removing a +// plugin clears stale entries. +// --------------------------------------------------------------------------- + +describe('main() — scriptPhases sidecar', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + const sidecarPath = appRoot => + path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', + ); + + // An app whose only autolinked dep is `expo`; when `pluginReturn` is given, + // expo declares a plugin returning that literal. + function scaffold(pluginReturn) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scriptphase-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + if (pluginReturn != null) { + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); + fs.writeFileSync( + path.join(expoDir, 'react-native.config.js'), + "module.exports = { spm: { autolinkingPlugin: './spm-plugin.js' } };\n", + ); + fs.writeFileSync( + path.join(expoDir, 'spm-plugin.js'), + `module.exports = function () { return ${pluginReturn}; };\n`, + ); + } + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: + pluginReturn != null + ? {expo: {root: expoDir, platforms: {ios: {}}}} + : {}, + }), + ); + return {appRoot, rnRoot}; + } + + it('records plugin-declared script phases, normalized', () => { + const {appRoot, rnRoot} = scaffold(`{ + scriptPhases: [{ + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + }], + }`); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + }, + { + id: 'expo-constants.stamp', + name: 'Stamp', + script: 'echo stamped', + position: 'end', + }, + ]); + }); + + it('writes [] when no plugin declares any (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold(null); + fs.writeFileSync( + sidecarPath(appRoot), + JSON.stringify([{id: 'stale', name: 'Stale', script: 'echo'}]), + ); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual( + [], + ); + }); +}); + // --------------------------------------------------------------------------- // main() — .spm-sync-watch-paths emission (mixed dirs + files) // @@ -1418,3 +1747,219 @@ describe('main() — .spm-sync-watch-paths emission', () => { expect([...lines].sort()).toEqual(lines); }); }); + +// --------------------------------------------------------------------------- +// main() — scope disambiguation: the borrowed name reaching a real manifest. +// --------------------------------------------------------------------------- + +describe('main() — scope disambiguation', () => { + let created = []; + let spies = []; + let logSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + spies.push(logSpy); + for (const m of ['warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // Each dep ships a Package.swift, so it reaches the aggregator as self-managed. + function buildFixture(...depNames) { + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scope-disambig-')), + ); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + const dependencies = {}; + for (const depName of depNames) { + const depDir = path.join(appRoot, 'node_modules', ...depName.split('/')); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version:6.0\n// hand-authored\n', + ); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.h'), '// header\n'); + fs.writeFileSync(path.join(depDir, 'ios', 'Lib.mm'), '// src\n'); + dependencies[depName] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + it('emits the disambiguated name as the package ref, the product ref and the header slice', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + expect(pkg).toContain( + '.package(name: "PowersyncReactNative", path: "libs/PowersyncReactNative")', + ); + expect(pkg).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + // Nothing is referenced under the name the derivation would have taken. + expect(pkg).not.toContain('"ReactNative", path: "libs/'); + expect(pkg).not.toContain('package: "ReactNative"'); + + // So `#import ` resolves for consumers. + expect( + fs.existsSync( + path.join(outDir, 'headers/PowersyncReactNative/ios/Lib.h'), + ), + ).toBe(true); + expect(fs.existsSync(path.join(outDir, 'libs/PowersyncReactNative'))).toBe( + true, + ); + expect(fs.existsSync(path.join(outDir, 'headers/ReactNative'))).toBe(false); + }); + + it('tells the developer which name it took and why', () => { + const {appRoot, rnRoot} = buildFixture('@powersync/react-native'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const line = logSpy.mock.calls + .map(call => call.join(' ')) + .find(l => l.includes('PowersyncReactNative')); + expect(line).toBeDefined(); + expect(line).toContain('@powersync/react-native'); + expect(line).toContain("'ReactNative'"); + }); + + it('still rejects an unscoped dep deriving a reserved name — it has no scope to borrow', () => { + const {appRoot, rnRoot} = buildFixture('react-headers'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); + + it('emits both names of a dep-vs-dep collision as package refs, product refs and header slices', () => { + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo'); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const outDir = path.join(appRoot, 'build/generated/autolinking'); + const pkg = fs.readFileSync(path.join(outDir, 'Package.swift'), 'utf8'); + for (const name of ['AFoo', 'BFoo']) { + expect(pkg).toContain(`.package(name: "${name}", path: "libs/${name}")`); + expect(pkg).toContain(`.product(name: "${name}", package: "${name}")`); + expect( + fs.existsSync(path.join(outDir, `headers/${name}/ios/Lib.h`)), + ).toBe(true); + } + expect(pkg).not.toContain('"Foo", path: "libs/'); + expect(pkg).not.toContain('package: "Foo"'); + expect(fs.existsSync(path.join(outDir, 'headers/Foo'))).toBe(false); + }); + + it('still rejects a collision the scopes cannot resolve', () => { + // 'a-foo' already derives 'AFoo', the name '@a/foo' borrows. + const {appRoot, rnRoot} = buildFixture('@a/foo', '@b/foo', 'a-foo'); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(SpmNameCollisionError); + }); +}); + +// --------------------------------------------------------------------------- +// autolinkingDepToSpmTarget — resolved names only, never re-derived ones. +// --------------------------------------------------------------------------- + +describe('autolinkingDepToSpmTarget', () => { + const dep = (extra = {}) => ({ + name: '@powersync/react-native', + root: '/dep', + platforms: {ios: {sourceDir: '/dep/ios'}}, + ...extra, + }); + + it('carries a resolved sibling name into the emitted sibling refs', () => { + const target = autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map([['@powersync/react-native', 'PowersyncReactNative']]), + ); + const manifest = generateSynthPackageSwift({ + swiftName: target.name, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep: false, + targetPath: '.', + }); + expect(manifest).toContain( + '.package(name: "PowersyncReactNative", path: "../PowersyncReactNative")', + ); + expect(manifest).toContain( + '.product(name: "PowersyncReactNative", package: "PowersyncReactNative")', + ); + expect(manifest).not.toContain('ReactNative", package: "ReactNative"'); + }); + + it('fails loudly instead of re-deriving a dep with no resolved name', () => { + expect(() => + autolinkingDepToSpmTarget( + '@powersync/react-native', + dep(), + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); + + it('fails loudly instead of re-deriving an unmapped spm.dependency', () => { + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/@powersync\/react-native/); + expect(() => + autolinkingDepToSpmTarget( + 'react-native-consumer', + { + name: 'react-native-consumer', + root: '/consumer', + platforms: {ios: {sourceDir: '/consumer/ios'}}, + swiftName: 'ReactNativeConsumer', + spmDependencies: ['@powersync/react-native'], + }, + '/out', + new Map(), + ), + ).toThrow(/expandSpmDependencies/); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js index 0d96db1a5a7f..29ed28e7e843 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js @@ -23,6 +23,59 @@ const path = require('path'); // generateXCFrameworksPackageSwift // --------------------------------------------------------------------------- +// Adding a product must be one edit to REACT_NATIVE_PRODUCTS (plus the static +// codegen template): the manifest's products AND targets both derive from it. +// ReactHeaders is the Clang umbrella target; every other product is served by +// an xcframework of the same name. +describe('generateXCFrameworksPackageSwift derives from the shared name constants', () => { + // jest.doMock registers in the module registry beyond the isolateModules + // scope, so the mocked constants must be dropped before the next test. + afterEach(() => { + jest.dontMock('../spm-utils'); + jest.resetModules(); + }); + + it('emits a product and a binary target for a newly reserved product', () => { + jest.isolateModules(() => { + // Declared by KIND, the way a real edit adds one — spm-utils derives the + // flat list from the kind lists, so the mock mirrors that derivation. + jest.doMock('../spm-utils', () => { + const actual = jest.requireActual('../spm-utils'); + const xcframeworkProducts = Object.freeze([ + ...actual.REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + 'ReactBrandNewHeaders', + ]); + return { + ...actual, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS: xcframeworkProducts, + REACT_NATIVE_PRODUCTS: Object.freeze([ + actual.REACT_NATIVE_UMBRELLA_PRODUCT, + ...xcframeworkProducts, + ]), + }; + }); + const { + generateXCFrameworksPackageSwift: generate, + } = require('../generate-spm-package'); + const out = generate(); + expect(out).toContain( + '.library(name: "ReactBrandNewHeaders", targets: ["ReactBrandNewHeaders"])', + ); + expect(out).toContain('path: "ReactBrandNewHeaders.xcframework"'); + }); + }); + + it('emits one library product per REACT_NATIVE_PRODUCTS entry, in order', () => { + const {REACT_NATIVE_PRODUCTS} = require('../spm-utils'); + const libraries = [ + ...generateXCFrameworksPackageSwift().matchAll( + /\.library\(name: "([^"]+)"/g, + ), + ].map(m => m[1]); + expect(libraries).toEqual([...REACT_NATIVE_PRODUCTS]); + }); +}); + describe('generateXCFrameworksPackageSwift', () => { it('exposes only invariant compile-time products', () => { const result = generateXCFrameworksPackageSwift(); diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js index 459804f421f5..93a443a5b00f 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -18,6 +18,7 @@ const { flavorForBuildConfiguration, frameworkConditionalSettings, generateXcscheme, + readScriptPhasesManifest, } = require('../generate-spm-xcodeproj'); const {execFileSync} = require('child_process'); const fs = require('fs'); @@ -60,6 +61,55 @@ const FRAMEWORK = { ], }; +// The pbxproj product references must follow the shared name constants: a +// product added there has to reach the app target, or the app links against a +// package product Xcode never references. +describe('SPM product references derive from the shared name constants', () => { + // jest.doMock registers in the module registry beyond the isolateModules + // scope, so the mocked constants must be dropped before the next test. + afterEach(() => { + jest.dontMock('../spm-utils'); + jest.resetModules(); + }); + + it('includes a newly reserved React Native product', () => { + jest.isolateModules(() => { + jest.doMock('../spm-utils', () => { + const actual = jest.requireActual('../spm-utils'); + return { + ...actual, + REACT_NATIVE_PRODUCTS: Object.freeze([ + ...actual.REACT_NATIVE_PRODUCTS, + 'ReactBrandNewHeaders', + ]), + }; + }); + const {buildSpmDependencyGraph} = require('../generate-spm-xcodeproj'); + const graph = buildSpmDependencyGraph( + (section, id) => `${section}:${id}`, + ); + expect(graph.products.map(p => p.product)).toContain( + 'ReactBrandNewHeaders', + ); + }); + }); + + it('references every React Native, aggregator and codegen product exactly once', () => { + const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_NATIVE_PRODUCTS, + } = require('../spm-utils'); + const {buildSpmDependencyGraph} = require('../generate-spm-xcodeproj'); + const graph = buildSpmDependencyGraph((section, id) => `${section}:${id}`); + expect(graph.products.map(p => p.product)).toEqual([ + ...REACT_NATIVE_PRODUCTS, + AUTOLINKED_PACKAGE_NAME, + ...REACT_CODEGEN_APP_PRODUCTS, + ]); + }); +}); + describe('scheme pre-action', () => { it('contains the sync script and target-scoped build environment', () => { const result = generateXcscheme( @@ -226,3 +276,168 @@ describe('embed framework phase script', () => { expect(script).not.toContain('SourcePackages'); }); }); + +describe('readScriptPhasesManifest', () => { + let appRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-script-phases-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + const write = contents => { + const dir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, '.spm-plugin-script-phases.json'), + contents, + 'utf8', + ); + }; + + it('is [] when the manifest does not exist (first `spm add`)', () => { + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('is [] for unparseable JSON, warning about it', () => { + write('{not json'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + expect( + logSpy.mock.calls.some(([msg]) => + /could not parse .*\.spm-plugin-script-phases\.json/.test(msg), + ), + ).toBe(true); + }); + + it('is [] for a non-array payload', () => { + write('{"id": "x"}'); + expect(readScriptPhasesManifest(appRoot)).toEqual([]); + }); + + it('parses valid entries and defaults position to end', () => { + write( + JSON.stringify([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'expo-constants.generate-app-config', + name: 'Generate Expo App Config', + script: 'node ./write-app-config.js', + position: 'beforeCompile', + inputPaths: ['$(SRCROOT)/../app.config.js'], + outputPaths: ['$(DERIVED_FILE_DIR)/app.config'], + alwaysOutOfDate: true, + }, + {id: 'b.stamp', name: 'Stamp', script: 'echo hi', position: 'end'}, + ]); + }); + + it('skips malformed entries and duplicate ids, keeping the valid ones', () => { + write( + JSON.stringify([ + {name: 'No id', script: 'echo'}, + {id: 'a', name: 'A', script: 'echo one'}, + {id: 'a', name: 'A again', script: 'echo two'}, + null, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name: 'A', script: 'echo one', position: 'end'}, + ]); + }); + + // The same ids the plugin contract accepts: a scoped npm name is the natural + // stable key for a package-owned phase. + it.each([['@expo/log-box'], ['@expo/ui']])( + 'keeps the scoped npm name %s as an id', + id => { + write(JSON.stringify([{id, name: 'X', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id, name: 'X', script: 'echo', position: 'end'}, + ]); + }, + ); + + it.each([ + // `:` is excluded so the `plugin:` UUID seed stays unambiguous. + ['an id with a colon', {id: 'a:b', name: 'X', script: 'echo'}], + ['an id with a space', {id: 'a b', name: 'X', script: 'echo'}], + ['the reserved id __proto__', {id: '__proto__', name: 'X', script: 'echo'}], + [ + 'the reserved id constructor', + {id: 'constructor', name: 'X', script: 'echo'}, + ], + ['the reserved id prototype', {id: 'prototype', name: 'X', script: 'echo'}], + [ + 'an unknown position', + {id: 'a', name: 'X', script: 'echo', position: 'afterLink'}, + ], + // A line break is the one thing no Xcode phase name can carry. This reader + // is the only gate on a stale or hand-edited sidecar. + ['a name with a newline', {id: 'a', name: 'L1\nL2', script: 'echo'}], + [ + 'a name with a carriage return', + {id: 'a', name: 'L1\rL2', script: 'echo'}, + ], + ])('skips an entry with %s', (_label, entry) => { + write(JSON.stringify([entry, {id: 'keep', name: 'Keep', script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'keep', name: 'Keep', script: 'echo', position: 'end'}, + ]); + }); + + // The injector normalizes the name for the `/* … */` comments and escapes it + // in the `name` field, so a pbxproj-hostile name needs no coercion here. + it.each([ + ['needs pbxproj quoting', 'Bundle "app.config"'], + ['closes a comment', 'Bad */ = { x'], + ['opens a comment', 'Bad /* x'], + ])('keeps a name that %s', (_label, name) => { + write(JSON.stringify([{id: 'a', name, script: 'echo'}])); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + {id: 'a', name, script: 'echo', position: 'end'}, + ]); + }); + + it('drops non-string and empty input/output path entries', () => { + write( + JSON.stringify([ + { + id: 'a', + name: 'A', + script: 'echo', + inputPaths: ['$(SRCROOT)/in', '', 7, null, '$(SRCROOT)/in2'], + outputPaths: [{}, '$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', inputPaths: 'not-an-array'}, + ]), + ); + expect(readScriptPhasesManifest(appRoot)).toEqual([ + { + id: 'a', + name: 'A', + script: 'echo', + position: 'end', + inputPaths: ['$(SRCROOT)/in', '$(SRCROOT)/in2'], + outputPaths: ['$(DERIVED_FILE_DIR)/out'], + }, + {id: 'b', name: 'B', script: 'echo', position: 'end'}, + ]); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index 445b4c6ef31a..f7ca39363552 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -11,9 +11,11 @@ 'use strict'; const { + buildPhaseOrder, injectSpmIntoPbxproj, planInjection, } = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); const fs = require('fs'); const path = require('path'); @@ -29,6 +31,41 @@ const PODS = PLAIN.replace( 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {', ); +// The app target's two XCBuildConfiguration UUIDs in the fixture. +const APP_DEBUG_CONFIG = 'AA0000000000000000000901'; +const APP_RELEASE_CONFIG = 'AA00000000000000000000A2'; + +const DEBUG_CONFIG_HEAD = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + +// Seed the app target's Debug config with a SWIFT_ACTIVE_COMPILATION_CONDITIONS +// the user already had, in the scalar form Xcode and the app template write. +function withDebugCondition(text, value) { + return text.replace( + DEBUG_CONFIG_HEAD, + `${DEBUG_CONFIG_HEAD}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = ${value};`, + ); +} + +// One XCBuildConfiguration's buildSettings dict, by config UUID. Build settings +// hold only scalars and `( … )` arrays, so the first `};` closes the dict. +function buildSettingsOf(text, configUuid) { + const open = text.indexOf( + 'buildSettings = {', + text.indexOf(`${configUuid} /*`), + ); + return text.slice(open, text.indexOf('};', open)); +} +// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS, +// set to any valid pbxproj value: a plain scalar (which injection promotes to an +// array) or an array injection appends to. +function withHeaderSearchPaths(value) { + return PLAIN.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + `HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + ); +} + const RN_PATH = '../node_modules/react-native'; // Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through @@ -69,6 +106,7 @@ function inject( remote = null, hermesCliPath = TEST_HERMES_CLI_PATH, generatedSources = [], + scriptPhases = [], ) { const plan = planInjection(text, {}); expect(plan.ok).toBe(true); @@ -86,9 +124,28 @@ function inject( hermesCliPath, generatedSources, TEST_FRAMEWORKS, + scriptPhases, ); } +// The app target's buildPhases members, in order, by trailing comment. +function buildPhaseComments(text) { + const bp = text.slice(text.indexOf('buildPhases = (')); + const arr = bp.slice(0, bp.indexOf(');')); + return [...arr.matchAll(/\/\* ([^*]+) \*\//g)].map(m => m[1]); +} + +// Move the "Sync SPM Autolinking" membership line below the Sources one — what a +// user dragging the phase down in Xcode produces. RN never re-seats its own sync +// phase, so the move sticks. +function dragSyncBelowSources(text) { + const memberLine = comment => + new RegExp(`\\n[\\t ]*[0-9A-Fa-f]{24} /\\* ${comment} \\*/,`).exec(text)[0]; + const sync = memberLine('Sync SPM Autolinking'); + const sources = memberLine('Sources'); + return text.replace(sync, '').replace(sources, sources + sync); +} + // A normalized generated source under the app root (the Expo case: // build/generated/autolinking/expo/ExpoModulesProvider.swift). `path` is // SRCROOT-relative, so `sourceTree = SOURCE_ROOT`. @@ -99,26 +156,6 @@ const PROVIDER_SOURCE = { fileType: 'sourcecode.swift', }; -// A simple balanced-delimiter check (the injected file must stay well-formed). -function isBalanced(text) { - let depth = 0; - for (let i = 0; i < text.length; i++) { - const c = text[i]; - if (c === '"') { - i++; - while (i < text.length && text[i] !== '"') { - if (text[i] === '\\') i++; - i++; - } - } else if (c === '{' || c === '(') { - depth++; - } else if (c === '}' || c === ')') { - depth--; - } - } - return depth === 0; -} - describe('planInjection', () => { it('accepts a plain SPM-only app and resolves its anchors', () => { const plan = planInjection(PLAIN, {}); @@ -216,6 +253,40 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(text).not.toContain('HERMES_CLI_PATH'); }); + // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the + // Metro URL — is gated by this setting alone. CocoaPods injects it at `pod + // install`; an SPM app has to get it here or a Debug build looks for a + // main.jsbundle it never built. + it('sets SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG on the debug config only', () => { + const {text} = inject(PLAIN); + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toMatch( + /SWIFT_ACTIVE_COMPILATION_CONDITIONS = \(\s*"\$\(inherited\)",\s*DEBUG,\s*\)/, + ); + expect(buildSettingsOf(text, APP_RELEASE_CONFIG)).not.toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', + ); + }); + + it('leaves a config that already sets DEBUG (scalar form) untouched', () => { + const {text} = inject(withDebugCondition(PLAIN, '"$(inherited) DEBUG"')); + // Not promoted to an array, not re-appended — DEBUG is already there. + expect(buildSettingsOf(text, APP_DEBUG_CONFIG)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + expect(text.match(/\bDEBUG\b/g)).toHaveLength(1); + }); + + it("adds DEBUG alongside the user's own compilation conditions", () => { + const {text} = inject( + withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + ); + // MY_DEBUG_UI must not be mistaken for DEBUG by a substring check. + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toContain('"$(inherited) MY_DEBUG_UI"'); + expect(debugSettings).toMatch(/^\s*DEBUG,$/m); + }); + it('prepends the Sync SPM Autolinking build phase', () => { const {text} = inject(PLAIN); expect(text).toContain('Sync SPM Autolinking'); @@ -227,6 +298,50 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(syncIdx).toBeLessThan(sourcesIdx); }); + it.each([ + [ + '"$(inherited)"', + ['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'], + ], + [ + '"$(inherited) $(SRCROOT)/vendor/include"', + [ + '"$(inherited)"', + '"$(inherited) $(SRCROOT)/vendor/include"', + '"$(SRCROOT)/build/generated/autolinking/headers"', + ], + ], + ])( + 'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)', + (scalar, expectedMembers) => { + const {text} = inject(withHeaderSearchPaths(scalar)); + const arrays = [ + ...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g), + ].map(m => + m[1] + .split('\n') + .map(line => line.trim().replace(/,$/, '')) + .filter(member => member.length > 0), + ); + // Both app-target configs (Debug + Release). + expect(arrays).toEqual([expectedMembers, expectedMembers]); + }, + ); + + it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => { + const {text} = inject(withHeaderSearchPaths('("$(inherited)", )')); + expect(isBalanced(text)).toBe(true); + const arrays = [ + ...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g), + ].map(m => m[1]); + // Both app-target configs, each keeping the one-line shape it was written in. + expect(arrays).toEqual( + Array(2).fill( + '"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ', + ), + ); + }); + it('adds one generated embed phase immediately after Frameworks', () => { const {text} = inject(PLAIN); expect(text).not.toContain('Fix SPM Embedded Flavor'); @@ -359,6 +474,616 @@ describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { }); }); +// A generated source's `name` (its basename) is plugin-derived, so it reaches +// the file reference's, the build file's and the Sources-membership comments +// under the same rules as a script-phase name: normalized, never raw. A `{` +// there makes findObjectByUuid read the next object's body as this one's, and a +// `,` makes removeArrayMembersByUuid chew the wrong line — corruption with no +// error. [label, filename, expected comment] +const HOSTILE_SOURCE_NAMES = [ + ['an opening brace', 'Weird{Name}.swift', 'Weird Name .swift'], + ['a comma', 'Weird,Name.swift', 'Weird Name.swift'], +]; + +describe.each(HOSTILE_SOURCE_NAMES)( + 'injectSpmIntoPbxproj — a generated source whose filename contains %s', + (_label, fileName, comment) => { + const src = { + path: `build/generated/autolinking/expo/${fileName}`, + name: fileName, + sourceTree: 'SOURCE_ROOT', + fileType: 'sourcecode.swift', + }; + + it('normalizes all three comments and keeps the project balanced', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [src]); + const [fileRefUuid, buildFileUuid] = generatedSourceUuids[src.path]; + expect(definitionComment(text, fileRefUuid)).toBe(comment); + expect(definitionComment(text, buildFileUuid)).toBe( + `${comment} in Sources`, + ); + expect(text).toContain(`fileRef = ${fileRefUuid} /* ${comment} */;`); + const sourcesPhase = text.slice( + text.indexOf('/* Begin PBXSourcesBuildPhase section */'), + ); + expect(sourcesPhase.slice(0, sourcesPhase.indexOf('/* End'))).toContain( + `${buildFileUuid} /* ${comment} in Sources */,`, + ); + expect(isBalanced(text)).toBe(true); + }); + + it('leaves the path and name VALUES verbatim', () => { + const {text} = inject(PLAIN, null, null, [src]); + expect(text).toContain(`path = "${src.path}";`); + expect(text).toContain(`name = "${fileName}";`); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [src]).text; + expect(inject(first, null, null, [src]).text).toBe(first); + }); + }, +); + +describe('injectSpmIntoPbxproj — an ordinary generated-source name', () => { + // Normalization must be invisible for every real-world filename, or every + // already-injected project churns on its next sync. + it('reaches all three comments byte-unchanged', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ + PROVIDER_SOURCE, + ]); + const [fileRefUuid, buildFileUuid] = + generatedSourceUuids[PROVIDER_SOURCE.path]; + expect(definitionComment(text, fileRefUuid)).toBe( + 'ExpoModulesProvider.swift', + ); + expect(definitionComment(text, buildFileUuid)).toBe( + 'ExpoModulesProvider.swift in Sources', + ); + expect(text).toContain( + `fileRef = ${fileRefUuid} /* ExpoModulesProvider.swift */;`, + ); + }); +}); + +// Plugin-declared build phases (the expo-constants case: write app.config into +// the app bundle after the JS bundle phase). +const APP_CONFIG_PHASE = { + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo ok > app.config', + position: 'end', + inputPaths: ['$(SRCROOT)/app.json'], + outputPaths: ['$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config'], +}; + +describe('injectSpmIntoPbxproj — Tier 4 (plugin script phases)', () => { + it('adds one shell script phase carrying the declared name, script and paths', () => { + const {text, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + [APP_CONFIG_PHASE], + ); + // Two RN-owned phases (sync + embed) plus this one. + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(text).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + expect(text).toContain('name = "Bundle Expo app.config";'); + expect(text).toContain('shellScript = "echo ok > app.config";'); + expect(text).toContain('\t\t\t\t"$(SRCROOT)/app.json",\n'); + expect(text).toContain( + '\t\t\t\t"$(TARGET_BUILD_DIR)/EXConstants.bundle/app.config",\n', + ); + // Recorded so `deinit` reverses it and `update` reconciles it. + const {injectedUuids} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + expect(injectedUuids).toEqual(expect.arrayContaining([uuid])); + expect(isBalanced(text)).toBe(true); + }); + + it('emits an unquoted alwaysOutOfDate = 1 only when the phase asks for it', () => { + const withFlag = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}], + ).text; + expect(withFlag).toContain('alwaysOutOfDate = 1;'); + // Xcode writes it immediately after `isa` — match that to avoid churn. + expect(withFlag).toContain( + 'isa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;', + ); + + for (const phase of [ + APP_CONFIG_PHASE, + {...APP_CONFIG_PHASE, alwaysOutOfDate: false}, + ]) { + expect(inject(PLAIN, null, null, [], [phase]).text).not.toContain( + 'alwaysOutOfDate', + ); + } + }); + + it("places an 'end' phase last in buildPhases", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + const comments = buildPhaseComments(text); + expect(comments[comments.length - 1]).toBe('Bundle Expo app.config'); + // NOTE: the fixture target has only Sources/Frameworks/Resources — it has + // no "Bundle React Native code and images" phase, so the real requirement + // (an 'end' phase runs AFTER the JS bundle phase) is not asserted here. + // End-of-array position is what delivers it on a real app target. + }); + + it("places a 'beforeCompile' phase after the sync phase and before Sources", () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, position: 'beforeCompile'}], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'Bundle Expo app.config', + 'Sources', + ]); + }); + + it('preserves declared order within each position', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + const {text} = inject( + PLAIN, + null, + null, + [], + [ + phase('pre-a', 'beforeCompile'), + phase('post-a', 'end'), + phase('pre-b', 'beforeCompile'), + phase('post-b', 'end'), + ], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'pre-a', + 'pre-b', + 'Sources', + ]); + expect(comments.slice(-2)).toEqual(['post-a', 'post-b']); + }); + + it('is idempotent with script phases — a second run is byte-for-byte identical', () => { + const phases = [ + {...APP_CONFIG_PHASE, alwaysOutOfDate: true}, + {...APP_CONFIG_PHASE, id: 'other', name: 'Other', position: 'end'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + const second = inject(first, null, null, [], phases).text; + expect(second).toBe(first); + }); + + it('escapes a script carrying quotes, a backslash, a newline and a $(VAR)', () => { + const script = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const phases = [{...APP_CONFIG_PHASE, script}]; + const {text} = inject(PLAIN, null, null, [], phases); + expect(text).toContain( + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";', + ); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('quotes a name containing a double quote in the field, dropping it from the comments', () => { + const phases = [{...APP_CONFIG_PHASE, name: 'Bundle "app.config"'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(text).toContain('name = "Bundle \\"app.config\\"";'); + expect(text).toContain(`${uuid} /* Bundle app.config */ = {`); + expect(buildPhaseComments(text)).toContain('Bundle app.config'); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('a rename refreshes the name field AND both /* … */ comments', () => { + const first = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write App Config'}], + ).text; + const renamed = inject( + first, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: 'Write Expo Config'}], + ).text; + // Xcode normalizes comments on its next write, so a stale one is a spurious + // diff in the user's repo: nothing but the name may differ. + expect(renamed).not.toContain('Write App Config'); + expect(renamed).toBe( + first.split('Write App Config').join('Write Expo Config'), + ); + }); +}); + +// A declared `position` — and the declared order of two phases sharing one — is +// enforced on every sync, not only at first injection. The membership lines are +// rewritten ONLY when the actual order differs, which is what keeps an unchanged +// sync byte-identical. +describe('injectSpmIntoPbxproj — repositioning plugin script phases', () => { + const phase = (id, position) => ({ + ...APP_CONFIG_PHASE, + id, + name: id, + position, + }); + + it('moves a phase from end to beforeCompile and back again', () => { + const atEnd = inject(PLAIN, null, null, [], [phase('a', 'end')]).text; + expect(buildPhaseComments(atEnd).slice(-1)).toEqual(['a']); + + const beforeCompile = [phase('a', 'beforeCompile')]; + const moved = inject(atEnd, null, null, [], beforeCompile).text; + expect(buildPhaseComments(moved).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'Sources', + ]); + expect(isBalanced(moved)).toBe(true); + // Re-syncing the now-matching declaration changes nothing. + expect(inject(moved, null, null, [], beforeCompile).text).toBe(moved); + + // And back: a move is a pure reordering of the membership lines. + expect(inject(moved, null, null, [], [phase('a', 'end')]).text).toBe(atEnd); + }); + + it('reorders two phases sharing a position when their declared order swaps', () => { + const inOrder = inject( + PLAIN, + null, + null, + [], + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ).text; + expect(buildPhaseComments(inOrder).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b1', + 'b2', + 'Sources', + ]); + + const swapped = inject( + inOrder, + null, + null, + [], + [phase('b2', 'beforeCompile'), phase('b1', 'beforeCompile')], + ).text; + expect(buildPhaseComments(swapped).slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'b2', + 'b1', + 'Sources', + ]); + }); + + it('reseats only the phase whose position changed', () => { + const declared = [ + phase('a', 'beforeCompile'), + phase('b', 'beforeCompile'), + phase('c', 'beforeCompile'), + ]; + const first = inject(PLAIN, null, null, [], declared).text; + expect(buildPhaseComments(first).slice(0, 5)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'b', + 'c', + 'Sources', + ]); + + const {text} = inject( + first, + null, + null, + [], + [declared[0], {...declared[1], position: 'end'}, declared[2]], + ); + const comments = buildPhaseComments(text); + expect(comments.slice(0, 4)).toEqual([ + 'Sync SPM Autolinking', + 'a', + 'c', + 'Sources', + ]); + expect(comments[comments.length - 1]).toBe('b'); + }); + + it('moves a phase the user dragged in Xcode back to its declared position', () => { + const phases = [phase('a', 'end')]; + const {text: first, scriptPhaseUuids} = inject( + PLAIN, + null, + null, + [], + phases, + ); + const memberLine = `\n\t\t\t\t${scriptPhaseUuids.a} /* a */,`; + expect(first).toContain(memberLine); + const dragged = first + .replace(memberLine, '') + .replace('buildPhases = (\n', `buildPhases = (${memberLine}\n`); + expect(buildPhaseComments(dragged)[0]).toBe('a'); + + expect(inject(dragged, null, null, [], phases).text).toBe(first); + }); + + // RN never re-seats its own sync phase, so a user who drags it below Sources + // keeps it there — but `beforeCompile` means before Sources, which is the + // guarantee the plugin contract makes. + it('seats a beforeCompile phase before Sources even when the sync phase sits below it', () => { + const dragged = dragSyncBelowSources(inject(PLAIN).text); + expect(buildPhaseComments(dragged).slice(0, 2)).toEqual([ + 'Sources', + 'Sync SPM Autolinking', + ]); + + const declared = [phase('a', 'beforeCompile')]; + const {text} = inject(dragged, null, null, [], declared); + expect(buildPhaseComments(text)).toEqual([ + 'a', + 'Sources', + 'Sync SPM Autolinking', + 'Frameworks', + 'Embed React Native Flavored Frameworks', + 'Resources', + ]); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], declared).text).toBe(text); + }); + + it('falls back to the sync phase as the anchor when the target has no Sources phase', () => { + const noSources = PLAIN.replace( + /\/\* Begin PBXSourcesBuildPhase section \*\/[\s\S]*?\/\* End PBXSourcesBuildPhase section \*\/\n\n/, + '', + ); + const {text} = inject( + noSources, + null, + null, + [], + [phase('a', 'beforeCompile')], + ); + expect(buildPhaseComments(text).slice(0, 2)).toEqual([ + 'Sync SPM Autolinking', + 'a', + ]); + }); + + it.each([ + ['one end phase', [phase('a', 'end')]], + ['one beforeCompile phase', [phase('a', 'beforeCompile')]], + [ + 'two beforeCompile phases', + [phase('b1', 'beforeCompile'), phase('b2', 'beforeCompile')], + ], + ['two end phases', [phase('e1', 'end'), phase('e2', 'end')]], + [ + 'mixed positions', + [ + phase('b1', 'beforeCompile'), + phase('e1', 'end'), + phase('b2', 'beforeCompile'), + phase('e2', 'end'), + ], + ], + ])('re-syncs %s byte-identically', (_label, phases) => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The membership order drives every re-seating decision, so it must list the +// members and nothing else: a `/* … */` comment is arbitrary plugin-supplied +// text, and a phase NAMED like a UUID would otherwise read as an extra member — +// making the actual order permanently disagree with the declared one. +describe('buildPhaseOrder', () => { + const PHANTOM = 'ABCDEF012345678901234567'; + + it('lists only line-leading UUIDs, never one inside a comment', () => { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name: PHANTOM}], + ); + const plan = planInjection(text, {}); + const order = buildPhaseOrder(text, plan.target); + + expect(text).toContain(`/* ${PHANTOM} */,`); + expect(order).not.toContain(PHANTOM); + expect(order).toHaveLength(buildPhaseComments(text).length); + }); + + it('seats a phase named like a UUID normally, and re-syncs byte-identically', () => { + const phases = [ + {...APP_CONFIG_PHASE, name: PHANTOM, position: 'beforeCompile'}, + ]; + const first = inject(PLAIN, null, null, [], phases).text; + expect(buildPhaseComments(first).slice(0, 3)).toEqual([ + 'Sync SPM Autolinking', + PHANTOM, + 'Sources', + ]); + expect(inject(first, null, null, [], phases).text).toBe(first); + }); +}); + +// The trailing comment beside a UUID on its object-definition line. +function definitionComment(text, uuid) { + const m = new RegExp(`\\n\\t*${uuid}(?: /\\* (.*?) \\*/)? = \\{`).exec(text); + return m == null ? null : (m[1] ?? null); +} + +// A pbxproj `/* … */` comment is cosmetic — Xcode regenerates it from the +// object's own `name` field — so a plugin-supplied name is NORMALIZED for the +// comment and kept verbatim (quoted) in the field. Nothing a scanner could read +// as structure may survive into a comment: findObjectByUuid takes the first `{` +// after the UUID as the object's body, and removeArrayMembersByUuid identifies a +// member line by its trailing comma — so a `{` or a `,` in a comment splices +// fields into the wrong object, or makes `deinit` chew the section header. +// [label, name, expected comment] +const HOSTILE_NAMES = [ + ['an opening brace', 'Bundle { app', 'Bundle app'], + ['a closing brace', 'Bundle } app', 'Bundle app'], + ['an opening paren', 'Bundle (app', 'Bundle app'], + ['a closing paren', 'Bundle app)', 'Bundle app'], + ['a comma', 'A , B', 'A B'], + ['a semicolon', 'A; B', 'A B'], + ['an equals sign', 'name = {', 'name'], + ['a comment terminator', 'Bad */ = { x', 'Bad x'], + ['a comment opener', 'Bad /* x', 'Bad x'], + ['a bare asterisk', 'A * B', 'A B'], + ['a bare slash', 'Copy A/B', 'Copy A B'], + ['an unbalanced double quote', 'He said "hi', 'He said hi'], + ['a balanced double-quote pair', 'Bundle "app.config"', 'Bundle app.config'], + ['a tab', 'A\tB', 'A B'], + ['non-ASCII characters', 'Générer la config 📦', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`, `Bundle ${'x'.repeat(300)}`], + // Nothing printable survives normalization — fall back to the phase id, + // itself normalized (see the scoped-id describe below). + ['only structural characters', '*/*', APP_CONFIG_PHASE.id], +]; + +describe.each(HOSTILE_NAMES)( + 'injectSpmIntoPbxproj — a plugin phase name containing %s', + (_label, name, comment) => { + const phases = [{...APP_CONFIG_PHASE, name}]; + + it('normalizes it in both comments and keeps the project balanced', () => { + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids[APP_CONFIG_PHASE.id]; + expect(definitionComment(text, uuid)).toBe(comment); + expect(buildPhaseComments(text)).toContain(comment); + expect(isBalanced(text)).toBe(true); + // The phase object is intact — the sanity check that nothing was spliced + // into a neighbouring object through a comment-borne `{`. + expect(text).toContain(`${uuid} /* ${comment} */ = {`); + expect(text.match(/isa = PBXShellScriptBuildPhase;/g)).toHaveLength(3); + }); + + it('re-injects byte-identically', () => { + const first = inject(PLAIN, null, null, [], phases).text; + expect(inject(first, null, null, [], phases).text).toBe(first); + }); + }, +); + +// A scoped npm package name is the natural stable id for a package-owned phase +// (`@expo/log-box` is a named consumer), so `@` and `/` are in the id charset. +// The id is also the comment's fallback, and it is normalized there exactly like +// a name — the comment must not depend on which characters the charset admits. +describe('injectSpmIntoPbxproj — a scoped-npm-name phase id', () => { + const SCOPED = {...APP_CONFIG_PHASE, id: '@expo/log-box'}; + + it('keys the phase UUID on the scoped id verbatim', () => { + const {scriptPhaseUuids} = inject(PLAIN, null, null, [], [SCOPED]); + expect(Object.keys(scriptPhaseUuids)).toEqual(['@expo/log-box']); + expect(scriptPhaseUuids['@expo/log-box']).toMatch(/^[0-9A-F]{24}$/); + }); + + it('normalizes the id when nothing in the name survives', () => { + const phases = [{...SCOPED, name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['@expo/log-box']; + expect(definitionComment(text, uuid)).toBe('@expo log-box'); + expect(buildPhaseComments(text)).toContain('@expo log-box'); + expect(text).toContain(`${uuid} /* @expo log-box */ = {`); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); + + it('writes no comment at all when neither the name nor the id survives', () => { + const phases = [{...APP_CONFIG_PHASE, id: '//', name: '*/*'}]; + const {text, scriptPhaseUuids} = inject(PLAIN, null, null, [], phases); + const uuid = scriptPhaseUuids['//']; + expect(definitionComment(text, uuid)).toBe(null); + expect(text).toContain(`${uuid} = {`); + expect(text).toMatch(new RegExp(`\\n\\t+${uuid},`)); + expect(isBalanced(text)).toBe(true); + expect(inject(text, null, null, [], phases).text).toBe(text); + }); +}); + +describe('injectSpmIntoPbxproj — phase name field vs. comment', () => { + it('keeps the raw name in the escaped `name` field Xcode displays', () => { + const cases = [ + ['Bundle "app.config"', 'name = "Bundle \\"app.config\\"";'], + ['A\tB', 'name = "A\\tB";'], + ['name = {', 'name = "name = {";'], + ['*/*', 'name = "*/*";'], + ]; + for (const [name, expected] of cases) { + const {text} = inject( + PLAIN, + null, + null, + [], + [{...APP_CONFIG_PHASE, name}], + ); + expect(text).toContain(expected); + } + }); + + it("leaves React Native's own two phase comments byte-identical", () => { + const {text} = inject(PLAIN, null, null, [], [APP_CONFIG_PHASE]); + for (const label of [ + 'Sync SPM Autolinking', + 'Embed React Native Flavored Frameworks', + ]) { + expect(text).toMatch( + new RegExp(`\\n\\t\\t[0-9A-F]{24} /\\* ${label} \\*/ = \\{`), + ); + expect(text).toMatch( + new RegExp(`\\n\\t\\t\\t\\t[0-9A-F]{24} /\\* ${label} \\*/,`), + ); + } + }); + + // Xcode's own comment convention for a package reference carries quotes and + // slashes. Normalizing those would rewrite bytes Xcode itself produces, so + // only untrusted labels go through commentSafe. + it("preserves Xcode's comment convention for the package references", () => { + const {text} = inject( + PLAIN, + null, + null, + [PROVIDER_SOURCE], + [APP_CONFIG_PHASE], + ); + expect(text).toContain( + '/* XCLocalSwiftPackageReference "build/generated/autolinking" */', + ); + expect(text).toContain('/* ExpoModulesProvider.swift in Sources */'); + expect(text).toContain('/* ReactHeaders in Frameworks */'); + }); +}); + describe('injectSpmIntoPbxproj — invariants', () => { it('produces a balanced (well-formed) pbxproj', () => { const {text} = inject(PLAIN); diff --git a/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js new file mode 100644 index 000000000000..c7330315b41a --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/pbxproj-oracles.js @@ -0,0 +1,33 @@ +/** + * 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. + * + * @format + * @noflow + */ + +'use strict'; + +// A simple balanced-delimiter check (an injected project must stay well-formed). +function isBalanced(text) { + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i++; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i++; + i++; + } + } else if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + } + } + return depth === 0; +} + +module.exports = {isBalanced}; diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index db544d351be1..f8c4dbdfe2f3 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -17,6 +17,7 @@ const { readPinnedConfigCommand, removeSpmInjection, } = require('../generate-spm-xcodeproj'); +const {isBalanced} = require('./pbxproj-oracles'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -38,12 +39,41 @@ afterEach(() => { // Build a throwaway app dir: /MyApp.xcodeproj/project.pbxproj seeded with // the plain (SPM-only) fixture, and a node_modules/react-native sibling so the // relative reactNativePath resolves. -function scaffoldApp() { +// Pre-existing values for an injected array setting (HEADER_SEARCH_PATHS), +// seeded into both app-target configs. The plain fixture has none, so it only +// ever exercises the create-from-absent path; deinit must restore each of +// these forms — a plain scalar is ordinary, valid pbxproj. +const PRE_EXISTING_HEADER_SEARCH_PATHS = { + 'a bare $(inherited) scalar': '"$(inherited)"', + 'a scalar with real content': '"$(inherited) $(SRCROOT)/vendor/include"', + 'an array': '(\n\t\t\t\t"$(inherited)",\n\t\t\t)', + // What hand edits and other generators (XcodeGen, Tuist) write. + 'a one-line array': '("$(inherited)", )', +}; + +// Seed a whole `KEY = value;` field (comments and stray whitespace included) +// into both app-target configs. +function withSetting(field /*: string */) { + return PLAIN.replaceAll( + 'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;', + `${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`, + ); +} + +function withHeaderSearchPaths(value /*: string */) { + return withSetting(`HEADER_SEARCH_PATHS = ${value};`); +} + +function scaffoldApp(pbxproj /*: string */ = PLAIN) { const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deinit-')); scaffoldedAppRoots.push(appRoot); const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj'); fs.mkdirSync(xcodeprojPath, {recursive: true}); - fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8'); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxproj, + 'utf8', + ); const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); fs.mkdirSync(rnRoot, {recursive: true}); const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); @@ -88,9 +118,41 @@ function writeManifest(appRoot, relPaths) { ); } +const SCRIPT_PHASES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', +); + +function writeScriptPhases(appRoot, phases) { + const manifestPath = path.join(appRoot, SCRIPT_PHASES_MANIFEST); + fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); + fs.writeFileSync(manifestPath, JSON.stringify(phases, null, 2), 'utf8'); +} + +const APP_CONFIG_PHASE = { + // Contract charset (/^[@A-Za-z0-9_./-]+$/) — the reader skips anything else. + id: 'expo-constants.app-config', + name: 'Bundle Expo app.config', + script: 'echo v1', + position: 'end', +}; + +function markerTextOf(xcodeprojPath) { + return fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'); +} + function readMarker(xcodeprojPath) { - return JSON.parse( - fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'), + return JSON.parse(markerTextOf(xcodeprojPath)); +} + +function schemePathOf(xcodeprojPath) { + return path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + 'MyApp.xcscheme', ); } @@ -121,6 +183,74 @@ describe('removeSpmInjection — the surgical inverse of add', () => { ); }); + // Both records the second run relies on — `createdArrayFields` and + // `scheme.created` — are carried forward for the reason spelled out on + // mergeCreatedArrayFields. Without that, this second marker forgets them and + // `deinit` leaves an empty `packageReferences` / `packageProductDependencies` + // behind and the generated scheme on disk. Zero script phases here: the defect + // is in the injector's reversal record, not in any one feature. + it('round-trips add → update → deinit byte-for-byte, scheme included', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + const schemePath = schemePathOf(xcodeprojPath); + expect(fs.existsSync(schemePath)).toBe(true); + + sync(); + // The re-sync changed neither the project… + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + // …nor the record of what has to be undone. + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toMatch(/packageReferences/); + expect(after).not.toMatch(/packageProductDependencies/); + expect(after).toBe(before); + expect(fs.existsSync(schemePath)).toBe(false); + }); + + // A Debug config that already carries DEBUG gets no edit at all, so there is + // nothing for the marker to record — and nothing left behind. Injecting into + // the scalar form regardless (addArrayStringValues dedupes by exact array + // member, which the scalar never matches) would promote it to an array the + // marker has no record of, and deinit would strand it. + it('leaves a Debug config that already sets DEBUG alone, add through deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const head = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + head, + `${head}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";`, + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + it('preserves an unrelated edit made to the pbxproj after add', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); @@ -190,6 +320,204 @@ describe('removeSpmInjection — the surgical inverse of add', () => { }); }); +// --------------------------------------------------------------------------- +// Scheme ownership. `scheme.created` records that RN wrote the file, which is +// necessary but not sufficient to delete it on `deinit`: the user may have +// replaced its contents with a scheme of their own (same name, same target). +// Deleting that would destroy their work, so the file is only removed while its +// contents are still the ones RN generates. +// --------------------------------------------------------------------------- +describe('deinit — scheme ownership', () => { + // A scheme the user authored for the same target: same file name, same + // BlueprintIdentifier, no PreActions. + function userAuthoredScheme(targetUuid) { + return ` + + + + + + + + + + + + +`; + } + + function setUp() { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const deinit = () => removeSpmInjection({appRoot, xcodeprojPath}); + const schemePath = schemePathOf(xcodeprojPath); + const readScheme = () => fs.readFileSync(schemePath, 'utf8'); + return {xcodeprojPath, sync, deinit, schemePath, readScheme}; + } + + it('deletes the scheme it created while the file is still its own', () => { + const {sync, deinit, schemePath} = setUp(); + sync(); + sync(); + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); + + it("keeps the user's own scheme when they replaced the generated one", () => { + const {xcodeprojPath, sync, deinit, schemePath, readScheme} = setUp(); + sync(); + const mine = userAuthoredScheme(readMarker(xcodeprojPath).targetUuid); + fs.writeFileSync(schemePath, mine, 'utf8'); + + // The second sync re-adds the pre-action to the file the user now owns, and + // still records that RN originally created it. + sync(); + expect(readScheme()).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scheme.created).toBe(true); + + expect(deinit().status).toBe('removed'); + // Their file survives, with only RN's pre-action stripped back out. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).toBe(mine); + }); + + it('keeps its own scheme once the user has edited it', () => { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'buildConfiguration = "Release"\n revealArchiveInOrganizer', + 'buildConfiguration = "Debug"\n revealArchiveInOrganizer', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + // Leaking a scheme beats deleting an edit: the file stays, minus the + // pre-action. + expect(fs.existsSync(schemePath)).toBe(true); + expect(readScheme()).not.toContain('Sync SPM Autolinking'); + expect(readScheme()).toContain( + ' { + const {sync, deinit, schemePath, readScheme} = setUp(); + sync(); + fs.writeFileSync( + schemePath, + readScheme().replace( + 'scriptText = "set -euo pipefail', + 'scriptText = "echo hi', + ), + 'utf8', + ); + + expect(deinit().status).toBe('removed'); + expect(fs.existsSync(schemePath)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// createdArrayFields — "this array field did not exist before RN touched it". +// It licenses removing the field at `deinit`, but only once RN's own members are +// gone AND nothing else is left in it: a package the user added to the same +// field is theirs, and dropping the field would orphan it. +// --------------------------------------------------------------------------- +describe('deinit — array fields RN created', () => { + it('keeps a created field the user added their own package to', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + // The user adds their own remote package in Xcode, after injection. + const userRef = 'DEADBEEF0000000000000001'; + const userMember = `${userRef} /* XCRemoteSwiftPackageReference "swift-log" */,`; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + pbxprojOf(xcodeprojPath).replace( + 'packageReferences = (\n', + `packageReferences = (\n\t\t\t\t${userMember}\n`, + ), + 'utf8', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + // Their member — and the field holding it — survive… + expect(after).toContain(userMember); + expect(after).toMatch(/packageReferences = \(/); + // …while RN's own members go, as does the field RN created and emptied. + expect(after).not.toMatch(/relativePath = build\/xcframeworks/); + expect(after).not.toMatch(/packageProductDependencies/); + }); + + it('restores a field that pre-existed but was empty byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + // An empty `packageReferences` the user already had (Xcode leaves one behind + // after removing the last package). RN never records it as created, so the + // field itself must outlive `deinit`. + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + '\t\t\tprojectDirPath = "";', + '\t\t\tpackageReferences = (\n\t\t\t);\n\t\t\tprojectDirPath = "";', + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).createdArrayFields).not.toEqual( + expect.arrayContaining([ + {container: 'project', key: 'packageReferences'}, + ]), + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); + describe('generated-sources reconciliation on update', () => { it('removes exactly the UUIDs of an entry dropped from the manifest, keeping the rest', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); @@ -270,6 +598,42 @@ describe('generated-sources reconciliation on update', () => { expect(readMarker(xcodeprojPath).generatedSources).toEqual({}); }); + // A plugin-supplied filename reaches three `/* … */` comments. Whatever it + // contains, `deinit` must find the file reference, the build file and their + // memberships again and leave the file as it was — the comment-normalization + // contract these lean on lives in inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Weird{Name}.swift'], + ['a comma', 'Weird,Name.swift'], + ])( + 'deinits a generated source whose filename contains %s, leaving no residue', + (_label, fileName) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const rel = `build/generated/autolinking/expo/${fileName}`; + writeManifest(appRoot, [rel]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuids = readMarker(xcodeprojPath).generatedSources[rel]; + expect(uuids).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + for (const uuid of uuids) { + expect(after).not.toContain(uuid); + } + expect(after).not.toContain('SPM Generated Sources'); + expect(after).not.toContain(fileName); + expect(after).toBe(before); + }, + ); + it('injects nothing generated-source-related when no manifest exists', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); injectSpmIntoExistingXcodeproj({ @@ -283,10 +647,315 @@ describe('generated-sources reconciliation on update', () => { }); }); +describe('script-phases reconciliation on update', () => { + it('records an added phase in the marker keyed on its plugin id', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const {scriptPhases} = readMarker(xcodeprojPath); + expect(Object.keys(scriptPhases)).toEqual([APP_CONFIG_PHASE.id]); + const uuid = scriptPhases[APP_CONFIG_PHASE.id]; + expect(uuid).toMatch(/^[0-9A-F]{24}$/); + expect(pbxprojOf(xcodeprojPath)).toContain( + `${uuid} /* Bundle Expo app.config */ = {`, + ); + }); + + it('removes the phase object AND its buildPhases membership when the id leaves the manifest', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + + writeScriptPhases(appRoot, []); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('Bundle Expo app.config'); + // The RN-owned phases are untouched. + expect(after).toContain('Sync SPM Autolinking'); + expect(readMarker(xcodeprojPath).scriptPhases).toEqual({}); + }); + + it('refreshes a changed script in place, leaving every other byte alone', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + expect(first).toContain('shellScript = "echo v1";'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: 'echo v2'}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + expect(pbxprojOf(xcodeprojPath)).toBe( + first.replace('"echo v1"', '"echo v2"'), + ); + }); + + // The declared position is enforced on every sync, not only at first + // injection — see inject-spm-xcodeproj-test.js for the ordering matrix. Here: + // it survives the sidecar/marker path, and `deinit` still reverses it. + it('re-seats a phase whose declared position changed, and still deinits cleanly', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + writeScriptPhases(appRoot, [APP_CONFIG_PHASE]); + sync(); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + const memberLine = `${uuid} /* Bundle Expo app.config */,`; + const atEnd = pbxprojOf(xcodeprojPath); + const sourcesAt = atEnd.indexOf('Sources */,'); + expect(atEnd.indexOf(memberLine)).toBeGreaterThan(sourcesAt); + + writeScriptPhases(appRoot, [ + {...APP_CONFIG_PHASE, position: 'beforeCompile'}, + ]); + sync(); + const moved = pbxprojOf(xcodeprojPath); + expect(moved.indexOf(memberLine)).toBeLessThan( + moved.indexOf('Sources */,'), + ); + // Moved, not duplicated. + expect(moved.split(memberLine)).toHaveLength(2); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('round-trips add → update → deinit byte-for-byte with unchanged phases', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [ + APP_CONFIG_PHASE, + { + ...APP_CONFIG_PHASE, + id: 'other', + name: 'Other', + position: 'beforeCompile', + alwaysOutOfDate: true, + inputPaths: ['$(SRCROOT)/app.json'], + }, + ]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain('Bundle Expo app.config'); + const marker = markerTextOf(xcodeprojPath); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + expect(fs.existsSync(schemePathOf(xcodeprojPath))).toBe(false); + }); + + // A scoped npm name is a valid id, and it is also a JSON ledger key in the + // marker — the only handle `deinit` has on the phase it injected. + it('round-trips a scoped npm-name id through the marker and deinits byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, id: '@expo/log-box'}]); + const sync = () => + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + sync(); + const injected = pbxprojOf(xcodeprojPath); + const marker = markerTextOf(xcodeprojPath); + expect(isBalanced(injected)).toBe(true); + expect(Object.keys(readMarker(xcodeprojPath).scriptPhases)).toEqual([ + '@expo/log-box', + ]); + expect(marker).toContain('"@expo/log-box"'); + const uuid = readMarker(xcodeprojPath).scriptPhases['@expo/log-box']; + expect(injected).toContain(`${uuid} /* Bundle Expo app.config */ = {`); + + sync(); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + expect(markerTextOf(xcodeprojPath)).toBe(marker); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain(uuid); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).toBe(before); + }); + + it('removes alwaysOutOfDate when a plugin flips it back off', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain('alwaysOutOfDate = 1;'); + + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: false}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('alwaysOutOfDate'); + expect(after).toContain('Bundle Expo app.config'); + + // …and back on again, stably (the field is re-added to an existing object, + // so it lands ahead of `isa` rather than after it — order is not semantic + // in a pbxproj, and a further re-sync must be a no-op). + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, alwaysOutOfDate: true}]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const back = pbxprojOf(xcodeprojPath); + expect(back).toContain('alwaysOutOfDate = 1;'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(back); + }); + + // A shell body full of pbxproj-hostile characters: double quotes, a + // backslash, a real newline and an Xcode `$(VAR)`. + const AWKWARD_SCRIPT = + 'echo "a\\b" > "$(DERIVED_FILE_DIR)/x"\nprintf \'%s\\n\' done'; + const AWKWARD_ESCAPED = + 'shellScript = "echo \\"a\\\\b\\" > \\"$(DERIVED_FILE_DIR)/x\\"\\nprintf \'%s\\\\n\' done";'; + + it('escapes an awkward script and refreshes it byte-identically on update', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain(AWKWARD_ESCAPED); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(injected); + }); + + it('deinit restores the pbxproj byte-for-byte after an awkward script', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, script: AWKWARD_SCRIPT}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain(AWKWARD_ESCAPED); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + // The hostile-name matrix, from the other side: whatever a plugin calls its + // phase, `deinit` must find the object and its membership again and leave the + // file as it was. The comment-normalization contract these lean on (and the + // per-name balance/idempotency assertions) lives in + // inject-spm-xcodeproj-test.js. + it.each([ + ['an opening brace', 'Bundle { app'], + ['a closing brace', 'Bundle } app'], + ['an opening paren', 'Bundle (app'], + ['a closing paren', 'Bundle app)'], + ['a comma', 'A , B'], + ['a semicolon', 'A; B'], + ['an equals sign', 'name = {'], + ['a comment terminator', 'Bad */ = { x'], + ['a comment opener', 'Bad /* x'], + ['a bare asterisk', 'A * B'], + ['a bare slash', 'Copy A/B'], + ['an unbalanced double quote', 'He said "hi'], + ['a balanced double-quote pair', 'Bundle "app.config"'], + ['a tab', 'A\tB'], + ['non-ASCII characters', 'Générer la config 📦'], + ['300 characters', `Bundle ${'x'.repeat(300)}`], + ['only structural characters', '*/*'], + ])( + 'deinits a phase whose name contains %s, leaving no residue', + (_label, name) => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + writeScriptPhases(appRoot, [{...APP_CONFIG_PHASE, name}]); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const uuid = readMarker(xcodeprojPath).scriptPhases[APP_CONFIG_PHASE.id]; + expect(pbxprojOf(xcodeprojPath)).toContain(uuid); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('PBXShellScriptBuildPhase'); + expect(after).not.toContain(uuid); + expect(after).toBe(before); + }, + ); +}); + // --------------------------------------------------------------------------- // artifactsVersionOverride — the marker field persisting an explicit -// `spm add/update --version ` pin (see setup-apple-spm.js / -// sync-spm-autolinking.js). SETS on an explicit override; PRESERVES a +// `spm add/update --version ` pin (see setup-apple-spm.js's +// determineVersion). SETS on an explicit override; PRESERVES a // previously-recorded value when the caller omits one; deinit drops it along // with the rest of the marker. // --------------------------------------------------------------------------- @@ -367,9 +1036,9 @@ describe('artifactsVersionOverride marker field', () => { }); // --------------------------------------------------------------------------- -// readArtifactsVersionOverride — pure fs read, used by the build-time sync -// (sync-spm-autolinking.js) to prefer a pinned version over the one derived -// from node_modules/react-native/package.json. +// readArtifactsVersionOverride — pure fs read, used by setup-apple-spm.js's +// determineVersion to prefer a pinned version over the one derived from +// node_modules/react-native/package.json. // --------------------------------------------------------------------------- describe('readArtifactsVersionOverride', () => { it('returns null when no xcodeproj has been injected yet', () => { @@ -539,3 +1208,172 @@ describe('readPinnedConfigCommand', () => { expect(readPinnedConfigCommand(appRoot)).toBeNull(); }); }); + +describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))( + 'removeSpmInjection with HEADER_SEARCH_PATHS already set to %s', + (_label, value) => { + it('restores the pre-existing value byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(value), + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('re-syncing is byte-for-byte identical', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(value), + ); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(first); + }); + }, +); + +// findField's token for a BARE scalar ends AT the `;`, so it includes any +// whitespace before it. Deinit must put those bytes back exactly, not a +// tidied-up version of them. +describe.each([ + 'HEADER_SEARCH_PATHS = $(inherited) ; /* note */', + 'HEADER_SEARCH_PATHS = ;', +])('removeSpmInjection with the untrimmed scalar `%s`', field => { + it('restores it byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field)); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); +}); + +describe('a scalar array setting injection has nothing to add to', () => { + const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";'; + const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";'; + + // The fixture's flavored-frameworks manifest is empty, so + // FRAMEWORK_SEARCH_PATHS is injected with no values at all. + it('is left untouched, unrecorded, and survives a later user edit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR)); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const injected = pbxprojOf(xcodeprojPath); + expect(injected).toContain(SCALAR); + expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/); + for (const change of readMarker(xcodeprojPath).buildSettingChanges) { + expect(change.promotedArrayScalars ?? {}).not.toHaveProperty( + 'FRAMEWORK_SEARCH_PATHS', + ); + } + + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + injected.replaceAll(SCALAR, EDITED), + 'utf8', + ); + removeSpmInjection({appRoot, xcodeprojPath}); + + const after = pbxprojOf(xcodeprojPath); + expect(after).toContain(EDITED); + expect(after).not.toContain(SCALAR); + }); +}); + +describe('a promoted array setting the user deleted after add', () => { + const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"'; + + it('is not resurrected by deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths(SCALAR), + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const deleted = pbxprojOf(xcodeprojPath).replace( + /\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g, + '', + ); + expect(deleted).not.toContain('HEADER_SEARCH_PATHS'); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + deleted, + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + + // Everything else is back to its pre-injection bytes; only the setting the + // user deleted stays gone. + expect(pbxprojOf(xcodeprojPath)).toBe( + before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''), + ); + }); +}); + +// `deinit` removes appendedArrayValues before it restores promotedArrayScalars, +// so recording a key under both happens to come out right today: the scalar +// restore rewrites the whole value last. That makes the exclusivity below +// invisible to a round-trip test, which is why it is asserted on the marker +// directly — reversing those two loops would otherwise silently start removing +// array members from an already-restored scalar. +describe('a promoted scalar is recorded once, not twice', () => { + it('records promotedArrayScalars and not appendedArrayValues for the key', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp( + withHeaderSearchPaths('"$(inherited) $(SRCROOT)/vendor/include"'), + ); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + const changes = readMarker(xcodeprojPath).buildSettingChanges; + expect(changes.length).toBeGreaterThan(0); + for (const change of changes) { + expect(Object.keys(change.promotedArrayScalars ?? {})).toContain( + 'HEADER_SEARCH_PATHS', + ); + expect(Object.keys(change.appendedArrayValues ?? {})).not.toContain( + 'HEADER_SEARCH_PATHS', + ); + } + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js index 7e18c6b1e043..63de6c82feb0 100644 --- a/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -18,6 +18,7 @@ const { scaffoldPackageSwiftForDep, translatePodspecToSpmTarget, } = require('../scaffold-package-swift'); +const {RemoteVersionError} = require('../spm-utils'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -140,6 +141,37 @@ describe('translatePodspecToSpmTarget', () => { expect(spec.coreReactNative).toBe(true); }); + it('uses the resolved swiftName (spm.name override) so the manifest matches what the autolinker registers', () => { + const model = podspec({name: 'react-native-worklets'}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-worklets', swiftName: 'worklets'}), + ); + expect(spec.swiftName).toBe('worklets'); + }); + + it('falls back to toSwiftName when the dep carries no resolved name', () => { + const spec = translatePodspecToSpmTarget( + podspec(), + autolinkedDep({name: 'react-native-foo'}), + ); + expect(spec.swiftName).toBe('ReactNativeFoo'); + }); + + it('resolves each sibling through the same overrides, not through toSwiftName', () => { + const model = podspec({dependencies: ['RNWorklets']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated', swiftName: 'reanimated'}), + new Map([['RNWorklets', 'react-native-worklets']]), + new Map([['react-native-worklets', 'worklets']]), + ); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + expect(spec.siblingSwiftNames).toEqual({ + 'react-native-worklets': 'worklets', + }); + }); + it('does not self-wire when a pod dependency maps back to the dep itself', () => { const model = podspec({dependencies: ['RNReanimated']}); const spec = translatePodspecToSpmTarget( @@ -604,6 +636,18 @@ describe('emitScaffoldedPackageSwift', () => { ); }); + it('emits the sibling override name for both the package and the product', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({ + siblingNames: ['react-native-worklets'], + siblingSwiftNames: {'react-native-worklets': 'worklets'}, + }), + ); + expect(out).toContain('.package(name: "worklets", path: "../worklets")'); + expect(out).toContain('.product(name: "worklets", package: "worklets")'); + expect(out).not.toContain('ReactNativeWorklets'); + }); + it('-includes the ObjC prefix header in c/cxx settings when needsObjCPrefix is set', () => { const withPrefix = emitScaffoldedPackageSwift( baseSpec({needsObjCPrefix: true}), @@ -747,6 +791,21 @@ end }; } + it('names the scaffolded package with the spm.name override the autolinker resolved', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep( + makeDep({swiftName: 'foo'}), + makeCtx(), + ); + expect(result.status).toBe('written'); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(content).toContain('name: "foo"'); + expect(content).not.toContain('ReactNativeFoo'); + }); + it('writes Package.swift into the dep root on the happy path', () => { makePodspec(); const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); @@ -1019,6 +1078,130 @@ describe('scaffoldAll', () => { 'skipped-no-podspec', ); }); + + function writeAutolinkingJson(dependencies) { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + } + + it('propagates a Swift name collision instead of scaffolding anyway, plugin or not', () => { + // 'react-headers' derives the reserved 'ReactHeaders', with no scope to + // borrow. Degrading to the direct deps would scaffold manifests SPM rejects + // later, and a plugin buys no exemption — `spm scaffold` has no plugin code. + const depRoot = path.join(appRoot, 'node_modules', 'react-headers'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {autolinkingPlugin: './spm-plugin.js'}};\n", + ); + writeAutolinkingJson({ + 'react-headers': {root: depRoot, platforms: {ios: {}}}, + }); + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(/React Native reserves/); + }); + + it('still falls back to the direct deps when a transitive dep cannot be resolved', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-a'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + "module.exports = {spm: {dependencies: ['ghost-dep-that-is-not-installed']}};\n", + ); + writeAutolinkingJson({ + 'react-native-a': {root: depRoot, platforms: {ios: {}}}, + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.depName)).toEqual(['react-native-a']); + expect(logSpy.mock.calls.map(call => call.join(' ')).join('\n')).toMatch( + /Transitive spm\.dependencies expansion failed/, + ); + } finally { + logSpy.mockRestore(); + } + }); + + it('emits the remote package reference for every dep it scaffolds', () => { + const depRoot = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), '// native\n'); + fs.writeFileSync( + path.join(depRoot, 'react-native-foo.podspec'), + 'Pod::Spec.new do |s|\n' + + ' s.name = "react-native-foo"\n' + + ' s.version = "1.0"\n' + + ' s.source_files = "ios/**/*.{h,m,mm}"\n' + + ' s.dependency "React-Core"\n' + + 'end\n', + ); + writeAutolinkingJson({ + 'react-native-foo': {root: depRoot, platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/rn.git'; + process.env.RN_SPM_REMOTE_VERSION = '9.9.9'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.map(r => r.status)).toEqual(['written']); + const manifest = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + expect(manifest).toContain( + '.package(url: "https://example.com/rn.git", exact: "9.9.9")', + ); + expect(manifest).not.toContain('.package(name: "ReactNative"'); + } finally { + logSpy.mockRestore(); + if (prevUrl == null) delete process.env.RN_SPM_REMOTE_URL; + else process.env.RN_SPM_REMOTE_URL = prevUrl; + if (prevVersion == null) delete process.env.RN_SPM_REMOTE_VERSION; + else process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + }); + + it('propagates a RemoteVersionError from the remote package config', () => { + writeAutolinkingJson({ + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + }); + const prevUrl = process.env.RN_SPM_REMOTE_URL; + const prevVersion = process.env.RN_SPM_REMOTE_VERSION; + process.env.RN_SPM_REMOTE_URL = 'https://example.com/react-native-spm.git'; + delete process.env.RN_SPM_REMOTE_VERSION; + try { + // No react-native under the temp appRoot, so no version resolves — the + // author must see that, not have it degraded into "expansion failed". + expect(() => + scaffoldAll({appRoot, projectRoot: appRoot, reactNativeRoot: appRoot}), + ).toThrow(RemoteVersionError); + } finally { + if (prevUrl == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = prevUrl; + } + if (prevVersion != null) { + process.env.RN_SPM_REMOTE_VERSION = prevVersion; + } + } + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 7386e4ffcbab..0721c1b60b69 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -12,6 +12,7 @@ const { detectStandardRnLayoutRedirect, + determineVersion, ensureBothArtifactFlavors, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, @@ -561,3 +562,91 @@ describe('shouldAutoDeintegrate', () => { expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// determineVersion — which RN version the artifact slots are wired to: +// explicit --version → the `artifactsVersionOverride` pinned in the injection +// marker by a previous `--version` → node_modules/react-native/package.json. +// --------------------------------------------------------------------------- + +describe('determineVersion', () => { + let appRoot; + let reactNativeRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-app-')); + reactNativeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-version-rn-')); + fs.writeFileSync( + path.join(reactNativeRoot, 'package.json'), + JSON.stringify({name: 'react-native', version: '1000.0.0'}), + ); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(reactNativeRoot, {recursive: true, force: true}); + }); + + const logged = () => logSpy.mock.calls.map(c => c.join(' ')).join('\n'); + + it('prefers an explicit --version over a pinned override', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect( + determineVersion({version: '0.81.0'}, reactNativeRoot, appRoot), + ).toBe('0.81.0'); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it('uses the pinned override when --version is omitted', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '0.80.0', + ); + }); + + it('names the marker in the log when the pin is the source', () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', { + artifactsVersionOverride: '0.80.0', + }); + determineVersion({version: null}, reactNativeRoot, appRoot); + + expect(logged()).toMatch(/0\.80\.0/); + expect(logged()).toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json with no pin recorded", () => { + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + expect(logged()).not.toMatch(/spm-injected\.json/); + }); + + it("falls back to react-native's package.json when no project is injected", () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); + + it('falls back without throwing when the marker is corrupt', () => { + const xcodeproj = path.join(appRoot, 'MyApp.xcodeproj'); + fs.mkdirSync(xcodeproj, {recursive: true}); + fs.writeFileSync(path.join(xcodeproj, SPM_INJECTED_MARKER), '{not json'); + + expect(determineVersion({version: null}, reactNativeRoot, appRoot)).toBe( + '1000.0.0', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js index ee5ee1e1c881..6c109ce67dc5 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -13,6 +13,7 @@ const { addArrayMembers, addArrayStringValues, + commentSafe, ensureScalarField, findApplicationTargets, findField, @@ -41,6 +42,19 @@ const PLAIN_PBXPROJ = fs.readFileSync( 'utf8', ); +// The app target's Debug buildSettings dict, as a body range. +function targetDebugDict(text) { + const cfg = findObjectByUuid(text, 'AA0000000000000000000901'); + const bs = findField(text, cfg, 'buildSettings'); + return {uuid: 'x', bodyOpen: bs.valueStart, bodyClose: bs.tokenEnd - 1}; +} + +// Delimiter balance, checked with the module's own quote-aware scanner: the +// outermost `{` must close on the file's last `}`. +function isBalanced(text) { + return scanToClose(text, text.indexOf('{')) === text.lastIndexOf('}'); +} + // --------------------------------------------------------------------------- // generateUUID // --------------------------------------------------------------------------- @@ -71,11 +85,45 @@ describe('quoteIfNeeded', () => { ['a\\b', '"a\\\\b"'], ['a"b', '"a\\"b"'], ['', '""'], + ['a\nb', '"a\\nb"'], + // A literal CR or tab inside a quoted value is legal, but Xcode rewrites it + // to its escape on the next save — a spurious diff in the user's repo. A + // plugin `script` with CRLF line endings is the way one gets in. + ['a\r\nb', '"a\\r\\nb"'], + ['a\tb', '"a\\tb"'], ])('quoteIfNeeded(%j) => %j', (input, expected) => { expect(quoteIfNeeded(input)).toBe(expected); }); }); +// --------------------------------------------------------------------------- +// commentSafe — a pbxproj `/* … */` comment is cosmetic (Xcode regenerates it +// from the object's fields), so nothing a scanner could read as structure is +// allowed to reach one. +// --------------------------------------------------------------------------- + +describe('commentSafe', () => { + it.each([ + ['Sync SPM Autolinking', 'Sync SPM Autolinking'], + ['Générer la config 📦', 'Générer la config 📦'], + ['Bad */ = { x', 'Bad x'], + ['A , B', 'A B'], + ['He said "hi', 'He said hi'], + ['A\tB', 'A B'], + ['a\r\nb', 'a b'], + ['Copy A/B', 'Copy A B'], + ['*/*', ''], + [' padded ', 'padded'], + ])('commentSafe(%j) => %j', (input, expected) => { + expect(commentSafe(input)).toBe(expected); + }); + + it('is stable under repetition (so a re-sync stays byte-identical)', () => { + const once = commentSafe('Bad */ = { x, y'); + expect(commentSafe(once)).toBe(once); + }); +}); + // --------------------------------------------------------------------------- // Surgical-edit toolkit (in-place injection primitives) // --------------------------------------------------------------------------- @@ -161,12 +209,6 @@ describe('addArrayMembers', () => { }); describe('addArrayStringValues', () => { - function targetDebugDict(text) { - const cfg = findObjectByUuid(text, 'AA0000000000000000000901'); - const bs = findField(text, cfg, 'buildSettings'); - return {uuid: 'x', bodyOpen: bs.valueStart, bodyClose: bs.tokenEnd - 1}; - } - it('creates an array seeded with $(inherited)', () => { const out = addArrayStringValues( PLAIN_PBXPROJ, @@ -195,6 +237,50 @@ describe('addArrayStringValues', () => { expect(out).toContain('"-ObjC"'); }); + // Xcode writes the seed quoted, but the unquoted form is just as valid and + // appears in hand-edited projects. Both are the same value to the build + // system, so neither may be re-emitted alongside the seed. + it.each(['"$(inherited)"', '$(inherited)'])( + 'promotes a bare %s scalar without emitting the seed twice', + priorValue => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + `OTHER_LDFLAGS = ${priorValue}; PRODUCT_NAME = "$(TARGET_NAME)";`, + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + const members = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/ + .exec(out)[1] + .split('\n') + .map(line => line.trim().replace(/,$/, '')) + .filter(member => member.length > 0); + // The scalar's value IS the seed the array is created with. + expect(members).toEqual(['"$(inherited)"', '"-ObjC"']); + }, + ); + + it('promotes an empty scalar without emitting a bare `,` member', () => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = ; PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + const block = /OTHER_LDFLAGS = \(\n([\s\S]*?)\t+\);/.exec(out)[1]; + // Asserted on the raw block: a member list filtered for emptiness (as the + // test above does) would hide the malformed element this guards against. + expect(block.split('\n').filter(line => /^\s*,$/.test(line))).toEqual([]); + expect(block).toContain('"-ObjC"'); + }); + it('dedups by EXACT token, not substring (adds "-ObjC" even when "-ObjCFoo" is present)', () => { const withArray = PLAIN_PBXPROJ.replace( 'PRODUCT_NAME = "$(TARGET_NAME)";', @@ -226,6 +312,102 @@ describe('addArrayStringValues', () => { }); }); +// Xcode writes array build settings multi-line, but hand-edited projects and +// other generators (XcodeGen, Tuist) emit compact one-line ones. Members must +// land INSIDE the array whatever its shape, and `deinit` must be able to take +// them back out again — hence the byte-identical add→remove round trip. +describe('array build settings of every written shape', () => { + const NEW = '"/new"'; + + // Seed `OTHER_LDFLAGS = ;` into the app target's Debug config. + function withValue(value) { + return PLAIN_PBXPROJ.replace( + '\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";', + `\t\t\t\tOTHER_LDFLAGS = ${value};\n\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";`, + ); + } + + function add(text, values) { + return addArrayStringValues( + text, + targetDebugDict(text), + 'OTHER_LDFLAGS', + values, + ); + } + + function remove(text, values) { + return removeArrayStringValues( + text, + targetDebugDict(text), + 'OTHER_LDFLAGS', + values, + ); + } + + describe.each([ + ['an empty array', '()'], + ['a lone member with no trailing comma', '("/a")'], + ['a trailing comma and space', '("/a", )'], + ['no space after the comma', '("/a","/b")'], + ['a space after the comma', '("/a", "/b")'], + ['a member whose quotes hold a comma and parens', '("$(FOO(x)),weird")'], + ['the multi-line shape Xcode writes', '(\n\t\t\t\t\t"/a",\n\t\t\t\t)'], + ])('%s', (_label, shape) => { + const input = withValue(shape); + + it('adds the value inside the array, leaving the file balanced', () => { + const out = add(input, [NEW]); + const field = findField(out, targetDebugDict(out), 'OTHER_LDFLAGS'); + expect(field.value.trimStart().startsWith('(')).toBe(true); + expect(field.value).toContain(NEW); + // Nothing was spliced ahead of the field — i.e. outside the array. + expect(out.slice(0, field.matchStart)).toBe( + input.slice(0, field.matchStart), + ); + expect(isBalanced(out)).toBe(true); + }); + + it.each([[[NEW]], [[NEW, '"/new2"']]])( + 'remove undoes add of %j byte-for-byte', + values => { + const added = add(input, values); + expect(added).not.toBe(input); + expect(remove(added, values)).toBe(input); + }, + ); + }); + + it.each([ + ['a value that is not there', '("/a", )', '"/zzz"'], + ['a member stripped of its quotes', '("$(inherited)", )', '$(inherited)'], + ])('removes nothing when asked for %s', (_label, shape, value) => { + const input = withValue(shape); + expect(remove(input, [value])).toBe(input); + }); + + it('is a no-op when a one-line array already holds the value', () => { + const input = withValue(`(${NEW})`); + expect(add(input, [NEW])).toBe(input); + }); + + it('dedupes a member whose quotes hold a comma', () => { + const weird = '"$(FOO(x)),weird"'; + const input = withValue(`(${weird}, )`); + expect(add(input, [weird])).toBe(input); + }); + + it('splices a multi-line array on its own line, before the closing `)`', () => { + const input = withValue('(\n\t\t\t\t\t"/a",\n\t\t\t\t)'); + expect(add(input, [NEW])).toBe( + input.replace( + '\t\t\t\t\t"/a",\n', + `\t\t\t\t\t"/a",\n\t\t\t\t\t${NEW},\n`, + ), + ); + }); +}); + describe('ensureScalarField', () => { it('adds a scalar only when absent', () => { const project = findProjectObject(PLAIN_PBXPROJ); @@ -377,6 +559,15 @@ describe('surgical removal (deinit inverse)', () => { expect(removeField(added, target2, 'SPM_TEST_FLAG')).toBe(PLAIN_PBXPROJ); }); + // What makes it safe for the marker to carry a created-field record forward + // across syncs: a field the user has since deleted by hand costs nothing. + it('removeField is a no-op when the field is absent', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + expect(removeField(PLAIN_PBXPROJ, target, 'SPM_TEST_FLAG')).toBe( + PLAIN_PBXPROJ, + ); + }); + it('removeArrayStringValues removes only the named values', () => { const [target] = findApplicationTargets(PLAIN_PBXPROJ); const seeded = addArrayStringValues(PLAIN_PBXPROJ, target, 'SPM_TEST_ARR', [ diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js index 87e116d32d51..e7d7aaac1c54 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -11,7 +11,18 @@ 'use strict'; const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_HEADERS_TARGET_DIR, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, RemoteVersionError, + RESERVED_SWIFT_NAMES, buildPerAppHeaderTree, defaultCacheDir, displayPath, @@ -46,6 +57,89 @@ describe('toSwiftName', () => { }); }); +// --------------------------------------------------------------------------- +// Reserved Swift names — the one list the manifests and the guard both use +// --------------------------------------------------------------------------- + +describe('reserved Swift names', () => { + it('names the React Native package and the per-app codegen package', () => { + expect(REACT_NATIVE_PACKAGE_NAME).toBe('ReactNative'); + expect(REACT_CODEGEN_PACKAGE_NAME).toBe('React-GeneratedCode'); + }); + + it('pins each product list to its literal names', () => { + expect(REACT_NATIVE_PRODUCTS).toEqual([ + 'ReactHeaders', + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + expect(REACT_CODEGEN_PRODUCTS).toEqual(['ReactAppHeaders']); + expect(REACT_CODEGEN_APP_PRODUCTS).toEqual([ + 'ReactCodegen', + 'ReactAppDependencyProvider', + ]); + }); + + it('tags each React Native product by kind, so no consumer has to infer it from position', () => { + expect(REACT_NATIVE_UMBRELLA_PRODUCT).toBe('ReactHeaders'); + expect(REACT_NATIVE_HEADERS_PRODUCT).toBe('ReactNativeHeaders'); + expect(REACT_NATIVE_XCFRAMEWORK_PRODUCTS).toEqual([ + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + }); + + it('names the autolinking aggregator package (which shares its name with its product)', () => { + expect(AUTOLINKED_PACKAGE_NAME).toBe('Autolinked'); + }); + + it('names the invariant React headers target directory', () => { + expect(REACT_HEADERS_TARGET_DIR).toBe('ReactHeadersTarget'); + }); + + // The one test that pins the literal strings: every other check compares + // constants to constants, so this is what would catch a rename. + it('reserves exactly the names React Native puts in a manifest', () => { + expect([...RESERVED_SWIFT_NAMES].sort()).toEqual([ + 'Autolinked', + 'React-GeneratedCode', + 'ReactAppDependencyProvider', + 'ReactAppHeaders', + 'ReactCodegen', + 'ReactHeaders', + 'ReactNative', + 'ReactNativeDependenciesHeaders', + 'ReactNativeHeaders', + ]); + }); + + it('holds names that real generated manifests actually use', () => { + const { + generateXCFrameworksPackageSwift, + } = require('../generate-spm-package'); + const manifest = generateXCFrameworksPackageSwift(); + for (const name of [REACT_NATIVE_PACKAGE_NAME, ...REACT_NATIVE_PRODUCTS]) { + expect(manifest).toContain(`"${name}"`); + } + }); + + it('does NOT reserve the headers target dir — target names only have to be unique within their own package', () => { + expect(RESERVED_SWIFT_NAMES).not.toContain(REACT_HEADERS_TARGET_DIR); + }); + + it('freezes the lists so no caller can mutate the shared source of truth', () => { + for (const list of [ + REACT_NATIVE_PRODUCTS, + REACT_CODEGEN_PRODUCTS, + REACT_CODEGEN_APP_PRODUCTS, + RESERVED_SWIFT_NAMES, + ]) { + expect(Array.isArray(list)).toBe(true); + expect(Object.isFrozen(list)).toBe(true); + } + }); +}); + // --------------------------------------------------------------------------- // defaultCacheDir // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js index 82626894c681..78eab0be9602 100644 --- a/packages/react-native/scripts/spm/autolinking-plugins.js +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -69,6 +69,14 @@ * // dir add/remove there re-triggers the sync. Relative/empty/non-string * // entries are dropped with a warning; a non-array is ignored. * watchPaths: ['/abs/path/Package.swift', '/abs/dir'], + * // Build-time shell phases on the app target — SwiftPM has no + * // `script_phase`. Only id/name/script are required; `id` is the + * // ledger key and UUID seed, `position` defaults to 'end'. Recorded + * // to `.spm-plugin-script-phases.json`, from which the `spm add`/ + * // `update` injector emits a PBXShellScriptBuildPhase per entry. + * // Malformed entries and duplicate ids are fatal. + * scriptPhases: [{id, name, script, position, inputPaths, outputPaths, + * alwaysOutOfDate}], * }; * }; * @@ -76,12 +84,14 @@ * the merge, so regeneration stays deterministic and idempotent. */ +const {isValidScriptPhaseId, isValidScriptPhaseName} = require('./spm-utils'); const path = require('path'); /*:: import type { AutolinkedDep, PluginContext, PluginResult, + PluginScriptPhase, DiscoveredPlugin, } from './spm-types'; */ @@ -149,6 +159,12 @@ function discoverPlugins( return found; } +const isNonEmptyString = (value /*: unknown */) /*: boolean */ => + typeof value === 'string' && value.length > 0; + +const isOptionalStringList = (value /*: unknown */) /*: boolean */ => + value == null || (Array.isArray(value) && value.every(isNonEmptyString)); + /** * Invoke discovered plugins and merge their results. Each plugin gets the same * context. Fail-closed: a throwing plugin aborts (named), and a malformed @@ -169,10 +185,12 @@ function invokePlugins( const flavoredFrameworks /*: Array<{id: string, frameworkName: string, linkage: 'dynamic', flavors: {debug: string, release: string}}> */ = []; const watchPaths /*: Array */ = []; + const scriptPhases /*: Array */ = []; const seenPackages /*: Set */ = new Set(); const seenProducts /*: Set */ = new Set(); const seenFrameworkIds /*: Set */ = new Set(); const seenFrameworkNames /*: Set */ = new Set(); + const seenScriptPhaseIds /*: Set */ = new Set(); for (const {depName, pluginPath, plugin} of plugins) { let result /*: unknown */ = null; @@ -203,11 +221,18 @@ function invokePlugins( const rawFrameworks = result.flavoredFrameworks ?? []; // $FlowFixMe[incompatible-use] const rawWatch = result.watchPaths ?? []; + // $FlowFixMe[incompatible-use] + const rawScriptPhases = result.scriptPhases ?? []; if (!Array.isArray(rawFrameworks)) { throw new Error( `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`, ); } + if (!Array.isArray(rawScriptPhases)) { + throw new Error( + `react-native spm: '${depName}' returned a non-array scriptPhases.`, + ); + } // Watch paths are best-effort staleness hints, so // a non-array is ignored (warn, never fatal). if (!Array.isArray(rawWatch)) { @@ -314,6 +339,60 @@ function invokePlugins( } watchPaths.push(w); } + // Fatal like flavoredFrameworks, not dropped like watchPaths: a missing + // phase produces a GREEN build whose generated content was never written. + for (const phase of rawScriptPhases) { + if ( + phase == null || + !isValidScriptPhaseId(phase.id) || + !isNonEmptyString(phase.script) || + (phase.position != null && + phase.position !== 'beforeCompile' && + phase.position !== 'end') || + !isOptionalStringList(phase.inputPaths) || + !isOptionalStringList(phase.outputPaths) || + (phase.alwaysOutOfDate != null && + typeof phase.alwaysOutOfDate !== 'boolean') + ) { + const named = typeof phase?.id === 'string' ? ` '${phase.id}'` : ''; + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase${named} ` + + '(need {id (matching /^[@A-Za-z0-9_./-]+$/, and not __proto__, ' + + 'constructor or prototype), name, script} plus optional ' + + '{position: "beforeCompile" | "end", inputPaths, outputPaths, ' + + 'alwaysOutOfDate}).', + ); + } + if (!isValidScriptPhaseName(phase.name)) { + throw new Error( + `react-native spm: '${depName}' returned an invalid scriptPhase name for ` + + `'${phase.id}' (a name must be a non-empty single-line string — it is ` + + "the phase's display name in Xcode).", + ); + } + if (seenScriptPhaseIds.has(phase.id)) { + throw new Error( + `react-native spm: duplicate script phase id '${phase.id}'.`, + ); + } + seenScriptPhaseIds.add(phase.id); + const normalized /*: PluginScriptPhase */ = { + id: phase.id, + name: phase.name, + script: phase.script, + position: phase.position ?? 'end', + }; + if (phase.inputPaths != null) { + normalized.inputPaths = [...phase.inputPaths]; + } + if (phase.outputPaths != null) { + normalized.outputPaths = [...phase.outputPaths]; + } + if (phase.alwaysOutOfDate != null) { + normalized.alwaysOutOfDate = phase.alwaysOutOfDate; + } + scriptPhases.push(normalized); + } } return { @@ -322,6 +401,7 @@ function invokePlugins( generatedSources, flavoredFrameworks, watchPaths, + scriptPhases, }; } diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js index 0b23b983f0f0..4aea9de5ee59 100644 --- a/packages/react-native/scripts/spm/expand-spm-dependencies.js +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -10,10 +10,12 @@ 'use strict'; -const {toSwiftName} = require('./spm-utils'); +const {RESERVED_SWIFT_NAMES, makeLogger, toSwiftName} = require('./spm-utils'); const fs = require('fs'); const path = require('path'); +const {warn} = makeLogger('expand-spm-dependencies'); + /** * expand-spm-dependencies.js — Resolves transitive native deps declared via * `spm.dependencies` in a library's react-native.config.js. @@ -32,7 +34,7 @@ const path = require('path'); * list with autolinking-shaped entries so the downstream pipeline can convert * each to an SPM target without further branching. * - * I/O is injected (readConfig, resolveDep) so the logic stays pure and + * I/O is injected (readConfig, resolveDep, log) so the logic stays pure and * testable. */ @@ -44,52 +46,232 @@ import type {AutolinkedDep} from './spm-types'; type RnConfig = {...}; type ReadConfig = (root: string) => ?RnConfig; type ResolveDep = (name: string, fromRoot: string) => ?string; +type Log = (message: string) => void; +// Keyed by lower case, valued with the canonical spelling: two names differing +// only in case are not distinct enough for the build to keep the two apart. +type ReservedNames = ReadonlyMap; type Options = { readConfig: ReadConfig, resolveDep: ResolveDep, + // Names to reserve alongside RESERVED_SWIFT_NAMES, supplied by the caller + // (remote mode relabels the RN package) since this module reads no config. + extraReservedNames?: ?ReadonlyArray, + log?: ?Log, }; */ -// Validates and returns the Swift target name for a dep. Falls back to -// toSwiftName(npmName) when no override is set. The override is the dep's -// `react-native.config.js` `spm.name`, intended for libraries whose import -// prefix differs from the auto-derived name (e.g. `react-native-worklets` -// publishes headers under `` via the podspec `s.header_dir`, -// so the SPM target name should be `worklets`, not `ReactNativeWorklets`). +/** + * A misconfiguration rather than a resolution failure: scaffoldAll degrades past + * a transitive dep it cannot find, but must still surface this. + */ +class SpmNameCollisionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'SpmNameCollisionError'; + } +} + +// The charset `spm.name` must satisfy — permissive on purpose, since it has to +// admit header-dir style (lowercase with hyphens) as well as Swift identifiers. +// Shared with the app's own `spm.modules` names. +function isValidSwiftName(name /*: unknown */) /*: boolean */ { + return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name); +} + +function reservedSwiftNames( + extraReservedNames /*: ?ReadonlyArray */, +) /*: ReservedNames */ { + return new Map( + [...RESERVED_SWIFT_NAMES, ...(extraReservedNames ?? [])].map(name => [ + name.toLowerCase(), + name, + ]), + ); +} + +// The scope-borrowed form of a name: `@powersync/react-native`'s `ReactNative` +// becomes `PowersyncReactNative`. +function scopeBorrowedName( + npmName /*: string */, + swiftName /*: string */, +) /*: ?string */ { + const scope = /^@([^/]+)\//.exec(npmName)?.[1]; + return scope == null ? null : `${toSwiftName(scope)}${swiftName}`; +} + +// The Swift target name for one dep, judged in isolation. `spm.name` is for +// libraries whose import prefix differs from the derived name: +// `react-native-worklets` ships headers as `` (podspec +// `s.header_dir`), so its target is `worklets`, not `ReactNativeWorklets`. A +// derived name that lands on a reserved one borrows the npm scope instead. function resolveSwiftName( npmName /*: string */, config /*: ?RnConfig */, + reserved /*: ReservedNames */, + log /*:: ?: ?Log */, ) /*: string */ { // $FlowFixMe[prop-missing] config has dynamic shape const override = config?.spm?.name; - if (override == null) { - return toSwiftName(npmName); + if (override != null) { + if (typeof override !== 'string' || override.length === 0) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, + ); + } + if (!isValidSwiftName(override)) { + throw new Error( + `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + return override; } - if (typeof override !== 'string' || override.length === 0) { - throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override: expected a non-empty string, got ${JSON.stringify(override)}.`, - ); + + const derived = toSwiftName(npmName); + if (!reserved.has(derived.toLowerCase())) { + return derived; } - // Accept Swift-identifier style (TitleCase / snake_case) and header-dir - // style (lowercase, optional hyphens). Reject whitespace, slashes, and - // other characters that would break SPM target / module identifiers. - if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(override)) { - throw new Error( - `react-native autolinking: '${npmName}' has an invalid 'spm.name' override '${override}': must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, - ); + const disambiguated = scopeBorrowedName(npmName, derived); + if (disambiguated == null || reserved.has(disambiguated.toLowerCase())) { + return derived; + } + log?.( + `'${npmName}' would take React Native's reserved name '${derived}', so its npm scope is prepended: '${disambiguated}'. ` + + `Set 'spm.name' in ${npmName}'s react-native.config.js to choose the name yourself.`, + ); + return disambiguated; +} + +function assertNameNotReserved( + swiftName /*: string */, + reserved /*: ReservedNames */, + labels /*: {label: string, remedy: string} */, +) /*: void */ { + const reservedName = reserved.get(swiftName.toLowerCase()); + if (reservedName == null) { + return; + } + // Vaguer about the case clash than the dep-vs-dep message on purpose: this + // set spans package identities and product names, which collide differently. + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: ${labels.label} resolves to '${swiftName}', ` + + (reservedName === swiftName + ? `which React Native reserves for its own SPM package and products.` + : `which differs from React Native's reserved '${reservedName}' only in case — not distinct enough for the build to keep the two apart.`) + + ` ${labels.remedy}`, + ); +} + +/** + * Throws when `swiftName` is one React Native's own manifests use. `remedy` is + * the fix: a library sets `spm.name`, an app renames its `spm.modules` entry. + */ +function assertSwiftNameNotReserved( + swiftName /*: string */, + options /*: { + label: string, + remedy: string, + extraReservedNames?: ?ReadonlyArray, + } */, +) /*: void */ { + const {label, remedy, extraReservedNames} = options; + assertNameNotReserved(swiftName, reservedSwiftNames(extraReservedNames), { + label, + remedy, + }); +} + +// Reserved-name backstop over the resolved set. Unconditional: a plugin-shipping +// library is checked like any other, so `spm scaffold` — which knows nothing +// about plugins — cannot disagree with the autolinker about the same dep. +function assertNoReservedSwiftNames( + deps /*: ReadonlyArray */, + reserved /*: ReservedNames */, +) /*: void */ { + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + assertNameNotReserved(swiftName, reserved, { + label: `'${dep.name}'`, + remedy: `Set a different 'spm.name' in ${dep.name}'s react-native.config.js.`, + }); + } +} + +// Pulls apart deps that resolved to the same name by borrowing their npm scopes. +// Every scoped member of a colliding group moves: there is no non-arbitrary +// winner to keep. Exactly one pass — retrying would trade a diagnosable error +// for a name nobody can predict. +function disambiguateSharedSwiftNames( + deps /*: ReadonlyArray */, + autoNamed /*: ReadonlySet */, + log /*: ?Log */, +) /*: void */ { + const groups /*: Map> */ = + new Map(); + for (const dep of deps) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + const key = swiftName.toLowerCase(); + const group = groups.get(key); + if (group == null) { + groups.set(key, [{dep, swiftName}]); + } else { + group.push({dep, swiftName}); + } + } + + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } + for (const {dep, swiftName} of group) { + // A name we derived can borrow a second time (`AAReactNative`); the + // member whose name we did not derive is the incumbent and keeps it. + if (!autoNamed.has(dep.name)) { + continue; + } + const borrowed = scopeBorrowedName(dep.name, swiftName); + if (borrowed == null) { + continue; + } + const others = group + .filter(other => other.dep !== dep) + .map(other => `'${other.dep.name}'`) + .join(', '); + log?.( + `'${dep.name}' would share the name '${swiftName}' with ${others}, so its npm scope is prepended: '${borrowed}'. ` + + `Set 'spm.name' in ${dep.name}'s react-native.config.js to choose the name yourself.`, + ); + dep.swiftName = borrowed; + } } - return override; } function expandSpmDependencies( directDeps /*: Array */, options /*: Options */, ) /*: Array */ { - const {readConfig, resolveDep} = options; + const {readConfig, resolveDep, extraReservedNames, log} = options; + const reserved = reservedSwiftNames(extraReservedNames); const byName /*: Map */ = new Map(); for (const dep of directDeps) { byName.set(dep.name, {...dep, spmDependencies: []}); } + const autoNamed /*: Set */ = new Set(); + const resolveName = ( + npmName /*: string */, + config /*: ?RnConfig */, + ) /*: string */ => { + // $FlowFixMe[prop-missing] config has dynamic shape + if (config?.spm?.name == null) { + autoNamed.add(npmName); + } + return resolveSwiftName(npmName, config, reserved, log); + }; const queue /*: Array */ = directDeps.map(d => d.name); while (queue.length > 0) { @@ -105,7 +287,7 @@ function expandSpmDependencies( // Resolve swiftName lazily from the same config read we already need for // spm.dependencies — saves a duplicate readConfig call per direct dep. if (current.swiftName == null) { - current.swiftName = resolveSwiftName(currentName, config); + current.swiftName = resolveName(currentName, config); } // $FlowFixMe[prop-missing] config has dynamic shape const transitives /*: Array */ = config?.spm?.dependencies ?? []; @@ -134,7 +316,7 @@ function expandSpmDependencies( name: transitiveName, root: transitiveRoot, platforms: {ios: iosPlatform}, - swiftName: resolveSwiftName(transitiveName, transitiveConfig), + swiftName: resolveName(transitiveName, transitiveConfig), spmDependencies: [], }); queue.push(transitiveName); @@ -144,6 +326,14 @@ function expandSpmDependencies( current.spmDependencies = currentSpmDeps; } + const allDeps /*: Array */ = Array.from(byName.values()); + + disambiguateSharedSwiftNames(allDeps, autoNamed, log); + + // Both checks below validate the FINAL set, after that pass: a borrowed scope + // can land on a reserved name, or on one another dep already holds. + assertNoReservedSwiftNames(allDeps, reserved); + // Collision check: two deps mapping to the same Swift name (whether via // override or auto-derivation) would clobber each other in the synth // package layout and the centralized headers tree. Surface it now with a @@ -154,7 +344,7 @@ function expandSpmDependencies( // passes but the two still collide as directories on the default // case-insensitive macOS filesystem (synth package layout + headers tree). const seen /*: Map */ = new Map(); - for (const dep of byName.values()) { + for (const dep of allDeps) { const swiftName = dep.swiftName; if (swiftName == null) { continue; @@ -163,7 +353,7 @@ function expandSpmDependencies( const existing = seen.get(key); if (existing != null) { const same = existing.swiftName === swiftName; - throw new Error( + throw new SpmNameCollisionError( `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` + (same ? `both resolve to '${swiftName}'.` @@ -174,7 +364,7 @@ function expandSpmDependencies( seen.set(key, {name: dep.name, swiftName}); } - return Array.from(byName.values()); + return allDeps; } // --------------------------------------------------------------------------- @@ -188,8 +378,34 @@ function defaultReadConfig(root /*: string */) /*: ?RnConfig */ { } try { // $FlowFixMe[unsupported-syntax] - return require(configPath); - } catch { + const mod = require(configPath); + // Read both export styles, because the community CLI's two loaders + // disagree with each other: its sync path (`loadConfig`) requires the + // module and sees named exports at top level, its async path + // (`loadConfigAsync`) takes the default export only. Merging covers both, + // with named exports winning — the shape the sync path already resolves. + // Every sibling key of the default export is preserved + // (`dependency.platforms.ios` is read from this result too). + // A function-style config (`module.exports = () => ({...})`) and other + // non-objects pass through untouched — there is no default export to + // unwrap, and nulling them would hide a config that used to be read. + if (mod == null || typeof mod !== 'object') { + return mod; + } + const dflt = mod.default; + if (dflt == null || typeof dflt !== 'object') { + return mod; + } + const {default: _unused, ...named} = mod; + return {...dflt, ...named}; + } catch (e) { + // A config can fail to load for reasons unrelated to SPM (it may import a + // devDependency absent in a consumer install), so this stays a warning — + // but a silent null turns a dropped `spm` block into a link error much + // later. + warn( + `Failed to load ${configPath}: ${e.message}. Any 'spm' settings in it are ignored.`, + ); return null; } } @@ -209,7 +425,10 @@ function defaultResolveDep( } module.exports = { + SpmNameCollisionError, + assertSwiftNameNotReserved, expandSpmDependencies, + isValidSwiftName, resolveSwiftName, defaultReadConfig, defaultResolveDep, diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index d8f14abbb85c..1f1b1b87d213 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -19,6 +19,7 @@ PluginFlavoredFramework, PluginPackageDep, PluginProductDep, + PluginScriptPhase, ReactDescriptor, RawAutolinkingJson, SpmModuleConfig, @@ -58,17 +59,24 @@ const {discoverPlugins, invokePlugins} = require('./autolinking-plugins'); const { + SpmNameCollisionError, + assertSwiftNameNotReserved, defaultReadConfig, defaultResolveDep, expandSpmDependencies, + isValidSwiftName, } = require('./expand-spm-dependencies'); const {readPodspec} = require('./read-podspec'); const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, RemoteVersionError, findProjectRoot, makeLogger, remotePackageConfig, - toSwiftName, } = require('./spm-utils'); const fs = require('fs'); const path = require('path'); @@ -89,7 +97,12 @@ const {log, warn} = makeLogger('generate-spm-autolinking'); let remoteCfg /*: ?{url: string, version: string, identity: string} */ = null; function reactNativePackageLabel() /*: string */ { - return remoteCfg != null ? remoteCfg.identity : 'ReactNative'; + return remoteCfg != null ? remoteCfg.identity : REACT_NATIVE_PACKAGE_NAME; +} +// In remote mode the RN package is labelled with the remote identity, so that +// name is reserved for this run too. +function reservedNamesForRun() /*: ?Array */ { + return remoteCfg != null ? [remoteCfg.identity] : undefined; } function reactNativePackageDecl(localDecl /*: string */) /*: string */ { return remoteCfg != null @@ -104,10 +117,11 @@ function reactNativePackageDecl(localDecl /*: string */) /*: string */ { function reactProducts() /*: Array<{name: string, package: string}> */ { const rn = reactNativePackageLabel(); return [ - {name: 'ReactHeaders', package: rn}, - {name: 'ReactNativeHeaders', package: rn}, - {name: 'ReactNativeDependenciesHeaders', package: rn}, - {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, + ...REACT_NATIVE_PRODUCTS.map(name => ({name, package: rn})), + ...REACT_CODEGEN_PRODUCTS.map(name => ({ + name, + package: REACT_CODEGEN_PACKAGE_NAME, + })), ]; } function reactProductDeps() /*: string */ { @@ -146,7 +160,7 @@ function reactDescriptor( }; } else if (absXcframeworks != null) { packageRef = { - name: 'ReactNative', + name: REACT_NATIVE_PACKAGE_NAME, path: toPosix(absXcframeworks), relPath: xcframeworksRelPath != null ? toPosix(xcframeworksRelPath) : undefined, @@ -155,7 +169,7 @@ function reactDescriptor( return null; } const products = reactProducts().filter( - p => p.package !== 'React-GeneratedCode' || codegenPackageExists, + p => p.package !== REACT_CODEGEN_PACKAGE_NAME || codegenPackageExists, ); return {packageRef, products}; } @@ -231,7 +245,6 @@ function readAutolinkingJson( * name: "MyNativeModule", * path: "ios/MyNativeModule", // relative to appRoot * exclude: ["*.js", "*.podspec"], // optional - * publicHeadersPath: ".", // optional * } * ] * } @@ -254,6 +267,41 @@ function readSpmModulesFromConfig( } } +/** + * Validates one app-local `spm.modules` name against the same rules a library's + * `spm.name` gets: a usable Swift identifier, not a name React Native reserves, + * and not one already taken by another module or an autolinked dep. + * `taken` maps lower-cased name → the name as written. + */ +function assertSpmModuleName( + name /*: unknown */, + taken /*: Map */, +) /*: void */ { + const remedy = + "Rename it in this app's react-native.config.js 'spm.modules'."; + if (typeof name !== 'string' || !isValidSwiftName(name)) { + throw new Error( + `react-native autolinking: invalid 'spm.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + const moduleName = name; + assertSwiftNameNotReserved(moduleName, { + label: `the 'spm.modules' entry '${moduleName}'`, + remedy, + extraReservedNames: reservedNamesForRun(), + }); + const clash = taken.get(moduleName.toLowerCase()); + if (clash != null) { + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` + + (clash === moduleName + ? `is already the name of another autolinked target.` + : `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) + + ` ${remedy}`, + ); + } +} + /** * Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to * skip. The escape hatch for the transitive plugin discovery (an app opts a @@ -730,9 +778,9 @@ function expandSpmSourceGlobs( * Returns null if the dependency doesn't have iOS support. * * `swiftNameByNpm` maps each autolinked dep's npm name to its resolved Swift - * name (populated by expandSpmDependencies, possibly overridden via the dep's - * `spm.name` config). Optional for backwards compatibility with callers that - * don't have the map; falls back to `toSwiftName(name)` per entry. + * name (populated by expandSpmDependencies, honoring the dep's `spm.name` + * config and scope disambiguation). Every name this function emits comes from + * there — see requireSwiftName. */ /** * Read the dep's podspec (if any) and extract its declared @@ -789,11 +837,27 @@ function extractPodspecHeaderSearchPaths( return out; } +/** + * The Swift name expandSpmDependencies resolved for `npmName`, or a hard error: + * re-deriving one here would emit a reference nothing in the graph matches. + */ +function requireSwiftName( + npmName /*: string */, + resolved /*: ?string */, +) /*: string */ { + if (resolved == null) { + throw new Error( + `react-native autolinking: no resolved Swift name for '${npmName}'. expandSpmDependencies must resolve every autolinked dep's name before SPM targets are generated.`, + ); + } + return resolved; +} + function autolinkingDepToSpmTarget( depName /*: string */, dep /*: AutolinkedDep */, outputDir /*: string */, - swiftNameByNpm /*: ?Map */, + swiftNameByNpm /*: Map */, ) /*: SpmTarget | null */ { const iosPlatform = dep.platforms.ios; const sourceDir = iosPlatform.sourceDir ?? dep.root; @@ -806,10 +870,7 @@ function autolinkingDepToSpmTarget( // same convention the spmModule branch in main() follows. const relSourcePath = path.relative(outputDir, sourceDir); - // Prefer the resolved Swift name (which honors `spm.name` overrides set in - // the dep's react-native.config.js). Fall back to toSwiftName(depName) when - // the caller didn't run expandSpmDependencies. - const targetName = dep.swiftName ?? toSwiftName(depName); + const targetName = requireSwiftName(depName, dep.swiftName); // No exclude inference — main()'s emission loop emits `sources:` (an // explicit allowlist). User-supplied excludes still work. @@ -819,13 +880,11 @@ function autolinkingDepToSpmTarget( const resources = privacyManifest != null ? [privacyManifest] : undefined; // Map declared spm.dependencies (npm names) to Swift target names so the - // synth's .product(...) deps list reaches the consuming target. Each - // transitive npm name's Swift name comes from the map (honoring overrides); - // toSwiftName fallback handles entries the map doesn't know about. + // synth's .product(...) deps list reaches the consuming target. const spmDeps /*: Array */ = dep.spmDependencies ?? []; const spmTargetDependencies = spmDeps.length > 0 - ? spmDeps.map(n => swiftNameByNpm?.get(n) ?? toSwiftName(n)) + ? spmDeps.map(n => requireSwiftName(n, swiftNameByNpm.get(n))) : undefined; const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir); @@ -899,12 +958,14 @@ function generateAutolinkedPackageSwift( ) { packageDeps.push( reactNativePackageDecl( - `.package(name: "ReactNative", path: "${xcframeworksRelPath}")`, + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${xcframeworksRelPath}")`, ), ); // Per-app generated headers come from the ReactAppHeaders product in // the codegen package (sibling of the autolinking dir). - packageDeps.push(`.package(name: "React-GeneratedCode", path: "../ios")`); + packageDeps.push( + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "../ios")`, + ); } // AutolinkedAggregate's target dependencies: .product(...) for npm sub-package @@ -1007,10 +1068,10 @@ import PackageDescription import Foundation ${guardBlock}let package = Package( - name: "Autolinked", + name: "${AUTOLINKED_PACKAGE_NAME}", platforms: [.iOS(.v15)], products: [ - .library(name: "Autolinked", targets: ["AutolinkedAggregate"]), + .library(name: "${AUTOLINKED_PACKAGE_NAME}", targets: ["AutolinkedAggregate"]), ], ${packageDepsBlock} targets: [ .target( @@ -1074,13 +1135,13 @@ function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ { spec.codegenPackagePath ?? '../../../ios'; packageDeps.push( reactNativePackageDecl( - `.package(name: "ReactNative", path: "${reactNativePackagePath}")`, + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${reactNativePackagePath}")`, ), ); // Per-app generated headers come from the ReactAppHeaders product in // the codegen package. packageDeps.push( - `.package(name: "React-GeneratedCode", path: "${codegenPackagePath}")`, + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "${codegenPackagePath}")`, ); } for (const dep of spmDependencies) { @@ -1251,11 +1312,13 @@ function main(argv /*:: ?: Array */) /*: void */ { const allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + extraReservedNames: reservedNamesForRun(), + log, }); - // Map every autolinked npm name to its resolved Swift name (post-override) - // so transitive references inside autolinkingDepToSpmTarget find the right - // target identifier — not just the auto-derived toSwiftName. + // Map every autolinked npm name to its resolved Swift name so transitive + // references inside autolinkingDepToSpmTarget find the right target + // identifier. const swiftNameByNpm /*: Map */ = new Map(); for (const dep of allDeps) { if (dep.swiftName != null) { @@ -1287,8 +1350,39 @@ function main(argv /*:: ?: Array */) /*: void */ { discoveredPlugins.map(p => p.depName), ); + // Skipped means no sibling package is created for the host either, so a + // dep declaring it in `spm.dependencies` gets a package reference to a + // path this run never writes — SPM then reports only the missing path. + // Only manifests React Native emits can carry that reference: a dep + // shipping its own Package.swift declares its package references itself, + // and the classification loop below would treat it as self-managed. + const pluginHostDependents /*: Map> */ = new Map(); + for (const dep of allDeps) { + const declaredHosts = (dep.spmDependencies ?? []).filter(name => + pluginHostDeps.has(name), + ); + if (declaredHosts.length === 0) { + continue; + } + const sourceDir = dep.platforms.ios.sourceDir ?? dep.root; + if (sourceDir == null || findSelfManagedPackageDir(sourceDir) != null) { + continue; + } + for (const host of declaredHosts) { + const dependents = pluginHostDependents.get(host) ?? []; + dependents.push(dep.name); + pluginHostDependents.set(host, dependents); + } + } + for (const dep of allDeps) { if (pluginHostDeps.has(dep.name)) { + const dependents = pluginHostDependents.get(dep.name); + if (dependents != null) { + throw new Error( + `react-native autolinking: '${dep.name}' ships an SPM autolinking plugin, which owns its native contribution — so React Native does not build it as a sibling target for anything to depend on. It is declared in 'spm.dependencies' by ${dependents.map(name => `'${name}'`).join(', ')}. Remove it there; nothing is lost. Its plugin links its products into the app and resolves its own ecosystem's dependencies, so a library that builds against it does not declare it here.`, + ); + } log( `Skipping ${dep.name} target generation — provided by its SPM autolinking plugin`, ); @@ -1321,7 +1415,15 @@ function main(argv /*:: ?: Array */) /*: void */ { // the globs now relative to its dir and attach the file list to the target // so the emission loop below renders `sources: [...]` literally. const configModules = readSpmModulesFromConfig(appRoot); + // Module names land in the manifest exactly as written, so they get the same + // checks a dep's Swift name gets. Seeded with the dep target names already + // emitted so a module can't shadow an autolinked library either. + const takenSwiftNames /*: Map */ = new Map( + entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]), + ); for (const mod of configModules) { + assertSpmModuleName(mod.name, takenSwiftNames); + takenSwiftNames.set(mod.name.toLowerCase(), mod.name); const absPath = path.resolve(appRoot, mod.path); const relPath = path.relative(outputDir, absPath); const userSources = @@ -1333,7 +1435,9 @@ function main(argv /*:: ?: Array */) /*: void */ { name: mod.name, path: relPath, exclude: mod.exclude ?? [], - publicHeadersPath: mod.publicHeadersPath ?? null, + // The synth wrapper owns the module's public interface: it declares + // publicHeadersPath: "include", a symlink to the module's header tree. + publicHeadersPath: null, sources: userSources, }, origin: 'spmModule', @@ -1402,8 +1506,9 @@ function main(argv /*:: ?: Array */) /*: void */ { // longer silently synthesize one for them (that duplicated the scaffolder and // hid the gap from the developer and the library author) — collect them and // fail with an actionable message after the classification pass. spmModules - // (app-local, podspec-less, explicitly declared in react-native.config.js) - // keep their synth wrappers: there is nothing to scaffold for them. + // (app-local, explicitly declared in react-native.config.js) keep their synth + // wrappers: an app-local dir has no npm identity, so there is no package for + // the aggregator to reference until one is written for it. const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ = []; @@ -1455,11 +1560,14 @@ function main(argv /*:: ?: Array */) /*: void */ { } continue; } - // spmModule: synth wrapper is the legitimate mechanism (no podspec exists - // to scaffold from, and the app developer declared it explicitly). But a - // mixed-language module can't be wrapped either — SPM can't compile Swift + - // C-family sources in one target, and a synth wrapper would fail with a - // cryptic SPM resolve error. Surface the same friendly diagnostic the + // spmModule: the synth wrapper is the mechanism, not a fallback — an + // app-local dir has no npm identity, so the wrapper is the only package the + // aggregator can reference. No podspec is read on this route by design: + // app-local native code isn't required to carry one. (A hand-written + // Package.swift still wins — the self-managed check above claims it first.) + // But a mixed-language module can't be wrapped either — SPM can't compile + // Swift + C-family sources in one target, and a synth wrapper would fail + // with a cryptic SPM resolve error. Surface the same friendly diagnostic the // community-dep path uses instead of letting SPM emit the cryptic one. if (hasMixedLanguageSources(absSource)) { throw new Error( @@ -1686,6 +1794,7 @@ function main(argv /*:: ?: Array */) /*: void */ { let pluginGeneratedSources /*: Array<{path: string}> */ = []; let pluginFlavoredFrameworks /*: Array */ = []; let pluginWatchPaths /*: Array */ = []; + let pluginScriptPhases /*: Array */ = []; if (discoveredPlugins.length > 0) { // React-GeneratedCode is the per-app codegen package (referenced as // `../ios` from outputDir). It may be absent (no codegen this run), so the @@ -1714,15 +1823,17 @@ function main(argv /*:: ?: Array */) /*: void */ { pluginGeneratedSources = result.generatedSources; pluginFlavoredFrameworks = result.flavoredFrameworks; pluginWatchPaths = result.watchPaths; + pluginScriptPhases = result.scriptPhases; log( `SPM plugins contributed ${pluginPackageDeps.length} package(s), ` + `${pluginProductDeps.length} product(s), ` + `${pluginGeneratedSources.length} generated source(s), ` + - `${pluginFlavoredFrameworks.length} flavored framework(s)`, + `${pluginFlavoredFrameworks.length} flavored framework(s), ` + + `${pluginScriptPhases.length} script phase(s)`, ); } - // Plugin sidecars. Both are ALWAYS written — even `[]` — so removing a + // Plugin sidecars. All are ALWAYS written — even `[]` — so removing a // plugin (or dropping its declaration) clears stale entries. Machine-local // absolute paths; gitignored + regenerated every sync. fs.mkdirSync(outputDir, {recursive: true}); @@ -1741,6 +1852,11 @@ function main(argv /*:: ?: Array */) /*: void */ { JSON.stringify(pluginFlavoredFrameworks, null, 2) + '\n', 'utf8', ); + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-script-phases.json'), + JSON.stringify(pluginScriptPhases, null, 2) + '\n', + 'utf8', + ); // Top-level aggregator: references every entry as .package(path:) and // depends on each via .product(...). No more inline targets — every @@ -1873,6 +1989,7 @@ if (require.main === module) { module.exports = { main, + autolinkingDepToSpmTarget, generateAutolinkedPackageSwift, generateSynthPackageSwift, reactDescriptor, diff --git a/packages/react-native/scripts/spm/generate-spm-package.js b/packages/react-native/scripts/spm/generate-spm-package.js index 813359cf0d46..83594d80b749 100644 --- a/packages/react-native/scripts/spm/generate-spm-package.js +++ b/packages/react-native/scripts/spm/generate-spm-package.js @@ -37,6 +37,12 @@ const {prepareFlavoredFrameworks} = require('./flavored-frameworks'); const { + REACT_HEADERS_TARGET_DIR, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, deriveAppName, displayPath, findProjectRoot, @@ -166,32 +172,38 @@ function findSourcePath( * Package.swift also imports it as a named package dependency. */ function generateXCFrameworksPackageSwift() /*: string */ { + // Each product's target follows from its KIND, not from its position in the + // list: the umbrella is a Clang target over the staged headers, and every + // xcframework-backed product gets a binaryTarget of the same name. + const products = REACT_NATIVE_PRODUCTS.map( + product => ` .library(name: "${product}", targets: ["${product}"]),`, + ); + const targets = [ + ` .target( + name: "${REACT_NATIVE_UMBRELLA_PRODUCT}", + dependencies: ["${REACT_NATIVE_HEADERS_PRODUCT}"], + path: "${REACT_HEADERS_TARGET_DIR}", + publicHeadersPath: "include" + ),`, + ...REACT_NATIVE_XCFRAMEWORK_PRODUCTS.map( + product => ` .binaryTarget( + name: "${product}", + path: "${product}.xcframework" + ),`, + ), + ]; + return `// swift-tools-version: 6.0 // AUTO-GENERATED by scripts/generate-spm-package.js – do not edit manually. import PackageDescription let package = Package( - name: "ReactNative", + name: "${REACT_NATIVE_PACKAGE_NAME}", products: [ - .library(name: "ReactHeaders", targets: ["ReactHeaders"]), - .library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"]), - .library(name: "ReactNativeDependenciesHeaders", targets: ["ReactNativeDependenciesHeaders"]), +${products.join('\n')} ], targets: [ - .target( - name: "ReactHeaders", - dependencies: ["ReactNativeHeaders"], - path: "ReactHeadersTarget", - publicHeadersPath: "include" - ), - .binaryTarget( - name: "ReactNativeHeaders", - path: "ReactNativeHeaders.xcframework" - ), - .binaryTarget( - name: "ReactNativeDependenciesHeaders", - path: "ReactNativeDependenciesHeaders.xcframework" - ), +${targets.join('\n')} ] ) `; diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 1cbe06304b8a..fe4687a22101 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -26,6 +26,7 @@ const {parseConfigCommandJson} = require('./generate-spm-autolinking-config'); const { addArrayMembers, addArrayStringValues, + commentSafe, ensureScalarField, findApplicationTargets, findField, @@ -42,13 +43,25 @@ const { removeObjectByUuid, serializeEntry, setScalarField, + uuidComment, } = require('./spm-pbxproj'); -const {makeLogger, remotePackageConfig} = require('./spm-utils'); +const { + AUTOLINKED_PACKAGE_NAME, + REACT_CODEGEN_APP_PRODUCTS, + REACT_CODEGEN_PACKAGE_NAME, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, + isValidScriptPhaseId, + isValidScriptPhaseName, + makeLogger, + remotePackageConfig, +} = require('./spm-utils'); const fs = require('fs'); const path = require('path'); /*:: import type { FlavoredFrameworkManifestEntry, + PluginScriptPhase, XcframeworkSlice, } from './spm-types'; */ @@ -73,6 +86,17 @@ const SPM_GENERATED_SOURCES_MANIFEST = path.join( '.spm-plugin-generated-sources.json', ); +// Manifest of plugin-contributed build-time shell phases for the app target — +// SwiftPM has no `script_phase`, so a framework that must generate content into +// the app bundle (expo-constants' `EXConstants.bundle/app.config`) declares one +// through the plugin contract. +const SPM_SCRIPT_PHASES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-script-phases.json', +); + // The single navigator group all injected generated sources are parented under // (created on first use). Its namespacedUUID id + display name. const SPM_GENERATED_SOURCES_GROUP_ID = 'SPMGeneratedSources'; @@ -92,36 +116,21 @@ const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = { // resolve the product dependencies — SPM doesn't expose transitive products. const SPM_PRODUCT_PACKAGES /*: Array<{product: string, packagePath: string, packageName: string}> */ = [ - { - product: 'ReactHeaders', - packagePath: 'build/xcframeworks', - packageName: 'ReactNative', - }, - { - product: 'ReactNativeHeaders', - packagePath: 'build/xcframeworks', - packageName: 'ReactNative', - }, - { - product: 'ReactNativeDependenciesHeaders', + ...REACT_NATIVE_PRODUCTS.map(product => ({ + product, packagePath: 'build/xcframeworks', - packageName: 'ReactNative', - }, + packageName: REACT_NATIVE_PACKAGE_NAME, + })), { - product: 'Autolinked', + product: AUTOLINKED_PACKAGE_NAME, packagePath: 'build/generated/autolinking', - packageName: 'Autolinked', + packageName: AUTOLINKED_PACKAGE_NAME, }, - { - product: 'ReactCodegen', - packagePath: 'build/generated/ios', - packageName: 'React-GeneratedCode', - }, - { - product: 'ReactAppDependencyProvider', + ...REACT_CODEGEN_APP_PRODUCTS.map(product => ({ + product, packagePath: 'build/generated/ios', - packageName: 'React-GeneratedCode', - }, + packageName: REACT_CODEGEN_PACKAGE_NAME, + })), ]; /*:: @@ -137,7 +146,15 @@ type BuildSettingChange = { // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once // CocoaPods is deintegrated. Deinit restores the original. replacedScalars?: {[string]: string}, + // Array settings that existed as a SCALAR and were promoted to a `( … )` + // array (key → the pre-injection raw value text, quotes included). Deinit + // restores that value verbatim; removing the injected members would leave + // the promoted array and its `"$(inherited)"` seed behind. + promotedArrayScalars?: {[string]: string}, }; +// An array field injection CREATED (rather than appended to a pre-existing +// one), so deinit removes the whole field and lands byte-identical. +type CreatedArrayField = {container: 'project' | 'target', key: string}; // A plugin-contributed source, normalized for pbxproj emission. `path` is // SRCROOT-relative when under the app root, else absolute; `sourceTree` is the // matching pbxproj token ('SOURCE_ROOT' or '""'). @@ -265,30 +282,42 @@ function spmGraphToEntries( return {localRefs, remoteRef, productDeps, buildFiles}; } -// Sync SPM Autolinking: timestamp check + conditional node re-run. Shared by -// the build phase (safety net) and the scheme pre-action (the one that -// actually fires before SPM resolution, so a single build picks up -// dep-graph changes from `npm install`). -// Build a PBXShellScriptBuildPhase entry (the "Sync SPM Autolinking" phase). +/** + * Build a PBXShellScriptBuildPhase entry. `inputPaths`/`outputPaths` accept + * either a plain path array or an already-serialized pbxproj list. + * `alwaysOutOfDate` emits Xcode's own `alwaysOutOfDate = 1;` (unquoted, right + * after `isa`, so a project Xcode rewrites stays diff-free) and is omitted + * entirely when false. `comment` overrides the cosmetic `/* … *​/` label, which + * otherwise derives from `name`. + */ function shellScriptPhase( phaseUUID /*: string */, name /*: string */, script /*: string */, - options /*: {inputPaths?: string, outputPaths?: string} */ = {}, + options /*: {inputPaths?: ?(string | ReadonlyArray), outputPaths?: ?(string | ReadonlyArray), alwaysOutOfDate?: ?boolean, comment?: string} */ = {}, ) /*: {uuid: string, comment: string, fields: {[string]: string}} */ { const empty = '(\n\t\t\t)'; + const pathList = ( + value /*: ?(string | ReadonlyArray) */, + ) /*: string */ => { + if (value == null) { + return empty; + } + return typeof value === 'string' ? value : pbxPathList(value); + }; return { uuid: phaseUUID, - comment: name, + comment: options.comment ?? name, fields: { isa: 'PBXShellScriptBuildPhase', + ...(options.alwaysOutOfDate === true ? {alwaysOutOfDate: '1'} : {}), buildActionMask: '2147483647', files: empty, inputFileListPaths: empty, - inputPaths: options.inputPaths ?? empty, + inputPaths: pathList(options.inputPaths), name: quoteIfNeeded(name), outputFileListPaths: empty, - outputPaths: options.outputPaths ?? empty, + outputPaths: pathList(options.outputPaths), runOnlyForDeploymentPostprocessing: '0', shellPath: '/bin/sh', shellScript: quoteIfNeeded(script), @@ -468,11 +497,126 @@ ${copies} `; } +/** + * The app target's `buildPhases` members, in the order the file lists them. + * Line-leading UUIDs only: a member's trailing comment carries a plugin-supplied + * phase name, and one that happens to look like a UUID would otherwise read as an + * extra member — leaving the actual order permanently at odds with the declared + * one, and offering a non-member as a re-seating anchor. + */ +function buildPhaseOrder( + text /*: string */, + target /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: Array */ { + const field = findField(text, target, 'buildPhases'); + if (field == null) { + return []; + } + return [...field.value.matchAll(/^[\t ]*([0-9A-Fa-f]{24})\b/gm)].map( + m => m[1], + ); +} + +/** + * Rewrite the trailing comment Xcode keeps beside a UUID — on the object's own + * definition line and on every array-member line referencing it. Xcode + * normalizes those comments on its next write, so leaving a stale one behind + * (after a plugin renames a phase) plants a spurious diff in the user's repo. + * No-op when the comment already reads `comment`. + */ +function setUuidComment( + text /*: string */, + uuid /*: string */, + comment /*: string */, +) /*: string */ { + return text.replace( + new RegExp(`(\\n[\\t ]*${uuid})(?: /\\* [^\\n]*? \\*/)?( = \\{|,)`, 'g'), + (_match, head, tail) => `${head}${uuidComment(comment)}${tail}`, + ); +} + +/*:: type SeatedPhase = {uuid: string, comment: string, position: 'beforeCompile' | 'end'}; */ + +/** + * Seat the plugin phases in the order the manifest declares: `beforeCompile` + * ones directly after the Sync SPM Autolinking phase (which stays first — it + * regenerates the content everything else reads) and always before Sources, + * `end` ones at the true end of `buildPhases`, after the app's own JS-bundle + * phase. The other members keep their relative order. + * + * `beforeCompile` is anchored on Sources, not merely on the sync phase: RN never + * re-seats its own sync phase, so a user who drags it below Sources would + * otherwise have every `beforeCompile` phase seated after compilation — the one + * thing the position promises not to do. Without a Sources phase the sync phase + * is the anchor. + * + * Rewrites the membership lines ONLY when the actual order differs from that, + * which is what keeps an unchanged sync byte-identical — `addBuildPhaseAfter` + * and `addArrayMembers` both short-circuit on a UUID that is already a member, + * so re-placing has to be driven from here. A phase a user dragged elsewhere in + * Xcode is therefore moved back: the declared position wins. + */ +function seatScriptPhases( + input /*: string */, + targetUuid /*: string */, + syncPhaseUuid /*: string */, + phases /*: ReadonlyArray */, + sourcesPhaseUuid /*: ?string */, +) /*: string */ { + const pluginUuids = new Set(phases.map(p => p.uuid)); + const actual = buildPhaseOrder( + input, + findApplicationTargetByUuid(input, targetUuid), + ); + const others = actual.filter(uuid => !pluginUuids.has(uuid)); + const beforeCompile = phases.filter(p => p.position === 'beforeCompile'); + const atEnd = phases.filter(p => p.position !== 'beforeCompile'); + const sourcesAt = + sourcesPhaseUuid != null ? others.indexOf(sourcesPhaseUuid) : -1; + const afterSyncAt = others.indexOf(syncPhaseUuid) + 1; + // 0 — no sync phase member, or Sources ahead of it — makes the beforeCompile + // phases lead the array, matching addArrayMembers' prepend fallback below. + const insertAt = + sourcesAt >= 0 ? Math.min(afterSyncAt, sourcesAt) : afterSyncAt; + const desired = [ + ...others.slice(0, insertAt), + ...beforeCompile.map(p => p.uuid), + ...others.slice(insertAt), + ...atEnd.map(p => p.uuid), + ]; + if ( + desired.length === actual.length && + desired.every((uuid, i) => uuid === actual[i]) + ) { + return input; + } + // File-wide removal is safe for a shell-phase UUID: it appears on its own + // definition line (which ends `= {`, never matched) and on `buildPhases` + // member lines. The uncovered case is a user who copied the same phase object + // into a SECOND target's buildPhases — it is stripped there too. + let text = removeArrayMembersByUuid(input, [...pluginUuids]); + const target = () => findApplicationTargetByUuid(text, targetUuid); + let anchor = insertAt > 0 ? others[insertAt - 1] : null; + for (const member of beforeCompile) { + text = + anchor == null + ? addArrayMembers(text, target(), 'buildPhases', [member], { + prepend: true, + }) + : addBuildPhaseAfter(text, target(), anchor, member); + anchor = member.uuid; + } + for (const member of atEnd) { + text = addArrayMembers(text, target(), 'buildPhases', [member]); + } + return text; +} + function addBuildPhaseAfter( text /*: string */, target /*: {bodyOpen: number, bodyClose: number, ...} */, afterUuid /*: string */, - member /*: {uuid: string, comment: string} */, + member /*: {uuid: string, comment: string, ...} */, ) /*: string */ { const field = findField(text, target, 'buildPhases'); if (field == null || field.value.includes(member.uuid)) { @@ -487,7 +631,7 @@ function addBuildPhaseAfter( const absoluteStart = field.valueStart + after.index; const lineEnd = text.indexOf('\n', absoluteStart + after[0].length); const indent = after[2]; - const line = `\n${indent}${member.uuid} /* ${member.comment} */,`; + const line = `\n${indent}${member.uuid}${uuidComment(member.comment)},`; return text.slice(0, lineEnd) + line + text.slice(lineEnd); } @@ -724,6 +868,18 @@ function escapeXmlAttribute(s /*: string */) /*: string */ { .replace(/'/g, '''); } +// The inverse of escapeXmlAttribute. `&` is expanded LAST so an entity that +// was itself escaped (`<` → `&lt;`) round-trips back to its own text +// rather than to `<`. +function unescapeXmlAttribute(s /*: string */) /*: string */ { + return s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + function generateXcscheme( appName /*: string */, targetUUID /*: string */, @@ -932,6 +1088,25 @@ const INJECTED_ARRAY_SETTINGS = [ }, ]; +// Array build settings injected only into debug-flavored configurations. +// +// Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by +// GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app +// template does not commit the setting: CocoaPods injects it at `pod install` +// time (react_native_post_install → set_build_setting +// SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug). +// An SPM app never runs CocoaPods, so without this `#if DEBUG` is false even +// in a Debug build — AppDelegate.swift's `bundleURL()` skips the Metro URL, +// falls back to a main.jsbundle that a Debug build never produced, and the app +// dies at launch with "No script url provided … unsanitizedScriptURLString = +// (null)" while Metro is running right there. +// +// Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that +// links the debug xcframeworks also compiles its Swift with DEBUG. +const DEBUG_ARRAY_SETTINGS = [ + {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, +]; + /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ function targetBuildConfigUuids( text /*: string */, @@ -1078,7 +1253,8 @@ function injectSpmIntoPbxproj( hermesCliPath /*: ?string */ = null, generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], -) /*: {text: string, injectedUuids: Array, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}} */ { + scriptPhases /*: ReadonlyArray */ = [], +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { let text = input; const mkUuid = (section /*: string */, id /*: string */) => namespacedUUID(plan.rootUuid, section, id); @@ -1111,10 +1287,7 @@ function injectSpmIntoPbxproj( insertObjects('XCSwiftPackageProductDependency', entries.productDeps); insertObjects('PBXBuildFile', entries.buildFiles); - // Track array fields we CREATE (vs. append to a pre-existing one) so deinit - // can remove the whole field and land byte-identical to the original. - const createdArrayFields /*: Array<{container: 'project' | 'target', key: string}> */ = - []; + const createdArrayFields /*: Array */ = []; // 2. packageReferences on the PBXProject. const pkgRefMembers = [ @@ -1305,9 +1478,14 @@ function injectSpmIntoPbxproj( const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`); const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`); generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid]; + // The name/path VALUES stay verbatim (escaped) — only the cosmetic + // comments are normalized, falling back to the normalized path (the + // ledger key) and then to no comment at all, which is well-formed. + const label = commentSafe(src.name) || commentSafe(src.path); + const inSources = label === '' ? '' : `${label} in Sources`; fileRefs.push({ uuid: fileRefUuid, - comment: src.name, + comment: label, fields: { isa: 'PBXFileReference', lastKnownFileType: src.fileType, @@ -1318,17 +1496,14 @@ function injectSpmIntoPbxproj( }); buildFiles.push({ uuid: buildFileUuid, - comment: `${src.name} in Sources`, + comment: inSources, fields: { isa: 'PBXBuildFile', - fileRef: `${fileRefUuid} /* ${src.name} */`, + fileRef: `${fileRefUuid}${uuidComment(label)}`, }, }); - sourcesMembers.push({ - uuid: buildFileUuid, - comment: `${src.name} in Sources`, - }); - groupChildren.push({uuid: fileRefUuid, comment: src.name}); + sourcesMembers.push({uuid: buildFileUuid, comment: inSources}); + groupChildren.push({uuid: fileRefUuid, comment: label}); } insertObjects('PBXFileReference', fileRefs); insertObjects('PBXBuildFile', buildFiles); @@ -1388,12 +1563,87 @@ function injectSpmIntoPbxproj( } } + // 9. Plugin-declared build phases (SwiftPM has no `script_phase`). Each + // phase's UUID is keyed on its plugin id, so re-runs refresh in place and + // `deinit` reverses it. + const scriptPhaseUuids /*: {[string]: string} */ = {}; + if (scriptPhases.length > 0) { + const seated /*: Array */ = []; + for (const scriptPhase of scriptPhases) { + const uuid = mkUuid( + 'PBXShellScriptBuildPhase', + `plugin:${scriptPhase.id}`, + ); + scriptPhaseUuids[scriptPhase.id] = uuid; + // The full name goes into the `name` field (escaped) — that is what Xcode + // displays. The cosmetic comments get it normalized, falling back to the + // normalized id and then to no comment at all, which is well-formed. The + // fallback is sanitized rather than trusted to the id charset, so widening + // that charset can never reach a comment. + const comment = + commentSafe(scriptPhase.name) || commentSafe(scriptPhase.id); + const entry = shellScriptPhase( + uuid, + scriptPhase.name, + scriptPhase.script, + { + inputPaths: scriptPhase.inputPaths, + outputPaths: scriptPhase.outputPaths, + alwaysOutOfDate: scriptPhase.alwaysOutOfDate, + comment, + }, + ); + if (!text.includes(uuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry(entry), + ); + } else { + // Rewrite the fields we own unconditionally — byte-identical when the + // plugin's declaration hasn't changed, so no content hashing is needed. + // Each write shifts offsets, hence the re-lookup per field. + for (const key of [ + 'name', + 'shellScript', + 'inputPaths', + 'outputPaths', + ]) { + const current = findObjectByUuid(text, uuid); + if (current != null) { + text = setScalarField(text, current, key, entry.fields[key]); + } + } + // Flipping alwaysOutOfDate back off means REMOVING the field — + // setScalarField would only ever write a value. + const current = findObjectByUuid(text, uuid); + if (current != null) { + text = + scriptPhase.alwaysOutOfDate === true + ? setScalarField(text, current, 'alwaysOutOfDate', '1') + : removeField(text, current, 'alwaysOutOfDate'); + } + } + injectedUuids.push(uuid); + text = setUuidComment(text, uuid, comment); + seated.push({uuid, comment, position: scriptPhase.position}); + } + text = seatScriptPhases( + text, + plan.targetUuid, + syncPhaseUuid, + seated, + sourcesPhaseUuid, + ); + } + return { text, injectedUuids, createdArrayFields, buildSettingChanges, generatedSourceUuids, + scriptPhaseUuids, }; } @@ -1451,6 +1701,28 @@ function resolveHermesCliPathSetting( } } +/** Strip the surrounding plist quotes from a build-setting token, if any. */ +function unquotePlist(s /*: string */) /*: string */ { + return s.replace(/^"/, '').replace(/"$/, ''); +} + +/** + * The individual values a build setting already carries, unquoted — for both + * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting + * (`("$(inherited)", DEBUG)`) and the scalar form the app template and + * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the + * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently + * skip the injection. + */ +function buildSettingValueTokens(value /*: string */) /*: Set */ { + return new Set( + value + .split(/[\s,()]+/) + .filter(Boolean) + .map(unquotePlist), + ); +} + function mergeReactBuildSettings( input /*: string */, configUuid /*: string */, @@ -1493,9 +1765,13 @@ function mergeReactBuildSettings( }; const createdArrayKeys /*: Array */ = []; const appendedArrayValues /*: {[string]: Array} */ = {}; + const promotedArrayScalars /*: {[string]: string} */ = {}; const createdScalars /*: Array */ = []; const arraySettings = [ ...INJECTED_ARRAY_SETTINGS, + ...(flavorForBuildConfiguration(configurationName) === 'debug' + ? DEBUG_ARRAY_SETTINGS + : []), ...frameworkArrayBuildSettings(flavoredFrameworks), ]; for (const {key, values} of arraySettings) { @@ -1504,15 +1780,41 @@ function mergeReactBuildSettings( continue; } const existing = findField(text, d, key); + // Non-null only for a scalar addArrayStringValues would promote to an array + // (same array-vs-scalar test it uses). Kept RAW: findField's token for a + // bare scalar ends at the `;`, so it carries any whitespace before it, and + // deinit has to write those bytes back verbatim. + const priorScalar = + existing != null && !existing.value.trimStart().startsWith('(') + ? existing.value + : null; if (existing == null) { createdArrayKeys.push(key); } else { - const fresh = values.filter(v => !existing.value.includes(v)); - if (fresh.length > 0) { + const present = buildSettingValueTokens(existing.value); + const fresh = values.filter(v => !present.has(unquotePlist(v))); + if (fresh.length === 0) { + // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by + // EXACT array member, so a value the user carries in the scalar form + // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would + // otherwise be promoted to an array and re-appended — an edit `deinit` + // has no record of and so could never reverse. + continue; + } + if (priorScalar == null) { appendedArrayValues[key] = fresh; } } + const beforeAdd = text; text = addArrayStringValues(text, d, key, values); + // Record only a promotion that actually happened: addArrayStringValues + // no-ops when `values` is empty or every value is already a member, and a + // recorded-but-untouched field would have deinit clobber whatever the user + // has there by then. Restoring the scalar subsumes removing the injected + // members, so the two records stay mutually exclusive per key. + if (priorScalar != null && text !== beforeAdd) { + promotedArrayScalars[key] = priorScalar; + } } const replacedScalars /*: {[string]: string} */ = {}; for (const {key, value} of scalars) { @@ -1571,6 +1873,9 @@ function mergeReactBuildSettings( appendedArrayValues, createdScalars, replacedScalars, + ...(Object.keys(promotedArrayScalars).length > 0 + ? {promotedArrayScalars} + : {}), }, }; } @@ -1838,6 +2143,86 @@ function readGeneratedSourcesManifest( return out; } +const nonEmptyStrings = (value /*: unknown */) /*: ?Array */ => + Array.isArray(value) + ? value.filter(p => typeof p === 'string' && p.length > 0) + : null; + +/** + * Read the plugin script-phases manifest at + * `/build/generated/autolinking/.spm-plugin-script-phases.json`. + * Absent, unparseable, or malformed → `[]`. Lenient by design even though the + * plugin contract validates these entries fatally at invoke time: the sidecar + * legitimately does not exist yet on a first `spm add`, and a stale or + * hand-edited file must not break injection. This reader is the ONLY gate on + * such a file, so it applies the same id/name rules (from spm-utils) that + * invokePlugins enforces fatally. + */ +function readScriptPhasesManifest( + appRoot /*: string */, +) /*: Array */ { + const manifestPath = path.join(appRoot, SPM_SCRIPT_PHASES_MANIFEST); + let raw /*: string */ = ''; + try { + raw = fs.readFileSync(manifestPath, 'utf8'); + } catch { + return []; + } + let entries /*: unknown */ = null; + try { + entries = JSON.parse(raw); + } catch { + log( + `warning: could not parse ${SPM_SCRIPT_PHASES_MANIFEST}; ` + + 'skipping script phases.', + ); + return []; + } + if (!Array.isArray(entries)) { + return []; + } + const out /*: Array */ = []; + for (const entry of entries) { + if ( + entry == null || + typeof entry !== 'object' || + !isValidScriptPhaseId(entry.id) || + !isValidScriptPhaseName(entry.name) || + typeof entry.script !== 'string' || + entry.script.length === 0 || + // An unknown position is a malformed entry, not something to coerce: it + // would silently run somewhere the plugin didn't ask for. + (entry.position != null && + entry.position !== 'beforeCompile' && + entry.position !== 'end') || + // Dedupe by id — the id seeds the phase's UUID, so a duplicate would + // insert two objects with identical UUIDs. + out.some(phase => phase.id === entry.id) + ) { + continue; + } + const phase /*: PluginScriptPhase */ = { + id: entry.id, + name: entry.name, + script: entry.script, + position: entry.position ?? 'end', + }; + const inputPaths = nonEmptyStrings(entry.inputPaths); + const outputPaths = nonEmptyStrings(entry.outputPaths); + if (inputPaths != null) { + phase.inputPaths = inputPaths; + } + if (outputPaths != null) { + phase.outputPaths = outputPaths; + } + if (typeof entry.alwaysOutOfDate === 'boolean') { + phase.alwaysOutOfDate = entry.alwaysOutOfDate; + } + out.push(phase); + } + return out; +} + /** * Read the `.spm-injected.json` marker of a previously-injected project, or * null when absent/unreadable. Used to reconcile generated sources on `update` @@ -1845,7 +2230,7 @@ function readGeneratedSourcesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, ...} */ { +) /*: ?{generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -1886,13 +2271,11 @@ function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { * update --version` pinned into the injected xcodeproj's `.spm-injected.json` * marker (see the field's doc comment in injectSpmIntoExistingXcodeproj * below), or null when no project is injected yet, no override is pinned, or - * the marker can't be read (never throws). Pure fs reads. - * - * Nothing in production calls this yet: the pin is written but never read - * back, so the build-time sync (sync-spm-autolinking.js) still derives the - * version from node_modules/react-native/package.json and can heal against a - * different artifact slot than the explicit `--version` selected. Only the - * tests cover it. + * the marker can't be read (never throws). Pure fs reads — setup-apple-spm.js's + * determineVersion prefers the pinned version over the one derived from + * node_modules/react-native/package.json, so a later flagless `add`/`update` + * (and `download`) stays on the SAME artifact slot the explicit `--version` + * selected. */ function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { const xcodeprojPath = findInjectedXcodeproj(appRoot); @@ -1929,6 +2312,39 @@ function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array */ { } } +/** + * Union the array fields RN has created across syncs, deduped by container+key. + * + * THE CANONICAL STATEMENT of why anything RN created is recorded stickily (the + * marker's `scheme.created` carries it forward for the same reason): a re-sync + * re-injects from a baseline with only the BUILD SETTINGS reversed, so it finds + * whatever the first run created already there and reports creating nothing. Once + * RN has created something, that fact has to be carried forward, or the new + * marker forgets it and `deinit` leaves an empty `packageReferences` / + * `packageProductDependencies` behind and the generated scheme on disk. + * + * The record licenses removal; it does not order it. `deinit` still checks that + * what it is about to remove is RN's (see its step 1 and isGeneratedScheme), so + * carrying forward a field the user has since taken over — or deleted by hand — + * is safe. + */ +function mergeCreatedArrayFields( + previous /*: ReadonlyArray */, + current /*: ReadonlyArray */, +) /*: Array */ { + const merged = [...previous]; + for (const field of current) { + if ( + !merged.some( + seen => seen.container === field.container && seen.key === field.key, + ) + ) { + merged.push(field); + } + } + return merged; +} + /** * Add SPM packages to a user's EXISTING xcodeproj in place. Returns * {status: 'injected', target} on success, or {status: 'refused', reason} @@ -1954,6 +2370,7 @@ function injectSpmIntoExistingXcodeproj( const remote = remotePackageConfig(appRoot); const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot); const generatedSources = readGeneratedSourcesManifest(appRoot); + const scriptPhases = readScriptPhasesManifest(appRoot); const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; const prevMarker = readMarker(xcodeprojPath); @@ -1983,6 +2400,15 @@ function injectSpmIntoExistingXcodeproj( namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID), ); } + // Same reconciliation for script phases, keyed on the plugin-supplied id. + const prevScriptPhases /*: {[string]: string} */ = + prevMarker?.scriptPhases ?? {}; + const currentPhaseIds = new Set(scriptPhases.map(p => p.id)); + for (const id of Object.keys(prevScriptPhases)) { + if (!currentPhaseIds.has(id)) { + staleUuids.push(prevScriptPhases[id]); + } + } // Re-apply generated settings from a clean recorded baseline. This removes // linker entries for plugin frameworks that disappeared and keeps the new // marker a complete inverse after an idempotent update. @@ -2003,6 +2429,7 @@ function injectSpmIntoExistingXcodeproj( createdArrayFields, buildSettingChanges, generatedSourceUuids, + scriptPhaseUuids, } = injectSpmIntoPbxproj( base, { @@ -2017,6 +2444,7 @@ function injectSpmIntoExistingXcodeproj( hermesCliPath, generatedSources, flavoredFrameworks, + scriptPhases, ); const changed = writeIfChanged(pbxprojPath, text); @@ -2044,9 +2472,9 @@ function injectSpmIntoExistingXcodeproj( // intentional pin, not something to silently re-derive from // node_modules/react-native/package.json. There is no "clear" verb yet; // `deinit` (removeSpmInjection) drops the whole marker, including this - // field. Read back by readArtifactsVersionOverride (above) so the - // build-time sync (sync-spm-autolinking.js) heals against the SAME slot - // `add`/`update` selected, even on a version-mismatched setup. + // field. Read back by readArtifactsVersionOverride (above) so a later + // flagless `add`/`update`/`download` resolves to the SAME slot, even on a + // version-mismatched setup. const artifactsVersionOverride = opts.artifactsVersionOverride ?? prevMarker?.artifactsVersionOverride ?? @@ -2072,16 +2500,26 @@ function injectSpmIntoExistingXcodeproj( target: plan.target.name, targetUuid: plan.target.uuid, injectedUuids: Array.from(new Set(injectedUuids)).sort(), - createdArrayFields, + createdArrayFields: mergeCreatedArrayFields( + prevMarker?.createdArrayFields ?? [], + createdArrayFields, + ), buildSettingChanges, // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next // `update` to reconcile away entries that left the manifest. generatedSources: generatedSourceUuids, + // Plugin phase id → its PBXShellScriptBuildPhase UUID, reconciled the + // same way. + scriptPhases: scriptPhaseUuids, artifactsVersionOverride, configCommand, scheme: { file: schemeResult.file, - created: schemeResult.status === 'created', + // Sticky — see mergeCreatedArrayFields for why a later sync cannot + // observe this for itself. + created: + schemeResult.status === 'created' || + prevMarker?.scheme?.created === true, }, }, null, @@ -2093,6 +2531,52 @@ function injectSpmIntoExistingXcodeproj( return {status: 'injected', target: plan.target.name}; } +/** The sync pre-action's script, unescaped, or null when the scheme has none. */ +function schemePreActionScript(xml /*: string */) /*: ?string */ { + const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); + if (titleIdx < 0) { + return null; + } + const marker = 'scriptText = "'; + const start = xml.indexOf(marker, titleIdx); + if (start < 0) { + return null; + } + const valueStart = start + marker.length; + // escapeXmlAttribute maps a literal `"` to `"`, so the next `"` is always + // the closing delimiter. + const valueEnd = xml.indexOf('"', valueStart); + return valueEnd < 0 + ? null + : unescapeXmlAttribute(xml.slice(valueStart, valueEnd)); +} + +/** + * Whether `xml` is still, byte for byte, the scheme RN generates for this target + * — everything but the pre-action's script, which RN rewrites in place on every + * sync and which varies with the app's react-native path, so it cannot be part of + * an ownership test. + * + * `deinit` deletes a scheme the marker says RN created only while this holds. A + * user may replace a generated scheme with one of their own under the same name + * (same target, so `injectOrCreateScheme` finds and updates it, and the created + * record stays), and destroying that is unrecoverable where leaving a scheme + * behind is not — so the harmless way of being wrong wins: a generated scheme the + * user has since edited leaks, minus its pre-action. + */ +function isGeneratedScheme( + xml /*: string */, + appName /*: string */, + targetUuid /*: string */, + projName /*: string */, +) /*: boolean */ { + const script = schemePreActionScript(xml); + return ( + script != null && + xml === generateXcscheme(appName, targetUuid, projName, script) + ); +} + /** * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added * to a scheme, and drop the `` wrapper if it is left empty (the @@ -2138,6 +2622,25 @@ function removeRecordedBuildSettings( ); } } + const promotedArrayScalars /*: {[string]: string} */ = + change.promotedArrayScalars ?? {}; + for (const key of Object.keys(promotedArrayScalars)) { + const current = dict(); + const originalValue = promotedArrayScalars[key]; + // A field that is gone was deleted by the user after injection; restoring + // it would resurrect it, at the top of the dict, matching neither state. + if ( + current != null && + typeof originalValue === 'string' && + findField(text, current, key) != null + ) { + // Rewriting the whole value is what makes the promotion reversible at + // all — its members and its `"$(inherited)"` seed are indistinguishable + // from the user's own once folded together. The tradeoff: members the + // user hand-added to the promoted array afterwards are discarded. + text = setScalarField(text, current, key, originalValue); + } + } for (const key of change.createdArrayKeys ?? []) { const current = dict(); if (current != null) { @@ -2199,7 +2702,15 @@ function removeSpmInjection( f.container === 'project' ? findProjectObject(text) : findObjectByUuid(text, marker.targetUuid); - if (obj != null) { + if (obj == null) { + continue; + } + // The record says the field did not exist before RN created it, which is + // necessary but not sufficient: anything still in it after our own members + // are gone is the user's (their own SPM package, added to the same field), + // and dropping the field would orphan it. + const field = findField(text, obj, f.key); + if (field != null && /^\(\s*\)$/.test(field.value)) { text = removeField(text, obj, f.key); } } @@ -2219,7 +2730,8 @@ function removeSpmInjection( writeIfChanged(pbxprojPath, text); log(`Removed SPM injection from ${path.relative(appRoot, pbxprojPath)}`); - // 3. Scheme: delete it if injection created it, else strip the pre-action. + // 3. Scheme: delete it if injection created it AND still owns its contents, + // else strip the pre-action and leave the file (see isGeneratedScheme). const scheme = marker.scheme; if (scheme != null && scheme.file != null) { const schemePath = path.join( @@ -2228,11 +2740,21 @@ function removeSpmInjection( 'xcschemes', scheme.file, ); - if (scheme.created === true) { - fs.rmSync(schemePath, {force: true}); - } else if (fs.existsSync(schemePath)) { + if (fs.existsSync(schemePath)) { const xml = fs.readFileSync(schemePath, 'utf8'); - writeIfChanged(schemePath, removePreActionFromScheme(xml)); + const ours = + scheme.created === true && + isGeneratedScheme( + xml, + marker.target, + marker.targetUuid, + path.basename(xcodeprojPath, '.xcodeproj'), + ); + if (ours) { + fs.rmSync(schemePath, {force: true}); + } else { + writeIfChanged(schemePath, removePreActionFromScheme(xml)); + } } } @@ -2251,6 +2773,7 @@ module.exports = { ensureStubPackages, buildSpmDependencyGraph, spmGraphToEntries, + buildPhaseOrder, planInjection, injectSpmIntoPbxproj, injectSpmIntoExistingXcodeproj, @@ -2262,5 +2785,6 @@ module.exports = { findInjectedXcodeproj, readArtifactsVersionOverride, readPinnedConfigCommand, + readScriptPhasesManifest, SPM_INJECTED_MARKER, }; diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js index cc3fb34b0e88..ef26d6e63614 100644 --- a/packages/react-native/scripts/spm/scaffold-package-swift.js +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -36,6 +36,7 @@ import type { */ const { + SpmNameCollisionError, defaultReadConfig, defaultResolveDep, expandSpmDependencies, @@ -43,6 +44,10 @@ const { const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); const {readPodspec} = require('./read-podspec'); const { + REACT_CODEGEN_PACKAGE_NAME, + REACT_CODEGEN_PRODUCTS, + REACT_NATIVE_PACKAGE_NAME, + REACT_NATIVE_PRODUCTS, SCAFFOLDER_MARKER, makeLogger, remotePackageConfig, @@ -235,14 +240,18 @@ function translatePodspecToSpmTarget( // `s.dependency "RNWorklets"` — a pod-style name the `react-native-*` // heuristic can't recognize — to the right sibling package. Empty by default. podToNpm /*: Map */ = new Map(), + // npm name → resolved Swift name for every autolinked dep, so a sibling + // reference honors that sibling's `spm.name` instead of re-deriving it. + swiftNameByNpm /*: Map */ = new Map(), ) /*: SpmScaffoldSpec */ { const warnings = [...model.warnings]; - // Swift target name: ALWAYS toSwiftName(npm-name). The autolinker - // registers each autolinked dep under that name in its aggregator (and in - // any sibling spm.dependencies refs), so the scaffolded Package.swift's - // product/library name must match — otherwise SPM resolution fails with - // a name mismatch on `.product(name: "X", package: "X")`. + // Swift target name: whatever the autolinker resolved for this dep — its + // `spm.name` override when set, else toSwiftName(npm-name). The autolinker + // registers the dep under that name in its aggregator (and in any sibling + // spm.dependencies refs), so the scaffolded Package.swift's product/library + // name must match it exactly — otherwise SPM resolution fails with a name + // mismatch on `.product(name: "X", package: "X")`. // // The podspec's `header_dir` is captured separately: when it changes the // include surface (e.g. `` instead of ``), @@ -251,10 +260,8 @@ function translatePodspecToSpmTarget( // `` resolve through // `-I common/cpp/`). Module-style includes that NEED the target name to // match (e.g. reanimated's `` via SwiftPM's auto-generated - // module map) require an explicit `spm.name` override in - // react-native.config.js — handled by the existing autolinker flow, not - // here. - const swiftName = toSwiftName(dep.name); + // module map) are what `spm.name` is for. + const swiftName = dep.swiftName ?? toSwiftName(dep.name); // Header search paths — substitute Xcode build-setting tokens against the // dep root. Anything we can't substitute is dropped + warned (avoids @@ -512,6 +519,14 @@ function translatePodspecToSpmTarget( ); } + const siblingSwiftNames /*: {[npmName: string]: string} */ = {}; + for (const npmName of siblingNames) { + const resolved = swiftNameByNpm.get(npmName); + if (resolved != null) { + siblingSwiftNames[npmName] = resolved; + } + } + return { swiftName, sources: expandedSources, @@ -520,6 +535,7 @@ function translatePodspecToSpmTarget( needsObjCPrefix, coreReactNative, siblingNames, + siblingSwiftNames, extraFrameworks: model.frameworks, weakFrameworks: model.weakFrameworks, compilerFlags: model.compilerFlags, @@ -661,7 +677,8 @@ function emitScaffoldedPackageSwift( // the app by definition, so it stays a path reference — relative, // computed at scaffold time. const remote = ctx.remote; - const rnLabel = remote != null ? remote.identity : 'ReactNative'; + const rnLabel = + remote != null ? remote.identity : REACT_NATIVE_PACKAGE_NAME; const codegenDir = ctx.codegenPackageDir; if (codegenDir == null) { throw new Error( @@ -679,24 +696,25 @@ function emitScaffoldedPackageSwift( 'emitScaffoldedPackageSwift: localXcfwPackageDir is required when no remote package is configured.', ); } - packageDeps.push(`.package(name: "ReactNative", path: "${xcfwDir}")`); + packageDeps.push( + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "${xcfwDir}")`, + ); } packageDeps.push( - `.package(name: "React-GeneratedCode", path: "${codegenDir}")`, - ); - targetDeps.push(`.product(name: "ReactHeaders", package: "${rnLabel}")`); - targetDeps.push( - `.product(name: "ReactNativeHeaders", package: "${rnLabel}")`, - ); - targetDeps.push( - `.product(name: "ReactNativeDependenciesHeaders", package: "${rnLabel}")`, - ); - targetDeps.push( - '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + `.package(name: "${REACT_CODEGEN_PACKAGE_NAME}", path: "${codegenDir}")`, ); + for (const product of REACT_NATIVE_PRODUCTS) { + targetDeps.push(`.product(name: "${product}", package: "${rnLabel}")`); + } + for (const product of REACT_CODEGEN_PRODUCTS) { + targetDeps.push( + `.product(name: "${product}", package: "${REACT_CODEGEN_PACKAGE_NAME}")`, + ); + } } for (const siblingName of spec.siblingNames) { - const swiftSibling = toSwiftName(siblingName); + const swiftSibling = + spec.siblingSwiftNames?.[siblingName] ?? toSwiftName(siblingName); // The autolinker references each self-managed (scaffolded) dep through a // `libs/` symlink, and SPM resolves a manifest's relative // package paths against that symlink location — so a sibling lives at @@ -796,6 +814,10 @@ type ScaffoldContext = { // podspec-name → npm-name index over all autolinked deps, so pod-style // `s.dependency` names (e.g. "RNWorklets") wire to the right sibling. podToNpm?: Map, + // npm-name → resolved Swift name over all autolinked deps, so sibling + // references honor each sibling's `spm.name`. + swiftNameByNpm?: Map, + remote: ?{url: string, version: string, identity: string}, }; */ @@ -954,6 +976,7 @@ function scaffoldPackageSwiftForDep( model, dep, ctx.podToNpm ?? new Map(), + ctx.swiftNameByNpm ?? new Map(), ); // Mixed-language fail-closed: SPM can't compile Swift + C-family in one @@ -1008,7 +1031,7 @@ function scaffoldPackageSwiftForDep( .join('/'); const content = emitScaffoldedPackageSwift(spec, { cacheSlotLabel: ctx.cacheSlotLabel, - remote: remotePackageConfig(ctx.appRoot), + remote: ctx.remote, codegenPackageDir: relFromManifest('build', 'generated', 'ios'), localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), }); @@ -1135,13 +1158,22 @@ function scaffoldAll( directDeps.push({name, root, platforms: {ios: iosPlatform}}); } + // Outside the try: a malformed remote config (RemoteVersionError) is a + // misconfiguration to surface, not an expansion failure to degrade past. + const remote = remotePackageConfig(appRoot); + let allDeps /*: Array */ = []; try { allDeps = expandSpmDependencies(directDeps, { readConfig: defaultReadConfig, resolveDep: defaultResolveDep, + extraReservedNames: remote != null ? [remote.identity] : undefined, + log, }); } catch (e) { + if (e instanceof SpmNameCollisionError) { + throw e; + } // A transitive-resolution failure shouldn't abort the whole scaffold pass; // fall back to the direct deps so at least those get manifests. log(`Transitive spm.dependencies expansion failed: ${e.message}`); @@ -1176,6 +1208,13 @@ function scaffoldAll( } } + const swiftNameByNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + if (dep.swiftName != null) { + swiftNameByNpm.set(dep.name, dep.swiftName); + } + } + const ctx /*: ScaffoldContext */ = { appRoot, projectRoot, @@ -1184,6 +1223,8 @@ function scaffoldAll( dryRun: opts.dryRun === true, cacheSlotLabel: opts.cacheSlotLabel ?? null, podToNpm, + swiftNameByNpm, + remote, }; const skipSet /*: Set */ = new Set(opts.skipDeps ?? []); diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js index 37db184129f4..6501894440cb 100644 --- a/packages/react-native/scripts/spm/spm-pbxproj.js +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -28,13 +28,55 @@ function generateUUID(seed /*: string */) /*: string */ { } /** - * Escapes a string for OpenStep plist format if needed. + * Escapes a string for OpenStep plist format if needed. A literal CR or tab in + * a quoted value is legal but Xcode rewrites it to its escape on the next save, + * planting a spurious diff in the user's repo — so escape those too (a plugin + * `script` with CRLF line endings is how one gets in). */ function quoteIfNeeded(s /*: string */) /*: string */ { if (/^[a-zA-Z0-9._/]+$/.test(s)) { return s; } - return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; + return `"${s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t')}"`; +} + +/** + * Normalize an UNTRUSTED label (a plugin's script-phase name, say) for use + * inside a pbxproj `/* … *​/` comment. Such a comment is purely cosmetic — Xcode + * regenerates it from the object's own fields — while the text AROUND it is + * scanned by delimiter, so every character that could be read as structure (or + * split the line) becomes a space, and runs of whitespace collapse: + * `findObjectByUuid` takes the first `{` after a UUID as that object's body, + * `removeArrayMembersByUuid` identifies a member line by its trailing comma, and + * `scanToClose` treats a `"` as opening a string. Stable (same input ⇒ same + * output), so re-syncing stays byte-identical. Returns '' when nothing survives; + * callers supply the fallback label. + * + * Comments RN writes to Xcode's OWN convention (`XCLocalSwiftPackageReference + * "build/xcframeworks"`, ` in Sources`) are NOT run through this: they are + * fixed strings, and normalizing them would rewrite bytes Xcode itself produces — + * a spurious diff in the user's repo on their next save. + */ +function commentSafe(s /*: string */) /*: string */ { + return s + .replace(/[{}(),;="*/\s]/g, ' ') + .replace(/ +/g, ' ') + .trim(); +} + +/** + * Format the ` /* … *​/` suffix pbxproj writes after a UUID, omitted entirely for + * an empty label (an uncommented UUID is well-formed — Xcode writes those itself + * for unnamed groups). Labels that did not originate in this repo must already + * have been through `commentSafe`. + */ +function uuidComment(comment /*: ?string */) /*: string */ { + return comment != null && comment !== '' ? ` /* ${comment} */` : ''; } /** @@ -47,11 +89,7 @@ function quoteIfNeeded(s /*: string */) /*: string */ { function serializeEntry( entry /*: {readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...} */, ) /*: string */ { - const comment = - entry.comment != null && entry.comment !== '' - ? ` /* ${entry.comment} */` - : ''; - let out = `\t\t${entry.uuid}${comment} = {`; + let out = `\t\t${entry.uuid}${uuidComment(entry.comment)} = {`; const fieldKeys = Object.keys(entry.fields); if ( fieldKeys.length <= 3 && @@ -343,8 +381,7 @@ function addArrayMembers( const memberIndent = fieldIndent + '\t'; const line = ( m /*: {readonly uuid: string, readonly comment?: ?string, ...} */, - ) => - `${memberIndent}${m.uuid}${m.comment != null && m.comment !== '' ? ` /* ${m.comment} */` : ''},\n`; + ) => `${memberIndent}${m.uuid}${uuidComment(m.comment)},\n`; const field = findField(text, obj, key); if (field != null) { @@ -367,6 +404,38 @@ function addArrayMembers( return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); } +/** + * Indices of the `,` separators at an array's top level — a comma inside a + * quoted member (`"$(FOO),weird"`) separates nothing. + */ +function topLevelCommas(inner /*: string */) /*: Array */ { + const out = []; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === '"') { + i = scanString(inner, i); + } else if (inner[i] === ',') { + out.push(i); + } + } + return out; +} + +/** + * The members of an array's inner text (what sits between its parens), trimmed + * and with empty slots — e.g. the one a trailing comma leaves — dropped. A bare + * scalar value parses as its own single member. + */ +function arrayMembers(inner /*: string */) /*: Array */ { + const members = []; + let start = 0; + for (const comma of topLevelCommas(inner)) { + members.push(inner.slice(start, comma)); + start = comma + 1; + } + members.push(inner.slice(start)); + return members.map(m => m.trim()).filter(m => m !== ''); +} + /** * Append raw string values to a `( … )` array build-setting (e.g. * OTHER_LDFLAGS), deduping by exact token. Creates the setting seeded with @@ -385,31 +454,57 @@ function addArrayStringValues( const field = findField(text, obj, key); if (field != null) { + const isArray = field.value.trimStart().startsWith('('); + const openParen = isArray + ? field.valueStart + field.value.indexOf('(') + : -1; + const closeParen = isArray ? scanToClose(text, openParen) : -1; + const inner = isArray ? text.slice(openParen + 1, closeParen) : field.value; // Dedup by EXACT existing member, not substring — a substring check would // treat `"-ObjC"` as already present when only `"-ObjCFoo"` is there (and - // vice-versa). Parse the current members (array `( … )` or bare scalar). - const existingMembers = new Set( - field.value - .replace(/^\s*\(/, '') - .replace(/\)\s*$/, '') - .split(',') - .map(s => s.trim()) - .filter(s => s.length > 0), - ); + // vice-versa). + const existingMembers = new Set(arrayMembers(inner)); const fresh = values.filter(v => !existingMembers.has(v)); if (fresh.length === 0) { return text; } - if (field.value.trimStart().startsWith('(')) { - // Existing array — splice fresh members before the closing `)`. - const lineStart = text.lastIndexOf('\n', field.tokenEnd - 1) + 1; - const lines = fresh.map(v => `${memberIndent}${v},\n`).join(''); - return text.slice(0, lineStart) + lines + text.slice(lineStart); + if (isArray) { + if (inner.includes('\n')) { + // Multi-line array — one member per line, before the closing `)`. + const lineStart = text.lastIndexOf('\n', closeParen) + 1; + const lines = fresh.map(v => `${memberIndent}${v},\n`).join(''); + return text.slice(0, lineStart) + lines + text.slice(lineStart); + } + // One-line array (hand-edited projects, XcodeGen, Tuist) — splice the + // members in ahead of the `)`, in the separator style already there. + // Reformatting it multi-line instead would have to record the old shape + // to stay reversible on deinit. + const commas = topLevelCommas(inner); + const gapMatch = + commas.length > 0 ? /^[\t ]*/.exec(inner.slice(commas[0] + 1)) : null; + const gap = gapMatch != null ? gapMatch[0] : ' '; + const core = inner.replace(/[\t ]+$/, ''); + const joined = fresh.join(`,${gap}`); + const insertion = + core === '' + ? joined + : core.endsWith(',') + ? `${gap}${joined},` + : `,${gap}${joined}`; + const at = openParen + 1 + core.length; + return text.slice(0, at) + insertion + text.slice(at); } - // Existing scalar — promote to an array preserving the prior value. + // Existing scalar — promote to an array preserving the prior value. Skip it + // when it IS the `"$(inherited)"` the array is seeded with (emitted twice), + // or when it is empty (a bare `,` is not a valid plist element). The seed + // test unquotes first: pbxproj accepts `$(inherited)` bare, and Xcode's own + // quoted form is the same value to the build system. + const prior = field.value.trim(); + const carriesPrior = + prior !== '' && prior.replace(/^"(.*)"$/s, '$1') !== '$(inherited)'; const replacement = arrayBlock([ '"$(inherited)"', - field.value.trim(), + ...(carriesPrior ? [prior] : []), ...fresh, ]); return ( @@ -464,7 +559,12 @@ function setScalarField( // --------------------------------------------------------------------------- // Surgical removal — the inverse of the additive helpers above. `deinit` uses // these to undo exactly what injection added, leaving every other byte (incl. -// user edits made after injection) untouched. All are pure string transforms. +// user edits made after injection) untouched. The exception is a scalar that +// injection promoted to an array: reversing that rewrites the whole field (see +// removeRecordedBuildSettings), so members added to it afterwards are lost. +// That is not deinit-only — every re-sync reverts from the recorded baseline +// before re-injecting, so an `spm update` discards them just the same. +// All are pure string transforms. // --------------------------------------------------------------------------- /** @@ -495,7 +595,8 @@ function removeObjectByUuid( * `uuids` from every `( … )` list in the file (packageReferences, * packageProductDependencies, a Frameworks phase's `files`, buildPhases, …). * Only matches member lines (trailing comma), never the object-definition line - * (which ends in `= {`), so it composes safely with removeObjectByUuid. + * (which ends in `= {`), so it composes safely with removeObjectByUuid — which + * holds because no comment can contain a comma (see commentSafe). */ function removeArrayMembersByUuid( text /*: string */, @@ -525,6 +626,33 @@ function removeField( return text.slice(0, f.matchStart) + text.slice(f.tokenEnd + 1); } +/** + * Drop one member from an array field's value text. The patterns mirror the + * forms addArrayStringValues inserts, tried in the order that makes the span + * removed exactly the span it added: a line of its own (multi-line array), then + * after a comma, before a comma, or alone ahead of the `)` (the one-line + * shapes). Each is anchored on a delimiter, so a value that is merely a prefix + * of a longer member is never mistaken for it. + */ +function removeArrayMember( + region /*: string */, + value /*: string */, +) /*: string */ { + const v = escapeRegExp(value); + for (const pattern of [ + `\\n[\\t ]*${v},`, + `,[\\t ]*${v}(?=[\\t ]*[,)])`, + `[\\t ]*${v},[\\t ]*`, + `[\\t ]*${v}(?=[\\t ]*\\))`, + ]) { + const shorter = region.replace(new RegExp(pattern), ''); + if (shorter !== region) { + return shorter; + } + } + return region; +} + /** * Remove specific raw string members from an existing `( … )` array field * (inverse of addArrayStringValues' append branch). Leaves the field and any @@ -542,7 +670,7 @@ function removeArrayStringValues( } let region = text.slice(f.valueStart, f.tokenEnd); for (const val of values) { - region = region.replace(new RegExp(`\\n[\\t ]*${escapeRegExp(val)},`), ''); + region = removeArrayMember(region, val); } return text.slice(0, f.valueStart) + region + text.slice(f.tokenEnd); } @@ -628,6 +756,8 @@ module.exports = { namespacedUUID, serializeEntry, quoteIfNeeded, + commentSafe, + uuidComment, // Surgical-edit toolkit (in-place injection): scanString, scanToClose, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index 75d0ba26b6aa..849fecff5637 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -168,7 +168,6 @@ export type SpmModuleConfig = { name: string, path: string, exclude?: Array, - publicHeadersPath?: ?string, // Optional CocoaPods-style glob allowlist (analog of s.source_files). // When set, replaces auto source discovery for the module — only files // matching one of these patterns are passed to SPM via `sources:`. @@ -219,6 +218,21 @@ export type PluginProductDep = {name: string, package: string}; // in the codegen package so it compiles. export type PluginGeneratedSource = {path: string}; +// A build-time shell phase for the app target — SwiftPM's missing analog of +// CocoaPods' `script_phase`. `id` is the stable ledger key and deterministic +// UUID seed (charset /^[@A-Za-z0-9_./-]+$/, so a scoped npm name works; `:` is +// excluded because the seed is `plugin:`); `position` is normalized to 'end' +// by invokePlugins so consumers never re-derive the default. +export type PluginScriptPhase = { + id: string, + name: string, + script: string, + position: 'beforeCompile' | 'end', + inputPaths?: Array, + outputPaths?: Array, + alwaysOutOfDate?: boolean, +}; + // A plugin-declared dynamic XCFramework pair. RN validates both paths, stages // immutable app-local slots, and links/embeds the selected framework outside // SwiftPM. @@ -302,6 +316,10 @@ export type PluginResult = { // for staleness, e.g. the plugin dep's own `Package.swift` and per-module // manifests. Folded into `.spm-sync-watch-paths` by main(). watchPaths: Array, + // Build-time shell phases for the app target, recorded to + // `.spm-plugin-script-phases.json` by main() and injected by `spm add`/ + // `update`. + scriptPhases: Array, }; export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ @@ -310,6 +328,11 @@ export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ generatedSources?: Array, flavoredFrameworks?: Array, watchPaths?: Array, + // `position` may be omitted here; invokePlugins normalizes it to 'end'. + scriptPhases?: Array<{ + ...PluginScriptPhase, + position?: 'beforeCompile' | 'end', + }>, }; export type DiscoveredPlugin = { @@ -458,10 +481,13 @@ export type SpmScaffoldSpec = { // Bucketed dependency references — pre-computed by the translation layer. // `coreReactNative` is true when ANY React-* / RCT* / RCT-Folly / glog // dep is present (so we add React's invariant header products). - // `siblingNames` are npm names that match other autolinked deps — resolved - // to Swift names by the scaffold orchestrator before emit. + // `siblingNames` are npm names that match other autolinked deps. + // `siblingSwiftNames` carries each one's resolved Swift name (honoring the + // sibling's `spm.name`); a sibling absent from it falls back to + // toSwiftName(npmName) at emit time. coreReactNative: boolean, siblingNames: Array, + siblingSwiftNames?: {[npmName: string]: string}, // Extra frameworks beyond the autolinker's default UIKit/Foundation/CoreGraphics // set. Merged with the defaults at emit time. extraFrameworks: Array, diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index e01afe05c368..938cfe5fae6e 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -14,6 +14,67 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +// The package and product names React Native's own generated manifests use. A +// dependency whose Swift name lands on one of them surfaces as a duplicate-name +// error from inside SPM's resolution, far from the react-native.config.js that +// caused it, so the autolinker rejects the collision up front instead. +// +// These cover the emitters in this directory and the collision guard. Adding a +// React Native SPM product also touches, depending on what the product is: +// - scripts/codegen/templates/Package.swift.spm-template — if the per-app +// codegen package must depend on it (a static Swift file, patched by +// string replacement, not generated from these constants) +// - flavored-frameworks.js INVARIANT_BINARY_TARGETS — if it is +// xcframework-backed +// - download-spm-artifacts.js REQUIRED_ARTIFACTS — if it ships as its own +// downloadable artifact (that list names ARTIFACTS, which overlap with +// product names without being the same set) +const REACT_NATIVE_PACKAGE_NAME /*: string */ = 'ReactNative'; +const REACT_CODEGEN_PACKAGE_NAME /*: string */ = 'React-GeneratedCode'; +// The autolinking aggregator package, whose single product shares its name. +const AUTOLINKED_PACKAGE_NAME /*: string */ = 'Autolinked'; +// React Native's products by KIND, so no consumer has to infer the kind from a +// position in a flat list. The umbrella is a Clang target over the staged +// headers that re-exports the pure-RN namespaces; the rest are each backed by +// an xcframework of the same name. +const REACT_NATIVE_UMBRELLA_PRODUCT /*: string */ = 'ReactHeaders'; +const REACT_NATIVE_HEADERS_PRODUCT /*: string */ = 'ReactNativeHeaders'; +const REACT_NATIVE_DEPENDENCIES_HEADERS_PRODUCT /*: string */ = + 'ReactNativeDependenciesHeaders'; +const REACT_NATIVE_XCFRAMEWORK_PRODUCTS /*: ReadonlyArray */ = + Object.freeze([ + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_DEPENDENCIES_HEADERS_PRODUCT, + ]); +// Derived, so a new product cannot be added without choosing a kind above. +const REACT_NATIVE_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_UMBRELLA_PRODUCT, + ...REACT_NATIVE_XCFRAMEWORK_PRODUCTS, +]); +// The codegen package product every autolinked target depends on for the app's +// generated headers. +const REACT_CODEGEN_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + 'ReactAppHeaders', +]); +// The codegen package products the APP target links, declared by the static +// template rather than by any emitter here. +const REACT_CODEGEN_APP_PRODUCTS /*: ReadonlyArray */ = Object.freeze([ + 'ReactCodegen', + 'ReactAppDependencyProvider', +]); +const REACT_HEADERS_TARGET_DIR /*: string */ = 'ReactHeadersTarget'; +// Target names only have to be unique within their own package, so +// REACT_HEADERS_TARGET_DIR is deliberately absent: a dependency named after it +// collides with nothing. +const RESERVED_SWIFT_NAMES /*: ReadonlyArray */ = Object.freeze([ + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + ...REACT_NATIVE_PRODUCTS, + ...REACT_CODEGEN_PRODUCTS, + ...REACT_CODEGEN_APP_PRODUCTS, +]); + /** * Creates a logger trio {log, warn, die} that prefixes messages with [name]. * log – green prefix, writes to stdout @@ -585,10 +646,10 @@ function installSpmCodegenTemplate( if (remote != null) { content = content .replace( - '.package(name: "ReactNative", path: "../../xcframeworks"),', + `.package(name: "${REACT_NATIVE_PACKAGE_NAME}", path: "../../xcframeworks"),`, `.package(url: "${remote.url}", exact: "${remote.version}"),`, ) - .split('package: "ReactNative")') + .split(`package: "${REACT_NATIVE_PACKAGE_NAME}")`) .join(`package: "${remote.identity}")`); } fs.writeFileSync(codegenPkgSwift, content, 'utf8'); @@ -627,7 +688,59 @@ function runCodegenAndInstallTemplate( } } +// --------------------------------------------------------------------------- +// Autolinking-plugin script phases — the shared validation rules. Two gates +// apply them with different POLICIES (invokePlugins throws, the injector's +// sidecar reader skips the entry), so only the rules live here. See +// __docs__/spm-autolinking-plugins.md for the reasoning. +// --------------------------------------------------------------------------- + +// `@` and `/` are admitted so a package can use its own scoped npm name +// (`@expo/log-box`). `:` is not: the id is hashed into the UUID seed as +// `plugin:`, and keeping the separator out of the id keeps that seed +// unambiguous. +const SCRIPT_PHASE_ID_PATTERN = /^[@A-Za-z0-9_./-]+$/; + +// `ledger.__proto__ = uuid` sets the prototype instead of an own property, so a +// phase with one of these ids would look recorded, vanish through +// JSON.stringify, and never be removable by `deinit`. +const RESERVED_SCRIPT_PHASE_IDS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + +function isValidScriptPhaseId(value /*: unknown */) /*: boolean */ { + return ( + typeof value === 'string' && + SCRIPT_PHASE_ID_PATTERN.test(value) && + !RESERVED_SCRIPT_PHASE_IDS.has(value) + ); +} + +/** + * The name reaches the project file twice: verbatim in the escaped `name` field + * Xcode displays, and normalized (spm-pbxproj's commentSafe) in the cosmetic + * `/* … *​/` comments. Both are safe for any single-line string, so the only + * thing left to refuse is a line break — which no Xcode phase display name can + * carry anyway. + */ +function isValidScriptPhaseName(value /*: unknown */) /*: boolean */ { + return typeof value === 'string' && value.length > 0 && !/[\r\n]/.test(value); +} + module.exports = { + REACT_NATIVE_PACKAGE_NAME, + REACT_CODEGEN_PACKAGE_NAME, + AUTOLINKED_PACKAGE_NAME, + REACT_NATIVE_UMBRELLA_PRODUCT, + REACT_NATIVE_HEADERS_PRODUCT, + REACT_NATIVE_XCFRAMEWORK_PRODUCTS, + REACT_NATIVE_PRODUCTS, + REACT_CODEGEN_PRODUCTS, + REACT_CODEGEN_APP_PRODUCTS, + REACT_HEADERS_TARGET_DIR, + RESERVED_SWIFT_NAMES, makeLogger, displayPath, sharedCacheDir, @@ -644,5 +757,7 @@ module.exports = { RemoteVersionError, installSpmCodegenTemplate, runCodegenAndInstallTemplate, + isValidScriptPhaseId, + isValidScriptPhaseName, SCAFFOLDER_MARKER, }; diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index 7ba17f98b836..d5f5eeed620c 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -369,7 +369,7 @@ describe('Native Animated', () => { const value = new Animated.Value(0); value.__makeNative(); const listener = jest.fn(); - const event = Animated.event([{nativeEvent: {foo: value}}], { + const event = new Animated.Event([{nativeEvent: {foo: value}}], { useNativeDriver: true, listener, }); diff --git a/packages/rn-tester/react-native.config.js b/packages/rn-tester/react-native.config.js index c63fd9e7127c..7d9058c4686d 100644 --- a/packages/rn-tester/react-native.config.js +++ b/packages/rn-tester/react-native.config.js @@ -36,7 +36,6 @@ module.exports = { { name: 'ReactCommonSamples', path: '../react-native/ReactCommon/react/nativemodule/samples/platform/ios', - publicHeadersPath: '.', }, { name: 'ReactRCTPushNotification', From 68e6b4c1c4d0c23374d999fb612309756c4f4397 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 06:18:44 -0500 Subject: [PATCH 3/5] fix: complete focused 0.87 fork header and artifact integration Carry reviewed platform classification, packaged include resolution, binary-derived sidecars, cache payloads and filesystem copies onto the curated mainline feature stack. Preserve one RCTUIKit header provider and macOS resource layout. Independent review verified inventory and artifact contracts, 1245 focused tests, provider checks and unchanged public type behavior. Native artifact builds remain a separate acceptance step. --- .../microsoft-prebuild-macos-core-test.rb | 180 ++++++++++ .../__tests__/prebuild-ios-sidecars-test.rb | 82 +++++ .../microsoft-prebuild-macos-core.yml | 105 +++++- .github/workflows/prebuild-ios-core.yml | 15 +- .../workflows/prebuild-ios-dependencies.yml | 15 +- .../__tests__/headers-compose-test.js | 147 +++++++- .../__tests__/headers-conditional-test.js | 315 ++++++++++++++++++ .../__tests__/headers-verify-test.js | 40 +++ .../__tests__/headers-xcframework-test.js | 218 ++++++++++++ .../__tests__/hermes-version-test.js | 21 -- .../__tests__/xcframework-test.js | 85 ++++- .../scripts/ios-prebuild/headers-compose.js | 32 +- .../headers-include-baseline.json | 6 - .../scripts/ios-prebuild/headers-inventory.js | 219 ++++++++++-- .../scripts/ios-prebuild/headers-verify.js | 9 + .../ios-prebuild/headers-xcframework.js | 71 ++-- .../ios-prebuild/reactNativeDependencies.js | 8 +- .../scripts/ios-prebuild/xcframework.js | 4 +- 18 files changed, 1437 insertions(+), 135 deletions(-) create mode 100644 .github/workflow-scripts/__tests__/microsoft-prebuild-macos-core-test.rb create mode 100644 .github/workflow-scripts/__tests__/prebuild-ios-sidecars-test.rb create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-conditional-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js diff --git a/.github/workflow-scripts/__tests__/microsoft-prebuild-macos-core-test.rb b/.github/workflow-scripts/__tests__/microsoft-prebuild-macos-core-test.rb new file mode 100644 index 000000000000..aa6e7fd6a645 --- /dev/null +++ b/.github/workflow-scripts/__tests__/microsoft-prebuild-macos-core-test.rb @@ -0,0 +1,180 @@ +# 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. + +require 'yaml' +require 'minitest/autorun' +require 'tmpdir' +require 'fileutils' +require 'open3' + +class MicrosoftPrebuildMacOSCoreTest < Minitest::Test + ROOT = File.expand_path('../..', __dir__) + WORKFLOW = YAML.load_file(File.join(ROOT, 'workflows/microsoft-prebuild-macos-core.yml')) + TOOLCHAIN = YAML.load_file(File.join(ROOT, 'actions/microsoft-setup-toolchain/action.yml')) + + def steps(job) + WORKFLOW.fetch('jobs').fetch(job).fetch('steps') + end + + def step(job, name) + steps(job).find { |s| s['name'] == name } || raise("Missing step: #{name}") + end + + # Evaluate the workflow's actual expressions for successful jobs. actionlint + # separately validates GitHub expression syntax and context availability. + def expression(value, platform: 'ios-simulator', hit: false, ref: 'refs/heads/main', family: nil) + source = value.sub(/\A\$\{\{\s*/, '').sub(/\s*\}\}\z/, '') + { + 'matrix.platform' => platform, + 'inputs.platform' => family, + 'github.ref' => ref, + 'steps.cache-slice.outputs.cache-hit' => hit.to_s, + 'steps.cache-xcframework.outputs.cache-hit' => hit.to_s + }.each { |token, replacement| source = source.gsub(token, replacement.inspect) } + source = source.gsub('startsWith(', 'starts_with(').gsub('endsWith(', 'ends_with(') + eval(source, binding) # Only checked-in workflow expressions, never external input. + end + + def starts_with(value, prefix) + value.start_with?(prefix) + end + + def ends_with(value, suffix) + value.end_with?(suffix) + end + + def enabled?(step, **context) + !step.key?('if') || !!expression(step['if'], **context) + end + + def test_build_cache_paths_and_header_transfers + platforms = WORKFLOW['jobs']['build']['strategy']['matrix']['platform'] + assert_equal %w[ios ios-simulator macos visionos visionos-simulator], platforms + restore = step('build', 'Restore slice cache')['with'] + save = step('build', 'Save slice cache')['with'] + assert_equal restore['path'], save['path'] + assert_equal '${{ steps.cache-slice.outputs.cache-primary-key }}', save['key'] + assert_match(/\Av2-/, restore['key']) + refute restore.key?('restore-keys') + + %w[Hermes dependency].each do |kind| + upload = step('build', "Upload #{kind} headers") + download = step('compose-xcframework', "Download #{kind} headers") + assert_equal upload['with']['name'], download['with']['name'] + assert_equal upload['with']['path'], download['with']['path'] + assert_includes restore['path'].lines.map(&:strip), upload['with']['path'] + assert_equal 'error', upload['with']['if-no-files-found'] + platforms.product([true, false]).each do |platform, hit| + assert_equal platform == 'ios-simulator', enabled?(upload, platform: platform, hit: hit) + end + end + + platforms.product([true, false]).each do |platform, hit| + %w[Setup\ toolchain Install\ npm\ dependencies Download\ Hermes\ artifacts Setup\ workspace\ (using\ prebuilt\ Hermes)].each do |name| + assert_equal !hit, enabled?(step('build', name), platform: platform, hit: hit) + end + assert enabled?(step('build', 'Upload headers'), platform: platform, hit: hit) + assert enabled?(step('build', 'Upload slice artifacts'), platform: platform, hit: hit) + end + end + + def test_toolchain_family_conditions_cover_simulators_and_compose_sdks + setup = step('build', 'Setup toolchain') + xcode = TOOLCHAIN['runs']['steps'].find { |s| s['name'] == 'Set up Xcode' } + vision = TOOLCHAIN['runs']['steps'].find { |s| s['name'] == 'Download visionOS SDK' } + { 'ios' => 'ios', 'ios-simulator' => 'ios', 'macos' => 'macos', + 'visionos' => 'visionos', 'visionos-simulator' => 'visionos' }.each do |platform, expected| + family = expression(setup['with']['platform'], platform: platform) + assert_equal expected, family + assert enabled?(xcode, family: family) + assert_equal expected == 'visionos', enabled?(vision, family: family) + end + family = step('compose-xcframework', 'Setup toolchain')['with']['platform'] + assert enabled?(xcode, family: family) + assert enabled?(vision, family: family) + script = step('compose-xcframework', 'Verify compose SDKs')['run'] + output, status = Open3.capture2('bash', '-euc', 'xcrun() { printf "%s\n" "$2"; }; ' + script) + assert status.success? + assert_equal %w[iphoneos iphonesimulator macosx xros xrsimulator], output.lines.map(&:strip) + end + + def test_compose_cold_and_cache_hit_paths + job = 'compose-xcframework' + restore = step(job, 'Restore compose cache')['with'] + save = step(job, 'Save compose cache')['with'] + assert_equal restore['path'], save['path'] + assert_equal '${{ steps.cache-xcframework.outputs.cache-primary-key }}', save['key'] + assert_match(/\Av2-/, restore['key']) + refute restore.key?('restore-keys') + %w[.github/workflows/microsoft-prebuild-macos-core.yml headers-include-baseline.json version.properties yarn.lock].each do |input| + assert_includes restore['key'], input + assert_includes step('build', 'Restore slice cache')['with']['key'], input + end + uploads = steps(job).select { |s| s['uses'] == 'actions/upload-artifact@v4' } + assert_equal 3, uploads.length + assert_equal restore['path'].lines.map(&:strip).sort, uploads.map { |s| s['with']['path'] }.sort + [true, false].each do |hit| + steps(job).each do |s| + next if s['uses']&.start_with?('actions/checkout', 'actions/cache/') + expected = s['name'] == 'Verify archive payloads' || uploads.include?(s) || !hit + assert_equal expected, enabled?(s, hit: hit), "#{s['name']}, hit=#{hit}" + end + end + assert_includes step(job, 'Create XCFramework')['run'], '--require-hermes' + assert_equal 'node scripts/ios-prebuild/headers-verify.js --flavor Debug', step(job, 'Verify composed headers (iOS Simulator)')['run'] + names = steps(job).map { |s| s['name'] } + assert_operator names.index('Verify composed headers (iOS Simulator)'), :<, names.index('Name XCFramework archives') + assert_operator names.index('Verify archive payloads'), :<, names.index('Save compose cache') + uploads.each { |s| assert_equal 'error', s['with']['if-no-files-found'] } + end + + def test_cache_save_conditions + { 'build' => 'Save slice cache', 'compose-xcframework' => 'Save compose cache' }.each do |job, name| + %w[refs/heads/main refs/heads/0.87-stable refs/heads/topic refs/pull/1/merge].product([true, false]).each do |ref, hit| + expected = !hit && %w[refs/heads/main refs/heads/0.87-stable].include?(ref) + assert_equal expected, enabled?(step(job, name), hit: hit, ref: ref) + end + end + end + + def test_actual_archive_commands_reject_the_old_payload + Dir.mktmpdir('prebuild-payload-test-') do |dir| + %w[React ReactNativeHeaders].each do |name| + FileUtils.mkdir_p(File.join(dir, "Debug/#{name}.xcframework")) + File.write(File.join(dir, "Debug/#{name}.xcframework/Info.plist"), 'fixture') + end + FileUtils.mkdir_p(File.join(dir, 'Debug/Symbols')) + File.write(File.join(dir, 'Debug/Symbols/fixture.dSYM'), 'fixture') + run = lambda do |script, cwd| + Open3.capture3('bash', '-euc', script, chdir: cwd) + end + assert run.call('tar -czf React.xcframework.tar.gz React.xcframework ReactNativeHeaders.xcframework; tar -czf ReactNativeHeaders.xcframework.tar.gz ReactNativeHeaders.xcframework', File.join(dir, 'Debug')).last.success? + assert run.call(step('compose-xcframework', 'Name XCFramework archives')['run'], dir).last.success? + assert run.call('tar -czf ../../ReactCoreDebug.framework.dSYM.tar.gz .', File.join(dir, 'Debug/Symbols')).last.success? + check = step('compose-xcframework', 'Verify archive payloads')['run'] + assert run.call(check, dir).last.success? + FileUtils.mv(File.join(dir, 'ReactNativeHeadersDebug.xcframework.tar.gz'), File.join(dir, 'headers.saved')) + refute run.call(check, dir).last.success?, 'A missing standalone archive must fail' + FileUtils.mv(File.join(dir, 'headers.saved'), File.join(dir, 'ReactNativeHeadersDebug.xcframework.tar.gz')) + assert run.call('tar -czf ../ReactCoreDebug.xcframework.tar.gz React.xcframework', File.join(dir, 'Debug')).last.success? + refute run.call(check, dir).last.success?, 'The old core-only payload must fail on cache hits too' + end + end + + def test_actual_compose_input_checks_require_hermes_headers + Dir.mktmpdir('prebuild-input-test-') do |dir| + script = step('compose-xcframework', 'Verify downloaded artifacts')['run'] + %w[ + packages/react-native/.build/output/spm/Debug/Build/Products + packages/react-native/.build/headers + packages/react-native/.build/artifacts/hermes/destroot/include/hermes + packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + ].each { |entry| FileUtils.mkdir_p(File.join(dir, entry)) } + refute Open3.capture3('bash', '-euc', script, chdir: dir).last.success? + File.write(File.join(dir, 'packages/react-native/.build/artifacts/hermes/destroot/include/hermes/hermes.h'), 'fixture') + assert Open3.capture3('bash', '-euc', script, chdir: dir).last.success? + end + end +end diff --git a/.github/workflow-scripts/__tests__/prebuild-ios-sidecars-test.rb b/.github/workflow-scripts/__tests__/prebuild-ios-sidecars-test.rb new file mode 100644 index 000000000000..d9e953338100 --- /dev/null +++ b/.github/workflow-scripts/__tests__/prebuild-ios-sidecars-test.rb @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license in the root LICENSE file. + +require 'yaml' +require 'minitest/autorun' +require 'tmpdir' +require 'fileutils' +require 'open3' + +class PrebuildIOSSidecarsTest < Minitest::Test + ROOT = File.expand_path('../..', __dir__) + + def workflow(name) + YAML.load_file(File.join(ROOT, 'workflows', name)) + end + + def steps(name) + workflow(name).fetch('jobs').values.flat_map { |job| job.fetch('steps', []) } + end + + def step(name, title) + steps(name).find { |s| s['name'] == title } || raise(title) + end + + def expand(text, flavor) + text.gsub(/\$\{\{\s*matrix.flavor\s*\}\}/, flavor) + end + + def run_step(name, title, root, flavor) + Open3.capture3('bash', '-euc', expand(step(name, title).fetch('run'), flavor), chdir: root) + end + + def test_standalone_sidecars_are_cached_and_uploaded_for_both_flavors + [ + ['prebuild-ios-core.yml', 'Rename ReactNativeHeaders XCFramework tarball', 'Upload ReactNativeHeaders XCFramework Artifact', 'Save cache if present'], + ['prebuild-ios-dependencies.yml', 'Compress Headers Sidecar XCFramework', 'Upload Headers Sidecar XCFramework Artifact', 'Save XCFramework in Cache'], + ].each do |name, create, upload, save| + assert_includes(step(name, create).fetch('if'), "cache-hit != 'true'") + refute(step(name, upload).key?('if'), 'cache hits must upload too') + upload_path = step(name, upload).fetch('with').fetch('path') + assert_includes(step(name, save).fetch('with').fetch('path').lines.map(&:strip), upload_path) + %w[Debug Release].each do |flavor| + Dir.mktmpdir('ios-sidecar-payload-') do |dir| + if name.include?('core') + stage = File.join(dir, "packages/react-native/.build/output/xcframeworks/#{flavor}") + %w[React ReactNativeHeaders].each do |framework| + FileUtils.mkdir_p(File.join(stage, "#{framework}.xcframework")) + File.write(File.join(stage, "#{framework}.xcframework/Info.plist"), 'fixture') + end + assert(Open3.capture3('tar', '-czf', 'ReactNativeHeaders.xcframework.tar.gz', 'ReactNativeHeaders.xcframework', chdir: stage).last.success?) + assert(run_step(name, 'Compress and Rename XCFramework', dir, flavor).last.success?) + else + stage = File.join(dir, 'packages/react-native/third-party') + %w[ReactNativeDependencies ReactNativeDependenciesHeaders].each do |framework| + FileUtils.mkdir_p(File.join(stage, "#{framework}.xcframework")) + File.write(File.join(stage, "#{framework}.xcframework/Info.plist"), 'fixture') + end + assert(run_step(name, 'Compress and Rename XCFramework', dir, flavor).last.success?) + end + stdout, stderr, status = run_step(name, create, dir, flavor) + assert(status.success?, stdout + stderr) + archive = File.join(dir, expand(upload_path, flavor)) + listing, status = Open3.capture2('tar', '-tzf', archive) + assert(status.success?) + assert_match(/Headers\.xcframework\/Info.plist/, listing) + combined = step(name, 'Upload XCFramework Artifact').fetch('with').fetch('path') + listing, status = Open3.capture2('tar', '-tzf', File.join(dir, expand(combined, flavor))) + assert(status.success?) + assert_match(/Headers\.xcframework\/Info.plist/, listing) + end + end + end + end + + def test_old_incomplete_cache_keys_are_invalidated_consistently + {'prebuild-ios-core.yml' => 'v5-ios-core-xcframework-', 'prebuild-ios-dependencies.yml' => 'v6-ios-dependencies-xcframework-'}.each do |name, prefix| + keys = steps(name).filter_map { |s| s.dig('with', 'key') }.select { |key| key.start_with?(prefix) } + assert_equal(2, keys.length) + assert_equal(keys[0], keys[1]) + end + end +end diff --git a/.github/workflows/microsoft-prebuild-macos-core.yml b/.github/workflows/microsoft-prebuild-macos-core.yml index 2477eb9bc17a..c0ad3f4cd941 100644 --- a/.github/workflows/microsoft-prebuild-macos-core.yml +++ b/.github/workflows/microsoft-prebuild-macos-core.yml @@ -27,17 +27,20 @@ jobs: id: cache-slice uses: actions/cache/restore@v4 with: - key: v1-macos-core-${{ matrix.platform }}-Debug-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v2-macos-core-${{ matrix.platform }}-Debug-${{ hashFiles('.github/workflows/microsoft-prebuild-macos-core.yml', '.github/workflows/microsoft-resolve-hermes.yml', '.github/actions/microsoft-setup-toolchain/action.yml', '.github/scripts/resolve-hermes.mts', 'yarn.lock', 'packages/react-native/package.json', 'packages/react-native/Package.swift', 'packages/react-native/**/*.podspec', 'packages/react-native/sdks/.hermes*version', 'packages/react-native/sdks/hermes-engine/version.properties', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild/headers-include-baseline.json', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} path: | packages/react-native/.build/output/spm/Debug/Build/Products packages/react-native/.build/headers + packages/react-native/.build/artifacts/hermes/destroot/include + packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers - name: Setup toolchain if: steps.cache-slice.outputs.cache-hit != 'true' uses: ./.github/actions/microsoft-setup-toolchain with: node-version: '22' - platform: ${{ matrix.platform }} + # The shared action selects Xcode/SDKs by platform family. + platform: ${{ startsWith(matrix.platform, 'visionos') && 'visionos' || startsWith(matrix.platform, 'ios') && 'ios' || matrix.platform }} - name: Install npm dependencies if: steps.cache-slice.outputs.cache-hit != 'true' @@ -69,25 +72,47 @@ jobs: run: node scripts/ios-prebuild.js -b -f Debug -p ${{ matrix.platform }} - name: Save slice cache - if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable') }} + if: ${{ steps.cache-slice.outputs.cache-hit != 'true' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable')) }} uses: actions/cache/save@v4 with: - key: v1-macos-core-${{ matrix.platform }}-Debug-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: ${{ steps.cache-slice.outputs.cache-primary-key }} path: | packages/react-native/.build/output/spm/Debug/Build/Products packages/react-native/.build/headers + packages/react-native/.build/artifacts/hermes/destroot/include + packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + + # Transfer the exact headers used by the iOS compile-gate slice, including + # on cache hits. Do not resolve a potentially different dependency here. + - name: Upload Hermes headers + if: matrix.platform == 'ios-simulator' + uses: actions/upload-artifact@v4 + with: + name: prebuild-macos-core-hermes-headers-Debug + path: packages/react-native/.build/artifacts/hermes/destroot/include + if-no-files-found: error + + - name: Upload dependency headers + if: matrix.platform == 'ios-simulator' + uses: actions/upload-artifact@v4 + with: + name: prebuild-macos-core-dependency-headers-Debug + path: packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + if-no-files-found: error - name: Upload headers uses: actions/upload-artifact@v4 with: name: prebuild-macos-core-headers-Debug-${{ matrix.platform }} path: packages/react-native/.build/headers + if-no-files-found: error - name: Upload slice artifacts uses: actions/upload-artifact@v4 with: name: prebuild-macos-core-slice-Debug-${{ matrix.platform }} path: packages/react-native/.build/output/spm/Debug/Build/Products + if-no-files-found: error compose-xcframework: name: 'Compose XCFramework (Debug)' @@ -104,17 +129,25 @@ jobs: id: cache-xcframework uses: actions/cache/restore@v4 with: - key: v1-macos-core-xcframework-Debug-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v2-macos-core-xcframework-Debug-${{ hashFiles('.github/workflows/microsoft-prebuild-macos-core.yml', '.github/workflows/microsoft-resolve-hermes.yml', '.github/actions/microsoft-setup-toolchain/action.yml', '.github/scripts/resolve-hermes.mts', 'yarn.lock', 'packages/react-native/package.json', 'packages/react-native/Package.swift', 'packages/react-native/**/*.podspec', 'packages/react-native/sdks/.hermes*version', 'packages/react-native/sdks/hermes-engine/version.properties', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild/headers-include-baseline.json', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} path: | packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz + packages/react-native/.build/output/xcframeworks/ReactNativeHeadersDebug.xcframework.tar.gz - name: Setup toolchain if: steps.cache-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/microsoft-setup-toolchain with: node-version: '22' - platform: ios + platform: visionos + + - name: Verify compose SDKs + if: steps.cache-xcframework.outputs.cache-hit != 'true' + run: | + for sdk in iphoneos iphonesimulator macosx xros xrsimulator; do + xcrun --sdk "$sdk" --show-sdk-path + done - name: Install npm dependencies if: steps.cache-xcframework.outputs.cache-hit != 'true' @@ -136,24 +169,45 @@ jobs: path: packages/react-native/.build/headers merge-multiple: true + - name: Download Hermes headers + if: steps.cache-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v4 + with: + name: prebuild-macos-core-hermes-headers-Debug + path: packages/react-native/.build/artifacts/hermes/destroot/include + + - name: Download dependency headers + if: steps.cache-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v4 + with: + name: prebuild-macos-core-dependency-headers-Debug + path: packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers + - name: Verify downloaded artifacts if: steps.cache-xcframework.outputs.cache-hit != 'true' run: | - echo "=== Products directory ===" - ls -R packages/react-native/.build/output/spm/Debug/Build/Products/ | head -40 - echo "=== Headers directory ===" - ls packages/react-native/.build/headers/ | head -20 + test -d packages/react-native/.build/output/spm/Debug/Build/Products + test -d packages/react-native/.build/headers + test -f packages/react-native/.build/artifacts/hermes/destroot/include/hermes/hermes.h + test -d packages/react-native/third-party/ReactNativeDependencies.xcframework/Headers - name: Create XCFramework if: steps.cache-xcframework.outputs.cache-hit != 'true' working-directory: packages/react-native - run: node scripts/ios-prebuild -c -f Debug + run: node scripts/ios-prebuild -c -f Debug --require-hermes + + - name: Verify composed headers (iOS Simulator) + if: steps.cache-xcframework.outputs.cache-hit != 'true' + working-directory: packages/react-native + run: node scripts/ios-prebuild/headers-verify.js --flavor Debug - - name: Compress XCFramework + - name: Name XCFramework archives if: steps.cache-xcframework.outputs.cache-hit != 'true' + working-directory: packages/react-native/.build/output/xcframeworks run: | - cd packages/react-native/.build/output/xcframeworks/Debug - tar -cz -f ../ReactCoreDebug.xcframework.tar.gz React.xcframework + # The composer already packages React AND ReactNativeHeaders together. + cp Debug/React.xcframework.tar.gz ReactCoreDebug.xcframework.tar.gz + cp Debug/ReactNativeHeaders.xcframework.tar.gz ReactNativeHeadersDebug.xcframework.tar.gz - name: Compress dSYMs if: steps.cache-xcframework.outputs.cache-hit != 'true' @@ -161,14 +215,23 @@ jobs: cd packages/react-native/.build/output/xcframeworks/Debug/Symbols tar -cz -f ../../ReactCoreDebug.framework.dSYM.tar.gz . + - name: Verify archive payloads + working-directory: packages/react-native/.build/output/xcframeworks + run: | + # Also check cache hits before publishing the restored archives. + tar -tzf ReactCoreDebug.xcframework.tar.gz React.xcframework/Info.plist ReactNativeHeaders.xcframework/Info.plist + tar -tzf ReactNativeHeadersDebug.xcframework.tar.gz ReactNativeHeaders.xcframework/Info.plist + tar -tzf ReactCoreDebug.framework.dSYM.tar.gz > /dev/null + - name: Save compose cache - if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable') }} + if: ${{ steps.cache-xcframework.outputs.cache-hit != 'true' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '-stable')) }} uses: actions/cache/save@v4 with: - key: v1-macos-core-xcframework-Debug-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactApple/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: ${{ steps.cache-xcframework.outputs.cache-primary-key }} path: | packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz + packages/react-native/.build/output/xcframeworks/ReactNativeHeadersDebug.xcframework.tar.gz - name: Upload XCFramework uses: actions/upload-artifact@v4 @@ -176,6 +239,7 @@ jobs: name: ReactCoreDebug.xcframework.tar.gz path: packages/react-native/.build/output/xcframeworks/ReactCoreDebug.xcframework.tar.gz retention-days: 14 + if-no-files-found: error - name: Upload dSYMs uses: actions/upload-artifact@v4 @@ -183,3 +247,12 @@ jobs: name: ReactCoreDebug.framework.dSYM.tar.gz path: packages/react-native/.build/output/xcframeworks/ReactCoreDebug.framework.dSYM.tar.gz retention-days: 14 + if-no-files-found: error + + - name: Upload ReactNativeHeaders + uses: actions/upload-artifact@v4 + with: + name: ReactNativeHeadersDebug.xcframework.tar.gz + path: packages/react-native/.build/output/xcframeworks/ReactNativeHeadersDebug.xcframework.tar.gz + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index ba96d1fc19ae..2d2063615602 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -127,7 +127,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/.build/output/xcframeworks - key: v4-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + key: v5-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} - name: Setup node.js if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/setup-node @@ -223,6 +223,16 @@ jobs: run: | cd packages/react-native/.build/output/xcframeworks/${{matrix.flavor}}/Symbols tar -cz -f ../../ReactCore${{ matrix.flavor }}.framework.dSYM.tar.gz . + - name: Rename ReactNativeHeaders XCFramework tarball + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + run: | + cp packages/react-native/.build/output/xcframeworks/${{matrix.flavor}}/ReactNativeHeaders.xcframework.tar.gz \ + packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz + - name: Upload ReactNativeHeaders XCFramework Artifact + uses: actions/upload-artifact@v6 + with: + name: ReactNativeHeaders${{ matrix.flavor }}.xcframework.tar.gz + path: packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz - name: Upload XCFramework Artifact uses: actions/upload-artifact@v6 with: @@ -240,4 +250,5 @@ jobs: path: | packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.xcframework.tar.gz packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.framework.dSYM.tar.gz - key: v4-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} + packages/react-native/.build/output/xcframeworks/ReactNativeHeaders${{matrix.flavor}}.xcframework.tar.gz + key: v5-ios-core-xcframework-${{ matrix.flavor }}-${{ hashFiles('packages/react-native/Package.swift', 'packages/react-native/scripts/ios-prebuild/*.js', 'packages/react-native/scripts/ios-prebuild.js', 'packages/react-native/React/**/*', 'packages/react-native/ReactCommon/**/*', 'packages/react-native/Libraries/**/*') }} diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index a61426b0feb3..deb0a4fdb501 100644 --- a/.github/workflows/prebuild-ios-dependencies.yml +++ b/.github/workflows/prebuild-ios-dependencies.yml @@ -130,7 +130,7 @@ jobs: with: path: | packages/react-native/third-party/ - key: v5-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js', 'packages/react-native/scripts/ios-prebuild/headers-xcframework.js', 'packages/react-native/scripts/ios-prebuild/headers-spec.js') }} + key: v6-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js', 'packages/react-native/scripts/ios-prebuild/headers-xcframework.js', 'packages/react-native/scripts/ios-prebuild/headers-spec.js') }} # If cache hit, we already have our binary. We don't need to do anything. - name: Yarn Install if: steps.restore-xcframework.outputs.cache-hit != 'true' @@ -166,6 +166,16 @@ jobs: tar -cz -f packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz \ packages/react-native/third-party/ReactNativeDependencies.xcframework \ packages/react-native/third-party/ReactNativeDependenciesHeaders.xcframework + - name: Compress Headers Sidecar XCFramework + if: steps.restore-xcframework.outputs.cache-hit != 'true' + run: | + tar -cz -f packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz \ + packages/react-native/third-party/ReactNativeDependenciesHeaders.xcframework + - name: Upload Headers Sidecar XCFramework Artifact + uses: actions/upload-artifact@v6 + with: + name: ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz + path: packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz - name: Show Symbol folder content if: steps.restore-xcframework.outputs.cache-hit != 'true' run: ls -lR packages/react-native/third-party/Symbols @@ -192,5 +202,6 @@ jobs: with: path: | packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + packages/react-native/third-party/ReactNativeDependenciesHeaders${{ matrix.flavor }}.xcframework.tar.gz packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.framework.dSYM.tar.gz - key: v5-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js', 'packages/react-native/scripts/ios-prebuild/headers-xcframework.js', 'packages/react-native/scripts/ios-prebuild/headers-spec.js') }} + key: v6-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js', 'scripts/releases/ios-prebuild/compose-framework.js', 'packages/react-native/scripts/ios-prebuild/headers-xcframework.js', 'packages/react-native/scripts/ios-prebuild/headers-spec.js') }} diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js index 27cc84e54b11..742f1b89d16b 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -10,12 +10,20 @@ 'use strict'; +const resources = require('../framework-resources'); const { COMPOSE_TOOLING_FILES, + buildReactNativeHeadersXcframework, composeToolingHash, emitReactFrameworkHeaders, + ensureHeadersLayout, } = require('../headers-compose'); +const inventory = require('../headers-inventory'); +const spec = require('../headers-spec'); +const xcframework = require('../headers-xcframework'); +const childProcess = require('child_process'); const fs = require('fs'); +const os = require('os'); const path = require('path'); describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', () => { @@ -135,9 +143,7 @@ test('header sidecar composition uses the binary macOS slice instead of iOS defa namespaceModules: {}, }, root, - false, - null, - '/binary/React.xcframework', + emitter.stubSlicesFromXcframework('/binary/React.xcframework'), ); expect(recipe).toHaveBeenCalledWith('/binary/React.xcframework'); expect(compose).toHaveBeenCalledWith( @@ -152,3 +158,138 @@ test('header sidecar composition uses the binary macOS slice instead of iOS defa fs.rmSync(root, {recursive: true, force: true}); } }); + +describe('binary-derived header sidecars', () => { + let tmp = ''; + const plan = { + react: [], + reactNativeHeaders: [], + umbrella: [], + namespaceUmbrellas: [], + namespaceModules: {}, + depsNamespaces: [], + collisions: [], + privateReactHeaders: {modular: [], textual: []}, + }; + const writeBinary = (name, platform) => { + const dir = path.join(tmp, name + '.xcframework'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'Info.plist'), + JSON.stringify({ + AvailableLibraries: [ + {SupportedPlatform: platform, SupportedArchitectures: ['arm64']}, + ], + }), + ); + return dir; + }; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'header-slices-test-')); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest + .spyOn(inventory, 'computeInventory') + .mockReturnValue({headers: [], collisions: []}); + jest.spyOn(spec, 'planFromInventory').mockReturnValue(plan); + jest.spyOn(resources, 'buildReactPrivacyManifest').mockReturnValue(null); + jest.spyOn(resources, 'collectLprojDirs').mockReturnValue([]); + jest + .spyOn(childProcess, 'execFileSync') + .mockImplementation((command, args) => { + if (command === 'plutil') { + return fs.readFileSync(args[args.length - 1]); + } + if (command === '/bin/cp') { + fs.cpSync(args[1], args[2], {recursive: true}); + return Buffer.from(''); + } + throw new Error(`Unexpected native command: ${command}`); + }); + jest + .spyOn(xcframework, 'composeHeadersOnlyXcframework') + .mockImplementation((out, name) => { + const dir = path.join(out, name + '.xcframework'); + fs.mkdirSync(dir, {recursive: true}); + return dir; + }); + jest + .spyOn(xcframework, 'buildDepsHeadersXcframework') + .mockImplementation(out => { + const dir = path.join( + out, + 'ReactNativeDependenciesHeaders.xcframework', + ); + fs.mkdirSync(dir, {recursive: true}); + return dir; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + test('passes the supplied slices unchanged to the RN emitter', () => { + const slices = [ + {name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}, + ]; + buildReactNativeHeadersXcframework(tmp, plan, tmp, slices); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenCalledWith( + tmp, + 'ReactNativeHeaders', + expect.any(String), + slices, + ); + }); + + test('consumer derives each sidecar from its own binary and refreshes changed deps slices', () => { + writeBinary('React', 'macos'); + const deps = writeBinary('ReactNativeDependencies', 'xros'); + const out = path.join(tmp, 'composed'); + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenLastCalledWith( + out, + 'ReactNativeHeaders', + expect.any(String), + [{name: 'macos', sdk: 'macosx', targets: ['arm64-apple-macosx11.0']}], + ); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenLastCalledWith( + out, + path.join(deps, 'Headers'), + [], + [{name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}], + ); + + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.composeHeadersOnlyXcframework).toHaveBeenCalledTimes(1); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenCalledTimes(1); + + writeBinary('ReactNativeDependencies', 'ios'); + ensureHeadersLayout(tmp, tmp, out); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenLastCalledWith( + out, + path.join(deps, 'Headers'), + [], + [{name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}], + ); + expect(xcframework.buildDepsHeadersXcframework).toHaveBeenCalledTimes(2); + }); + + test.each(['React', 'ReactNativeDependencies'])( + 'rejects invalid %s metadata before emitting either sidecar', + name => { + writeBinary('React', 'macos'); + writeBinary('ReactNativeDependencies', 'xros'); + fs.writeFileSync( + path.join(tmp, name + '.xcframework', 'Info.plist'), + '{}', + ); + expect(() => + ensureHeadersLayout(tmp, tmp, path.join(tmp, 'composed')), + ).toThrow(/non-empty AvailableLibraries/); + expect(xcframework.composeHeadersOnlyXcframework).not.toHaveBeenCalled(); + expect(xcframework.buildDepsHeadersXcframework).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-conditional-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-conditional-test.js new file mode 100644 index 000000000000..588af71db7c7 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-conditional-test.js @@ -0,0 +1,315 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license in the root LICENSE file. + * @format + * @noflow + */ + +const headers = require('../headers'); +const { + buildInventory, + computeInventory, + scanHeader, +} = require('../headers-inventory'); +const { + collectIncludeHealth, + diffAgainstBaseline, +} = require('../headers-verify'); +const fs = require('fs'); +const path = require('path'); + +const root = '/header-inventory-fixture'; + +function classify(text, targets = []) { + jest.spyOn(headers, 'getHeaderFilesFromPodspecs').mockReturnValue({ + fixture: [ + { + specName: 'Fixture', + headerDir: '', + headers: [ + {source: path.join(root, 'source/A.h'), target: 'ns/A.h'}, + ...targets.map(([target, source]) => ({ + source: path.join(root, source), + target, + })), + ], + }, + ], + }); + jest + .spyOn(fs, 'readFileSync') + .mockImplementation(file => + file === path.join(root, 'source/A.h') ? text : '', + ); + return computeInventory(root); +} + +afterEach(() => jest.restoreAllMocks()); + +const excluded = [ + '#ifdef __ANDROID__', + '#ifdef ANDROID', + '#if defined(__ANDROID__)', + '#if defined __ANDROID__', + '#if (__ANDROID__)', + '#if defined(ANDROID)', + '#if defined(__ANDROID__) && UNKNOWN_FEATURE', + '#if UNKNOWN_FEATURE && (defined(__ANDROID__))', + '#if !(!defined(__ANDROID__))', + '#if defined /* comment */ (__ANDROID__)', + '#if defined(__ANDROID__) && \\\n UNKNOWN_FEATURE', +]; +const eligible = [ + '#if !defined(__ANDROID__)', + '#ifndef __ANDROID__', + '#if !__ANDROID__', + '#if UNKNOWN_PLATFORM', + '#if defined(__ANDROID__) || UNKNOWN_FEATURE', + '#if defined(__ANDROID__) == UNKNOWN_FEATURE', + '#if defined(__ANDROID__) ? UNKNOWN_FEATURE : OTHER_FEATURE', + '#if defined(__ANDROID__) + UNKNOWN_FEATURE', + '#if defined(__ANDROID__)_SUFFIX', + '#if defined(__APPLE__) && TARGET_OS_OSX', + '#if defined(TARGET_OS_OSX) && TARGET_OS_OSX', + '#if !TARGET_OS_OSX', +]; + +test.each(excluded)('excludes only a proven non-Apple branch: %s', guard => { + const inventory = classify( + `${guard}\n#include "platform/android/Missing.h"\n#include \n#endif`, + ); + expect(collectIncludeHealth(inventory)).toEqual([]); + expect(inventory.headers[0].includes.otherPlatform).toEqual([ + '"platform/android/Missing.h"', + 'ns/Missing.h', + ]); +}); + +test.each(eligible)('keeps a possibly Apple branch visible: %s', guard => { + expect( + collectIncludeHealth( + classify(`${guard}\n#include "platform/android/Missing.h"\n#endif`), + ), + ).toEqual(['quotedNotShipped ns/A.h -> "platform/android/Missing.h"']); +}); + +test('does not hide unguarded Android-spelled imports', () => { + expect( + collectIncludeHealth(classify('#include "platform/android/Missing.h"')), + ).toHaveLength(1); +}); + +test.each(['if', 'elif'])( + 'reads complete #%s expressions across multiline comments', + directive => { + const prefix = directive === 'elif' ? '#if defined(__ANDROID__)\n' : ''; + const text = `${prefix}#${directive} defined(__ANDROID__) /* split\n comment */ || defined(__APPLE__)\n#include "platform/macos/Missing.h"\n#endif`; + expect(collectIncludeHealth(classify(text))).toEqual([ + 'quotedNotShipped ns/A.h -> "platform/macos/Missing.h"', + ]); + }, +); + +test.each(['\r\n', '\r', 'mixed'])( + 'normalizes line endings and directive comments: %p', + newline => { + const guards = [ + ...[ + '__ANDROID__', + 'ANDROID', + '__APPLE__', + 'TARGET_OS_OSX', + 'UNKNOWN_PLATFORM', + '__cplusplus', + ].flatMap(macro => [ + `#ifdef ${macro}`, + `#ifndef ${macro}`, + `#if ${macro}`, + `#if !${macro}`, + `#if defined(${macro})`, + `#if !defined ${macro}`, + ]), + '#if defined(__APPLE__) && TARGET_OS_OSX', + '#if defined(TARGET_OS_OSX) && TARGET_OS_OSX', + '#if defined(__ANDROID__) && UNKNOWN_FEATURE', + '#if defined(__ANDROID__) || UNKNOWN_FEATURE', + '#if defined(__ANDROID__) == UNKNOWN_FEATURE', + '#if defined(__ANDROID__) ? UNKNOWN_FEATURE : OTHER_FEATURE', + '#if defined(__ANDROID__) /* split\n comment */ || defined(__APPLE__)', + '#if defined(__ANDROID__) && \\\n UNKNOWN_FEATURE', + ].flatMap(guard => [ + guard, + '#if UNKNOWN_FEATURE // first branch\n' + + guard + .replace(/^#ifdef (.*)$/, '#elif defined($1)') + .replace(/^#ifndef (.*)$/, '#elif !defined($1)') + .replace(/^#if\b/, '#elif'), + ]); + for (const guard of guards) { + for (let mask = 0; mask < 16; mask++) { + const comment = bit => + Math.floor(mask / 2 ** bit) % 2 ? ' // comment' : ''; + const lf = `${guard}${comment(0)}\n#include "Branch.h"\n#elif defined(__ANDROID__)${comment(1)}\n#include "Android.h"\n#else${comment(2)}\n#ifdef __cplusplus\n#include \n#endif\n#endif${comment(3)}\n#include "platform/macos/Missing.h"\n`; + let i = 0; + const converted = lf.replace(/\n/g, () => + newline === 'mixed' ? ['\n', '\r\n', '\r'][i++ % 3] : newline, + ); + expect(scanHeader(converted)).toEqual(scanHeader(lf)); + const expected = collectIncludeHealth(classify(lf)); + expect(expected).toContain( + 'quotedNotShipped ns/A.h -> "platform/macos/Missing.h"', + ); + expect(collectIncludeHealth(classify(converted))).toEqual(expected); + } + } + }, +); + +test('restores outer conditions and preserves independent C++ guards', () => { + const text = + '#ifdef __cplusplus\n#if defined(__ANDROID__)\n#include "Android.h"\n#else\n#include \n#endif\n#endif\n#include "Apple.h"'; + expect(scanHeader(text).includes).toEqual([ + {token: '"Android.h"', cxxGuarded: true, appleExcluded: true}, + {token: 'folly/dynamic.h', cxxGuarded: true}, + {token: '"Apple.h"', cxxGuarded: false}, + ]); +}); + +test.each(['\n', '\r\n'])( + 'splices newlines before recognizing block delimiters: %p', + newline => { + const text = [ + '#if defined(__ANDROID__) /\\', + '* split', + '#endif // inside block', + 'comment *\\', + '/ || UNKNOWN_FEATURE', + '#include "Apple.h"', + '#endif', + ].join(newline); + expect(scanHeader(text).includes).toEqual([ + {token: '"Apple.h"', cxxGuarded: false}, + ]); + }, +); + +test('line comments and quoted include tokens do not open block comments', () => { + const text = [ + '// /* not a block', + '#include "path//Apple.h"', + '// continued \\', + '#if defined(__ANDROID__)', + '#include "Apple.h"', + ].join('\n'); + expect(scanHeader(text).includes).toEqual([ + {token: '"path//Apple.h"', cxxGuarded: false}, + {token: '"Apple.h"', cxxGuarded: false}, + ]); +}); + +test.each(['if', 'elif'])( + 'real-source #%s regression remains visible with multiline comments', + directive => { + const rnRoot = path.resolve(__dirname, '../../..'); + const before = collectIncludeHealth(computeInventory(rnRoot)); + const wrapper = 'react/renderer/components/view/HostPlatformViewProps.h'; + const file = path.join(rnRoot, 'ReactCommon', wrapper); + const read = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((name, ...args) => { + const text = read(name, ...args); + return name === file + ? `${text}\n${directive === 'elif' ? '#if defined(__ANDROID__)\n' : ''}#${directive} defined(__ANDROID__) /* split\n comment */ || defined(__APPLE__)\n#include "platform/macos/Missing.h"\n#endif\n` + : text; + }); + expect( + diffAgainstBaseline( + collectIncludeHealth(computeInventory(rnRoot)), + before, + ), + ).toEqual({ + newOffenders: [ + `quotedNotShipped ${wrapper} -> "platform/macos/Missing.h"`, + ], + resolved: [], + }); + }, +); + +test.each(['sub/B.h', './sub/B.h', 'sub/../sub/B.h', '../ns/sub/B.h'])( + 'resolves packaged relative path %s', + token => { + const inventory = classify(`#include "${token}"`, [ + ['ns/sub/B.h', 'elsewhere/B.h'], + ]); + expect( + inventory.headers.find(h => h.naturalPath === 'ns/A.h').includes.internal, + ).toEqual([{naturalPath: 'ns/sub/B.h', cxxGuarded: false}]); + }, +); + +test('prefers packaged siblings over root and source spellings', () => { + const inventory = classify('#ifdef __cplusplus\n#include "sub/B.h"\n#endif', [ + ['ns/sub/B.h', 'elsewhere/B.h'], + ['sub/B.h', 'root/B.h'], + ['relocated/B.h', 'source/sub/B.h'], + ]); + expect( + inventory.headers.find(h => h.naturalPath === 'ns/A.h').includes.internal, + ).toEqual([{naturalPath: 'ns/sub/B.h', cxxGuarded: true}]); +}); + +test.each(['missing/B.h', '../../other/B.h', '/other/B.h'])( + 'does not invent a packaged target for %s', + token => { + expect( + collectIncludeHealth( + classify(`#include "${token}"`, [['other/B.h', 'elsewhere/B.h']]), + ), + ).toEqual([`quotedNotShipped ns/A.h -> "${token}"`]); + }, +); + +test.each([2, 3])('never suppresses %s conflicting physical sources', count => { + const sources = [ + 'ReactCommon/react/renderer/components/view/HostPlatformTouch.h', + 'ReactCommon/react/renderer/components/view/platform/cxx/react/renderer/components/view/HostPlatformTouch.h', + 'unexpected/Header.h', + ].slice(0, count); + jest.spyOn(headers, 'getHeaderFilesFromPodspecs').mockReturnValue({ + fixture: [ + { + specName: 'React-Fabric', + headerDir: '', + headers: sources.map(source => ({ + source: path.join(root, source), + target: 'react/renderer/components/view/HostPlatformTouch.h', + })), + }, + ], + }); + const inventory = buildInventory(root); + expect(inventory.collisions).toEqual([ + { + naturalPath: 'react/renderer/components/view/HostPlatformTouch.h', + sources: [...sources].sort(), + }, + ]); + expect(inventory.entries.size).toBe(1); // No donor-only physical aliases. +}); + +test('real inventory retains 1154 physical entries and six resolved baseline removals', () => { + const rnRoot = path.resolve(__dirname, '../../..'); + const inventory = computeInventory(rnRoot); + const baseline = JSON.parse( + fs.readFileSync( + path.join(__dirname, '../headers-include-baseline.json'), + 'utf8', + ), + ); + expect(inventory.headers).toHaveLength(1154); + expect(inventory.collisions).toEqual([]); + expect(baseline).toHaveLength(21); + expect( + diffAgainstBaseline(collectIncludeHealth(inventory), baseline), + ).toEqual({newOffenders: [], resolved: []}); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js new file mode 100644 index 000000000000..a637189ce866 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-verify-test.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license in the root LICENSE file. + * @format + * @noflow + */ + +const inventory = require('../headers-inventory'); +const spec = require('../headers-spec'); +const {main} = require('../headers-verify'); +const fs = require('fs'); + +afterEach(() => jest.restoreAllMocks()); + +test.each([[], ['--skip-compile'], ['--update-baseline']].map(argv => [argv]))( + 'rejects physical-source collisions before plan or baseline writes: %p', + argv => { + jest.spyOn(inventory, 'computeInventory').mockReturnValue({ + headers: [], + collisions: [ + {naturalPath: 'ns/A.h', sources: ['first/A.h', 'extra/A.h']}, + ], + }); + const plan = jest.spyOn(spec, 'planFromInventory'); + const write = jest.spyOn(fs, 'writeFileSync'); + expect(() => main(argv)).toThrow(/natural-path collisions \(R8\)/); + expect(plan).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }, +); + +test('retains the destination collision gate after inventory succeeds', () => { + jest + .spyOn(inventory, 'computeInventory') + .mockReturnValue({headers: [], collisions: []}); + jest + .spyOn(spec, 'planFromInventory') + .mockReturnValue({collisions: ['destination conflict']}); + expect(() => main([])).toThrow(/R8 collisions:\n {2}destination conflict/); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js index 6fc781504218..98a64a69bc00 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js @@ -12,6 +12,7 @@ const { buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, stubSlicesFromXcframework, } = require('../headers-xcframework'); const childProcess = require('child_process'); @@ -110,4 +111,221 @@ describe('stubSlicesFromXcframework', () => { /no stub recipe for slice 'watchos'[\s\S]*PLATFORM_STUB_RECIPES/, ); }); + + const iosLibraries = [ + {SupportedPlatform: 'ios', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ]; + + test('matches all five Microsoft platforms without adding Catalyst or architectures', () => { + const exec = mockPlist({ + AvailableLibraries: [ + ...iosLibraries, + { + SupportedPlatform: 'macos', + SupportedArchitectures: ['x86_64', 'arm64'], + }, + {SupportedPlatform: 'xros', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'xros', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64'], + }, + ], + }); + expect(stubSlicesFromXcframework('/React.xcframework')).toEqual([ + {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', + ], + }, + { + name: 'macos', + sdk: 'macosx', + targets: ['x86_64-apple-macosx11.0', 'arm64-apple-macosx11.0'], + }, + {name: 'xros', sdk: 'xros', targets: ['arm64-apple-xros1.0']}, + { + name: 'xros-simulator', + sdk: 'xrsimulator', + targets: ['arm64-apple-xros1.0-simulator'], + }, + ]); + expect(exec).toHaveBeenCalledWith('plutil', [ + '-convert', + 'json', + '-o', + '-', + '/React.xcframework/Info.plist', + ]); + }); + + test('matches the three upstream platforms including Catalyst', () => { + mockPlist({ + AvailableLibraries: [ + ...iosLibraries, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'maccatalyst', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ], + }); + const slices = stubSlicesFromXcframework('/React.xcframework'); + expect(slices.map(s => s.name)).toEqual([ + 'ios', + 'ios-simulator', + 'ios-maccatalyst', + ]); + expect(slices[2]).toEqual({ + name: 'ios-maccatalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], + }); + }); + + test.each([ + ['macos', undefined, 'macosx', 'macosx11.0'], + ['ios', 'simulator', 'iphonesimulator', 'ios15.0-simulator'], + ['xros', undefined, 'xros', 'xros1.0'], + ['tvos', undefined, 'appletvos', 'tvos15.1'], + ['tvos', 'simulator', 'appletvsimulator', 'tvos15.1-simulator'], + ])('preserves a single %s / %s slice', (platform, variant, sdk, target) => { + mockPlist({ + AvailableLibraries: [ + { + SupportedPlatform: platform, + SupportedPlatformVariant: variant, + SupportedArchitectures: ['arm64'], + }, + ], + }); + expect(stubSlicesFromXcframework('/single.xcframework')).toEqual([ + { + name: variant == null ? platform : `${platform}-${variant}`, + sdk, + targets: [`arm64-apple-${target}`], + }, + ]); + }); + + test.each([ + null, + {}, + {AvailableLibraries: null}, + {AvailableLibraries: {}}, + {AvailableLibraries: []}, + ])('rejects missing or empty libraries: %p', plist => { + mockPlist(plist); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /non-empty AvailableLibraries/, + ); + }); + + test.each([ + null, + {}, + {SupportedPlatform: ''}, + {SupportedPlatform: 1}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: ''}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: null}, + {SupportedPlatform: 'ios', SupportedPlatformVariant: 1}, + ])('rejects malformed platform metadata: %p', lib => { + mockPlist({AvailableLibraries: [lib]}); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /invalid platform metadata/, + ); + }); + + test.each(['watchos', 'visionos', 'toString', '__proto__'])( + 'rejects unknown platform %s', + platform => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: platform, SupportedArchitectures: ['arm64']}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /no stub recipe/, + ); + }, + ); + + test('rejects an unknown variant', () => { + mockPlist({ + AvailableLibraries: [ + {...iosLibraries[0], SupportedPlatformVariant: 'unknown'}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /no stub recipe for slice 'ios-unknown'/, + ); + }); + + test.each( + [ + undefined, + null, + [], + 'arm64', + [null], + [1], + [''], + ['arm64-apple-ios'], + ['arm64', 'arm64'], + ].map(archs => [archs]), + )( + 'rejects malformed architectures instead of inventing a default: %p', + archs => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'ios', SupportedArchitectures: archs}, + ], + }); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /invalid SupportedArchitectures/, + ); + }, + ); + + test('rejects duplicate platform/variant entries', () => { + mockPlist({AvailableLibraries: [iosLibraries[0], iosLibraries[0]]}); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /duplicate slice 'ios'/, + ); + }); + + test('reports invalid JSON with the artifact path', () => { + jest + .spyOn(childProcess, 'execFileSync') + .mockReturnValue(Buffer.from('not JSON')); + expect(() => stubSlicesFromXcframework('/bad.xcframework')).toThrow( + /failed to parse Info.plist of \/bad.xcframework/, + ); + }); + + test('reports a missing or unreadable plist', () => { + jest.spyOn(childProcess, 'execFileSync').mockImplementation(() => { + throw new Error('missing plist'); + }); + expect(() => stubSlicesFromXcframework('/missing.xcframework')).toThrow( + /failed to parse Info.plist.*missing plist/, + ); + }); + + test('rejects empty emitter input before invoking native tools', () => { + const exec = jest.spyOn(childProcess, 'execFileSync'); + expect(() => + composeHeadersOnlyXcframework('/unused', 'Headers', '/unused', []), + ).toThrow(/requires non-empty slices/); + expect(exec).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js index 7957515ad02a..ec7034cd0b9f 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/hermes-version-test.js @@ -144,24 +144,3 @@ test('the checked-in compiler and source tag match the single Hermes metadata', ).toBe(`hermes-v${selected.version}`); } }); - -test('the default runtime, compiler, and source tag versions agree', () => { - const previous = process.env.RCT_HERMES_V1_ENABLED; - try { - delete process.env.RCT_HERMES_V1_ENABLED; - // CI and prebuild use V1 by default; the shared helper defaults to legacy. - const {version, tagFile} = readHermesMetadata('v1-default'); - const {dependencies} = require('../../../package.json'); - expect(dependencies['hermes-compiler']).toBe(version); - const tag = fs - .readFileSync(path.resolve(__dirname, '../../../sdks', tagFile), 'utf8') - .trim(); - expect(tag).toBe(`hermes-v${version}`); - } finally { - if (previous == null) { - delete process.env.RCT_HERMES_V1_ENABLED; - } else { - process.env.RCT_HERMES_V1_ENABLED = previous; - } - } -}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js index 210c41f2e40c..c6156bd29f21 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js @@ -10,7 +10,10 @@ 'use strict'; -const {resolveHermesHeaders} = require('../xcframework'); +const codegen = require('../../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); +const compose = require('../headers-compose'); +const {buildXCFrameworks, resolveHermesHeaders} = require('../xcframework'); +const childProcess = require('child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); @@ -68,3 +71,83 @@ describe('resolveHermesHeaders', () => { ); }); }); + +describe('producer header slices', () => { + afterEach(() => jest.restoreAllMocks()); + + test.each(['macos', 'xros', 'unknown'])( + 'derives the RN sidecar from the composed %s binary', + platform => { + jest + .spyOn(codegen, 'generateFBReactNativeSpecIOS') + .mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(fs, 'rmSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readdirSync').mockReturnValue([]); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const plan = {}; + jest.spyOn(compose, 'computeSpecPlan').mockReturnValue(plan); + jest + .spyOn(compose, 'emitReactFrameworkHeaders') + .mockImplementation(() => {}); + const emit = jest + .spyOn(compose, 'buildReactNativeHeadersXcframework') + .mockReturnValue( + '/build/output/xcframeworks/Debug/ReactNativeHeaders.xcframework', + ); + const exec = jest + .spyOn(childProcess, 'execFileSync') + .mockImplementation(command => { + if (command === 'plutil') { + return Buffer.from( + JSON.stringify({ + AvailableLibraries: [ + { + SupportedPlatform: platform, + SupportedArchitectures: ['arm64'], + }, + ], + }), + ); + } + return Buffer.from(''); + }); + if (platform === 'unknown') { + expect(() => + buildXCFrameworks('/root', '/build', [], 'Debug', null), + ).toThrow(/no stub recipe/); + expect(emit).not.toHaveBeenCalled(); + expect(exec.mock.calls.some(([command]) => command === 'tar')).toBe( + false, + ); + return; + } + buildXCFrameworks('/root', '/build', [], 'Debug', null); + expect(exec).toHaveBeenCalledWith('plutil', [ + '-convert', + 'json', + '-o', + '-', + '/build/output/xcframeworks/Debug/React.xcframework/Info.plist', + ]); + expect(emit).toHaveBeenCalledWith( + '/build/output/xcframeworks/Debug', + plan, + '/root', + [ + { + name: platform, + sdk: platform === 'macos' ? 'macosx' : 'xros', + targets: [ + platform === 'macos' + ? 'arm64-apple-macosx11.0' + : 'arm64-apple-xros1.0', + ], + }, + ], + null, + null, + ); + }, + ); +}); diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js index fa405851a9cf..658259d49690 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-compose.js +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -35,8 +35,6 @@ const { renderUmbrellaHeader, } = require('./headers-spec'); const { - CATALYST_STUB_SLICE, - DEFAULT_STUB_SLICES, buildDepsHeadersXcframework, composeHeadersOnlyXcframework, stubSlicesFromXcframework, @@ -78,6 +76,7 @@ function composeToolingHash() /*: string */ { } /*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */ +/*:: import type {StubSlice} from './headers-xcframework'; */ /** * Computes the spec plan from the live source tree. Throws on collisions @@ -259,14 +258,12 @@ function buildReactNativeHeadersXcframework( outDir /*: string */, plan /*: HeadersSpecPlan */, rnRoot /*: string */, - includeCatalyst /*: boolean */ = false, + slices /*: Array */, // Optional dir containing a `hermes/` namespace (Hermes public headers from // the hermes-ios tarball's destroot/include). Folded in as a textual // namespace so `` resolves without per-library wiring. null // when unstaged — then `` stays unavailable. hermesHeaders /*: ?string */ = null, - // [macOS] Derive sidecar platforms from the binary whenever one is available. - binaryXcfw /*: ?string */ = null, overlayDir /*: ?string */ = null, ) /*: string */ { // ---- stage headers ---- @@ -305,12 +302,6 @@ function buildReactNativeHeadersXcframework( ); // ---- compose (stub archives + create-xcframework) ---- - const slices = - binaryXcfw != null - ? stubSlicesFromXcframework(binaryXcfw) - : includeCatalyst - ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] - : DEFAULT_STUB_SLICES; const outXcfw = composeHeadersOnlyXcframework( outDir, 'ReactNativeHeaders', @@ -349,11 +340,13 @@ function ensureHeadersLayout( const sourceXcfw = fs.realpathSync( path.join(artifactsDir, 'React.xcframework'), ); - const depsHeaders = path.join( + const depsXcfw = path.join( artifactsDir, 'ReactNativeDependencies.xcframework', - 'Headers', ); + const depsHeaders = path.join(depsXcfw, 'Headers'); + const reactSlices = stubSlicesFromXcframework(sourceXcfw); + const depsSlices = stubSlicesFromXcframework(depsXcfw); // 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 @@ -376,7 +369,9 @@ function ensureHeadersLayout( // recomposes instead of reusing a hermes-less ReactNativeHeaders. The // compose-tooling hash makes a local edit to headers-{inventory,spec,compose} // recompose too (the source xcframework's Info.plist mtime can't detect that). - const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\ntooling:${composeToolingHash()}\n`; + // Include both binary slice sets so a changed deps platform set also + // invalidates the cached sidecars, even when React itself is unchanged. + const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\ntooling:${composeToolingHash()}\nslices:${JSON.stringify([reactSlices, depsSlices])}\n`; if ( !force && fs.existsSync(reactXcfw) && @@ -406,18 +401,15 @@ function ensureHeadersLayout( outDir, plan, rnRoot, - false, + reactSlices, hermesHeaders, - sourceXcfw, // [macOS] ); - // [macOS] Match each sidecar to its own binary's actual platform slices. + // Each sidecar matches its own binary, which may carry a different slice set. buildDepsHeadersXcframework( outDir, depsHeaders, plan.depsNamespaces, - stubSlicesFromXcframework( - path.join(artifactsDir, 'ReactNativeDependencies.xcframework'), - ), + depsSlices, ); fs.writeFileSync(markerPath, marker); return {reactXcfw, headersXcfw, depsHeadersXcfw}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json index 9221b6945ba3..94fcea3de137 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json +++ b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json @@ -8,12 +8,6 @@ "notShipped react/renderer/mounting/StubViewTree.h -> react/renderer/mounting/stubs/StubView.h", "notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubView.h", "notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubViewTree.h", - "quotedNotShipped RCTAnimation/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"", - "quotedNotShipped RCTAnimation/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"", - "quotedNotShipped RCTAnimation/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"", - "quotedNotShipped React/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"", - "quotedNotShipped React/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"", - "quotedNotShipped React/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"", "quotedNotShipped react/nativemodule/dom/NativeDOM.h -> \"FBReactNativeSpecJSI.h\"", "quotedNotShipped react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h -> \"FBReactNativeSpecJSI.h\"", "quotedNotShipped react/nativemodule/idlecallbacks/NativeIdleCallbacks.h -> \"FBReactNativeSpecJSI.h\"", diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js index b1aa586c384e..875cabcea009 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-inventory.js +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -47,6 +47,7 @@ type Identity = { type IncludeRef = { token: string, // text between <> or "" cxxGuarded: boolean, // true when only reachable under #ifdef __cplusplus + appleExcluded?: boolean, // proven unreachable in the Apple payload }; type HeaderEntry = { @@ -128,12 +129,145 @@ const SDK_PREFIXES = new Set([ 'sys', ]); +// Three-valued logic: unknown feature conditions must keep both branches. +function conditionNot(value /*: ?boolean */) /*: ?boolean */ { + return value == null ? null : !value; +} + +function conditionAnd(a /*: ?boolean */, b /*: ?boolean */) /*: ?boolean */ { + return a === false || b === false + ? false + : a === true && b === true + ? true + : null; +} + +/** + * Evaluate only Boolean platform guards, not arbitrary preprocessor syntax. + * Android macros are false for every Apple slice. Leave all other macros + * unknown, including TARGET_OS_OSX: both macOS and generic C++ paths ship. + * Unsupported expressions remain unknown rather than hiding dependencies. + */ +function appleCondition(expression /*: string */) /*: ?boolean */ { + const tokens = + expression.match(/defined\b|[A-Za-z_]\w*|&&|\|\||[!()]|\S/g) ?? []; + let index = 0; + let valid = true; + const macroValue = (name /*: ?string */) /*: ?boolean */ => + name === '__ANDROID__' || name === 'ANDROID' ? false : null; + const unary = () /*: ?boolean */ => { + const token = tokens[index++]; + if (token === '!') { + return conditionNot(unary()); + } + if (token === '(') { + const value = or(); + valid = tokens[index++] === ')' && valid; + return value; + } + if (token === 'defined') { + const parenthesized = tokens[index] === '('; + if (parenthesized) { + index++; + } + const name = tokens[index++]; + valid = /^[A-Za-z_]\w*$/.test(name ?? '') && valid; + if (parenthesized) { + valid = tokens[index++] === ')' && valid; + } + return macroValue(name); + } + if (/^[A-Za-z_]\w*$/.test(token ?? '')) { + return macroValue(token); + } + valid = false; + return null; + }; + const and = () /*: ?boolean */ => { + let value = unary(); + while (tokens[index] === '&&') { + index++; + value = conditionAnd(value, unary()); + } + return value; + }; + const or = () /*: ?boolean */ => { + let value = and(); + while (tokens[index] === '||') { + index++; + value = conditionNot( + conditionAnd(conditionNot(value), conditionNot(and())), + ); + } + return value; + }; + const value = or(); + return valid && index === tokens.length ? value : null; +} + +/** + * Normalize CRLF and standalone CR before splicing escaped newlines and + * recognizing comments. A block comment is one space, even across physical + * lines. Only a newline outside that comment ends the directive. Keep line + * comments and quoted tokens separate so their delimiters cannot change the + * comment state. + */ +function headerLogicalLines(text /*: string */) /*: Array */ { + const source = text.replace(/\r\n?/g, '\n').replace(/\\\n/g, ''); + const lines = []; + let line = ''; + let inBlockComment = false; + let inLineComment = false; + let quote = ''; + for (let i = 0; i < source.length; i++) { + const char = source[i]; + const next = source[i + 1]; + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + i++; + } + } else if (char === '\n') { + lines.push(line); + line = ''; + inLineComment = false; + quote = ''; + } else if (inLineComment) { + continue; + } else if (quote !== '') { + line += char; + if (char === quote) { + quote = ''; + } else if (char === '\\' && next != null && quote !== '>') { + line += next; + i++; + } + } else if (char === '/' && next === '*') { + line += ' '; + inBlockComment = true; + i++; + } else if (char === '/' && next === '/') { + inLineComment = true; + i++; + } else { + if (char === '"' || char === "'") { + quote = char; + } else if (char === '<' && /^\s*#\s*(?:include|import)\s*$/.test(line)) { + quote = '>'; + } + line += char; + } + } + lines.push(line); + return lines; +} + /** - * Scans a header's text line by line, tracking the preprocessor-conditional + * Scans a header's logical lines, 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. + * Includes retain an exclusion flag when their branch cannot run on Apple. + * Unknown conditions remain conservatively eligible for include resolution. */ function scanHeader(text /*: string */) /*: { includes: Array, @@ -149,6 +283,8 @@ function scanHeader(text /*: string */) /*: { // Stack frames: 'cpp' (only under __cplusplus), 'notcpp', 'other'. const stack /*: Array<'cpp' | 'notcpp' | 'other'> */ = []; const inCxxOnly = () => stack.includes('cpp'); + const platformStack /*: Array<{active: ?boolean, remaining: ?boolean}> */ = + []; const includeRe = /^\s*#\s*(?:include|import)\s+(?:<([^>]+)>|"([^"]+)")/; const objcRe = @@ -156,33 +292,30 @@ function scanHeader(text /*: string */) /*: { const cxxRe = /^\s*(namespace\s+[A-Za-z_]|template\s*<|extern\s+"C\+\+"|enum\s+class\b|constexpr\b|using\s+(namespace\s|[A-Za-z_]\w*\s*=))/; - // Track /* ... */ block comments across lines so a documentation line inside - // a comment (e.g. `namespace`, `template <`, `constexpr`) can't trip the C++ - // detector below and needlessly shrink the umbrella. - let inBlockComment = false; - for (const rawLine of text.split('\n')) { - let line = rawLine; - if (inBlockComment) { - const end = line.indexOf('*/'); - if (end === -1) { - continue; // whole line still inside a block comment - } - line = line.slice(end + 2); - inBlockComment = false; - } - // Drop complete inline block comments, then line comments (which also - // swallow any `/*` living inside a `//` comment), then detect a block - // comment that opens and runs onto the next line. - line = line.replace(/\/\*.*?\*\//g, ''); - line = line.replace(/\/\/.*$/, ''); - const blockOpen = line.indexOf('/*'); - if (blockOpen !== -1) { - inBlockComment = true; - line = line.slice(0, blockOpen); - } + for (const line of headerLogicalLines(text)) { const cond = line.match(/^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$/); if (cond) { const [, directive, rest] = cond; + if ( + directive === 'if' || + directive === 'ifdef' || + directive === 'ifndef' + ) { + const expression = + directive === 'if' ? rest : `defined(${rest.trim()})`; + const value = appleCondition(expression); + const active = directive === 'ifndef' ? conditionNot(value) : value; + platformStack.push({active, remaining: conditionNot(active)}); + } else if (directive === 'endif') { + platformStack.pop(); + } else { + const frame = platformStack[platformStack.length - 1]; + if (frame != null) { + const value = directive === 'else' ? true : appleCondition(rest); + frame.active = conditionAnd(frame.remaining, value); + frame.remaining = conditionAnd(frame.remaining, conditionNot(value)); + } + } const mentionsCpp = /__cplusplus/.test(rest); if (directive === 'ifdef' || directive === 'if') { stack.push( @@ -212,6 +345,9 @@ function scanHeader(text /*: string */) /*: { includes.push({ token: inc[1] != null ? inc[1] : `"${inc[2]}"`, cxxGuarded: inCxxOnly(), + ...(platformStack.some(frame => frame.active === false) + ? {appleExcluded: true} + : {}), }); } if (objcRe.test(line)) { @@ -442,13 +578,32 @@ function classifyEntries( 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 (inc.appleExcluded) { + // Keep the edge visible without resolving a non-Apple dependency. + entry.includes.otherPlatform.push(token); + continue; + } + // Quoted includes search the packaged sibling first, then the include + // root. Normalize subdirectories and dot segments in both spellings. if (token.startsWith('"')) { const quotedToken = token.slice(1, -1); - // [macOS] Stable dispatch headers contain inactive Android branches. - if (quotedToken.startsWith('platform/android/')) { - entry.includes.otherPlatform.push(quotedToken); + const packagedPaths = path.posix.isAbsolute(quotedToken) + ? [] + : [ + path.posix.join( + path.posix.dirname(entry.naturalPath), + quotedToken, + ), + path.posix.normalize(quotedToken), + ]; + const packagedPath = packagedPaths.find( + candidate => !candidate.startsWith('../') && entries.has(candidate), + ); + if (packagedPath != null) { + entry.includes.internal.push({ + naturalPath: packagedPath, + cxxGuarded: inc.cxxGuarded, + }); continue; } const resolved = path.resolve(path.dirname(absSource), quotedToken); diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index 30a9ce778954..48b4e8286791 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -495,6 +495,15 @@ function parseArgs(argv /*: Array */) /*: { function main(argv /*:: ?: Array */) /*: void */ { const args = parseArgs(argv ?? process.argv.slice(2)); const inventory = computeInventory(RN_ROOT); + // [macOS] Reject physical-source collisions before plan selection or baseline writes. + if (inventory.collisions.length > 0) { + const detail = inventory.collisions + .map(c => `${c.naturalPath} <- ${c.sources.join(', ')}`) + .join('\n '); + throw new Error( + `header-inventory natural-path collisions (R8):\n ${detail}`, + ); + } const plan = planFromInventory(inventory, RN_ROOT); if (plan.collisions.length > 0) { throw new Error(`R8 collisions:\n ${plan.collisions.join('\n ')}`); diff --git a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js index 2cdd85487f50..50369f56f815 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -41,26 +41,6 @@ export type StubSlice = { }; */ -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'], -}; - // SupportedPlatform(+variant) from an xcframework Info.plist -> stub recipe. // The min OS version in the triple only shapes the stub object file; slice // identity (what create-xcframework groups by) comes from platform + variant @@ -110,24 +90,60 @@ function stubSlicesFromXcframework( `headers-xcframework: failed to parse Info.plist of ${xcfwPath}: ${message}`, ); } + if ( + !Array.isArray(plist?.AvailableLibraries) || + plist.AvailableLibraries.length === 0 + ) { + throw new Error( + `headers-xcframework: ${xcfwPath} must have a non-empty AvailableLibraries array.`, + ); + } + const seen = new Set(); return plist.AvailableLibraries.map(lib => { + if ( + lib == null || + typeof lib.SupportedPlatform !== 'string' || + lib.SupportedPlatform.length === 0 || + (lib.SupportedPlatformVariant !== undefined && + (typeof lib.SupportedPlatformVariant !== 'string' || + lib.SupportedPlatformVariant.length === 0)) + ) { + throw new Error( + `headers-xcframework: invalid platform metadata in ${xcfwPath}.`, + ); + } const key = lib.SupportedPlatformVariant != null ? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}` : lib.SupportedPlatform; - const recipe = PLATFORM_STUB_RECIPES[key]; - if (recipe == null) { + if (!Object.prototype.hasOwnProperty.call(PLATFORM_STUB_RECIPES, key)) { throw new Error( `headers-xcframework: no stub recipe for slice '${key}' of ` + `${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`, ); } + const recipe = PLATFORM_STUB_RECIPES[key]; + const archs = lib.SupportedArchitectures; + if ( + !Array.isArray(archs) || + archs.length === 0 || + archs.some(a => typeof a !== 'string' || !/^[A-Za-z0-9_]+$/.test(a)) || + new Set(archs).size !== archs.length + ) { + throw new Error( + `headers-xcframework: invalid SupportedArchitectures for slice '${key}' of ${xcfwPath}.`, + ); + } + if (seen.has(key)) { + throw new Error( + `headers-xcframework: duplicate slice '${key}' of ${xcfwPath}.`, + ); + } + seen.add(key); return { name: key, sdk: recipe.sdk, - targets: lib.SupportedArchitectures.map( - a => `${a}-apple-${recipe.os}${recipe.suffix}`, - ), + targets: archs.map(a => `${a}-apple-${recipe.os}${recipe.suffix}`), }; }); } @@ -144,6 +160,9 @@ function composeHeadersOnlyXcframework( stage /*: string */, slices /*: Array */, ) /*: string */ { + if (!Array.isArray(slices) || slices.length === 0) { + throw new Error(`headers-xcframework: ${name} requires non-empty slices.`); + } const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); // try/finally so an xcrun/xcodebuild failure mid-compose doesn't leave the // .stub-work-* staging dir behind in outDir. @@ -273,8 +292,6 @@ function buildDepsHeadersXcframework( } module.exports = { - CATALYST_STUB_SLICE, - DEFAULT_STUB_SLICES, DEPS_HEADERS_XCFRAMEWORK_NAME, buildDepsHeadersXcframework, composeHeadersOnlyXcframework, diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index 12c34d51319f..81f88ff1448b 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -147,9 +147,11 @@ async function prepareReactNativeDependenciesArtifactsAsync( 'ReactNativeDependenciesHeaders.xcframework', ); if (fs.existsSync(headersSidecarSource)) { - execSync(`cp -R "${headersSidecarSource}" "${artifactsPath}"`, { - stdio: 'inherit', - }); + fs.cpSync( + headersSidecarSource, + path.join(artifactsPath, path.basename(headersSidecarSource)), + {recursive: true}, + ); } else { dependencyLog( 'ReactNativeDependenciesHeaders.xcframework not present in the tarball ' + diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 9ebf1037fc23..c1fb1c788c4f 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,6 +13,7 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); +const {stubSlicesFromXcframework} = require('./headers-xcframework'); const utils = require('./utils'); const childProcess = require('child_process'); const fs = require('fs'); @@ -141,9 +142,8 @@ function buildXCFrameworks( path.dirname(outputPath), plan, rootFolder, - true, // include the mac-catalyst slice in the real compose + stubSlicesFromXcframework(outputPath), hermesHeaders, - outputPath, // [macOS] Match the binary, including its macOS slice. overlayDir, ); From c483c9194ccc75ab7068822842a418c9cb5f9446 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Thu, 17 Sep 2026 10:32:07 -0500 Subject: [PATCH 4/5] fix(0.87): apply reviewed header and API validation fixes --- .../__tests__/headers-compose-test.js | 33 ++++++++++++++----- .../__tests__/headers-inventory-test.js | 2 +- .../__tests__/xcframework-test.js | 4 ++- .../ios-prebuild/headers-xcframework.js | 4 +-- .../js-api/build-types/buildApiSnapshot.js | 2 +- .../__tests__/aliasedExports-test.js | 18 +++++++--- .../canonicalizeLocalPackageImports.js | 6 ++-- 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js index 742f1b89d16b..868019d04e42 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -10,6 +10,8 @@ 'use strict'; +import type {HeadersSpecPlan} from '../headers-spec'; + const resources = require('../framework-resources'); const { COMPOSE_TOOLING_FILES, @@ -50,7 +52,6 @@ describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', }); test('only the version header uses the built overlay on iOS and macOS slices', () => { - const os = require('os'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stamped-headers-')); const rnRoot = path.join(root, 'source'); const overlay = path.join(root, 'built'); @@ -74,13 +75,26 @@ test('only the version header uses the built overlay on iOS and macOS slices', ( for (const slice of slices) { fs.mkdirSync(path.join(xcfw, slice, 'React.framework'), {recursive: true}); } - const plan = { + const plan: HeadersSpecPlan = { react: [ - {relPath: 'ReactNativeVersion.h', source: versionSource}, - {relPath: 'TraceRecordingState.h', source: tracingSource}, + { + relPath: 'ReactNativeVersion.h', + source: versionSource, + naturalPath: 'React/ReactNativeVersion.h', + }, + { + relPath: 'TraceRecordingState.h', + source: tracingSource, + naturalPath: 'jsinspector-modern/tracing/TraceRecordingState.h', + }, ], + reactNativeHeaders: [], + depsNamespaces: [], umbrella: [], + namespaceModules: {}, + namespaceUmbrellas: [], privateReactHeaders: {modular: [], textual: []}, + collisions: [], }; try { emitReactFrameworkHeaders(xcfw, plan, rnRoot, overlay); @@ -121,7 +135,6 @@ describe('composeToolingHash', () => { test('header sidecar composition uses the binary macOS slice instead of iOS defaults', () => { const emitter = require('../headers-xcframework'); - const os = require('os'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'macos-headers-compose-')); const slices = [ {name: 'macos', sdk: 'macosx', targets: ['arm64-apple-macosx11.0']}, @@ -134,13 +147,17 @@ test('header sidecar composition uses the binary macOS slice instead of iOS defa .mockReturnValue(path.join(root, 'ReactNativeHeaders.xcframework')); // No compiler or xcodebuild is needed: this checks the platform handoff. try { - const {buildReactNativeHeadersXcframework} = require('../headers-compose'); buildReactNativeHeadersXcframework( root, { + react: [], reactNativeHeaders: [], + depsNamespaces: [], + umbrella: [], namespaceUmbrellas: [], namespaceModules: {}, + privateReactHeaders: {modular: [], textual: []}, + collisions: [], }, root, emitter.stubSlicesFromXcframework('/binary/React.xcframework'), @@ -161,7 +178,7 @@ test('header sidecar composition uses the binary macOS slice instead of iOS defa describe('binary-derived header sidecars', () => { let tmp = ''; - const plan = { + const plan: HeadersSpecPlan = { react: [], reactNativeHeaders: [], umbrella: [], @@ -171,7 +188,7 @@ describe('binary-derived header sidecars', () => { collisions: [], privateReactHeaders: {modular: [], textual: []}, }; - const writeBinary = (name, platform) => { + const writeBinary = (name: string, platform: string) => { const dir = path.join(tmp, name + '.xcframework'); fs.mkdirSync(dir, {recursive: true}); fs.writeFileSync( diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js index 300a559e1f5b..9c1e889dab00 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js @@ -24,7 +24,7 @@ test('fork inventory retains stable dispatcher paths and macOS headers without c 'HostPlatformViewProps', 'HostPlatformViewTraitsInitializer', ]) { - expect(byPath.get(`${base}${name}.h`).identities[0].source).toBe( + expect(byPath.get(`${base}${name}.h`)?.identities[0].source).toBe( `ReactCommon/${base}${name}.h`, ); expect(byPath.has(`${base}platform/macos/${base}${name}.h`)).toBe(true); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js index c6156bd29f21..91e6e2f2c306 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/xcframework-test.js @@ -73,7 +73,9 @@ describe('resolveHermesHeaders', () => { }); describe('producer header slices', () => { - afterEach(() => jest.restoreAllMocks()); + afterEach(() => { + jest.restoreAllMocks(); + }); test.each(['macos', 'xros', 'unknown'])( 'derives the RN sidecar from the composed %s binary', diff --git a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js index 50369f56f815..3bdb8f611a4e 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -98,7 +98,7 @@ function stubSlicesFromXcframework( `headers-xcframework: ${xcfwPath} must have a non-empty AvailableLibraries array.`, ); } - const seen = new Set(); + const seen /*: Set */ = new Set(); return plist.AvailableLibraries.map(lib => { if ( lib == null || @@ -116,7 +116,7 @@ function stubSlicesFromXcframework( lib.SupportedPlatformVariant != null ? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}` : lib.SupportedPlatform; - if (!Object.prototype.hasOwnProperty.call(PLATFORM_STUB_RECIPES, key)) { + if (!Object.hasOwn(PLATFORM_STUB_RECIPES, key)) { throw new Error( `headers-xcframework: no stub recipe for slice '${key}' of ` + `${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`, diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index c2dbc9b31869..5ae6c19cf733 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -263,7 +263,7 @@ async function rewriteLocalImports( async function getProcessedSnapshotResult( tempDirectory: string, options: BuildApiSnapshotOptions, - packages: $ReadOnlyArray<{directory: string, name: string}>, + packages: ReadonlyArray<{directory: string, name: string}>, ): Promise { const rollupPath = path.join( tempDirectory, diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js index c77c99bb3fea..f673e9273f6e 100644 --- a/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/aliasedExports-test.js @@ -22,11 +22,19 @@ async function exportsAfterPipeline(code: string) { return ast.program.body .filter(node => node.type === 'ExportNamedDeclaration') .flatMap(node => node.specifiers) - .map(specifier => ({ - local: specifier.local.name, - public: specifier.exported.name, - comment: specifier.trailingComments?.[0]?.value, - })); + .map(specifier => { + if ( + specifier?.type !== 'ExportSpecifier' || + specifier.exported.type !== 'Identifier' + ) { + throw new Error('Expected a named export with an identifier alias'); + } + return { + local: specifier.local.name, + public: specifier.exported.name, + comment: specifier.trailingComments?.[0]?.value, + }; + }); } describe('organizeDeclarations and versionExportedApis pipeline', () => { diff --git a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js index 9c97ce7f8118..2a71455d03f7 100644 --- a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js +++ b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js @@ -18,7 +18,7 @@ import type { function canonicalizeSource( source: string, - packageNames: $ReadOnlyArray, + packageNames: ReadonlyArray, ): string { if (!source.startsWith('./') && !source.startsWith('../')) { return source; @@ -37,8 +37,8 @@ function canonicalizeSource( } function canonicalizeLocalPackageImports( - packageNames: $ReadOnlyArray, -): PluginObj { + packageNames: ReadonlyArray, +): PluginObj { function canonicalizeNodeSource( nodePath: NodePath< ExportAllDeclaration | ExportNamedDeclaration | ImportDeclaration, From b62db850f25001644390d8836706b2a9ad92f1d0 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 22 Sep 2026 18:46:34 -0700 Subject: [PATCH 5/5] fix(ci): align Flow annotations and Jest configuration Apply the independently reviewed branch-specific CI follow-up while preserving public-registry locks and the linear stack. --- scripts/js-api/build-types/buildApiSnapshot.js | 2 +- .../typescript/canonicalizeLocalPackageImports.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index e60d756c57a8..cbbe5b4fbe64 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -254,7 +254,7 @@ async function rewriteLocalImports( async function getProcessedSnapshotResult( tempDirectory: string, options: BuildApiSnapshotOptions, - packages: $ReadOnlyArray<{directory: string, name: string}>, + packages: ReadonlyArray<{directory: string, name: string}>, ): Promise { const rollupPath = path.join( tempDirectory, diff --git a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js index 9c97ce7f8118..2a71455d03f7 100644 --- a/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js +++ b/scripts/js-api/build-types/transforms/typescript/canonicalizeLocalPackageImports.js @@ -18,7 +18,7 @@ import type { function canonicalizeSource( source: string, - packageNames: $ReadOnlyArray, + packageNames: ReadonlyArray, ): string { if (!source.startsWith('./') && !source.startsWith('../')) { return source; @@ -37,8 +37,8 @@ function canonicalizeSource( } function canonicalizeLocalPackageImports( - packageNames: $ReadOnlyArray, -): PluginObj { + packageNames: ReadonlyArray, +): PluginObj { function canonicalizeNodeSource( nodePath: NodePath< ExportAllDeclaration | ExportNamedDeclaration | ImportDeclaration,