From d06174fbfe478efa28b97bc219d1167bfdf8112c Mon Sep 17 00:00:00 2001 From: generatedunixname1563563004708334 Date: Tue, 7 Jul 2026 04:31:04 -0700 Subject: [PATCH 001/834] xplat/js/react-native-github/packages/react-native/ReactAndroid/src/main/jni/react/jni/ModuleRegistryBuilder.cpp (#57457) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57457 Reviewed By: cortinico Differential Revision: D110876914 fbshipit-source-id: 6c65fce7ad97dc31dc8df71925ed1e155bc1109a --- .../jni/test/ModuleRegistryBuilderTest.cpp | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/jni/test/ModuleRegistryBuilderTest.cpp diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/test/ModuleRegistryBuilderTest.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/test/ModuleRegistryBuilderTest.cpp new file mode 100644 index 000000000000..bd9ac941b05a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/test/ModuleRegistryBuilderTest.cpp @@ -0,0 +1,74 @@ +/* + * 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. + */ + +#include + +#include +#include + +#include + +#ifndef RCT_REMOVE_LEGACY_ARCH + +namespace facebook::react { + +/** + * Pure-C++ unit tests for the JNI-free slice of `ModuleRegistryBuilder.cpp`. + * + * The bulk of this translation unit is JNI-coupled: `ModuleHolder::getName` + * and `ModuleHolder::getProvider` both call into a live JavaVM (via + * `getClass()` / `jni::make_global(self())`), so their behaviour is + * exercised by the Robolectric / instrumentation tests that run against a + * real JVM. + * + * The one branch that can be validated host-side without an attached JavaVM + * is `buildNativeModuleList`'s null-collection guard: when the incoming + * `alias_ref>` is a null reference, the function must + * short-circuit and return an empty vector rather than dereferencing the + * null collection. `alias_ref` is default-constructible to a null state + * whose `operator bool()` returns false, which is exactly what + * `buildNativeModuleList`'s `if (javaModules)` check tests against, so we + * can drive that path without touching JNI. + */ + +/* + * Verifies the null-input contract of `buildNativeModuleList`: when the + * `javaModules` collection is a null `alias_ref`, no `JavaNativeModule` + * instances are constructed and the returned vector is empty. + * + * Bug this catches: if the `if (javaModules)` guard is ever removed or + * inverted, the subsequent `for (const auto& jm : *javaModules)` would + * dereference a null JNI reference and crash the process on every startup + * path that leaves `javaModules` unset (for example, new-architecture + * bring-up paths that legitimately pass no legacy Java modules). Because + * the crash would only surface once the process actually reaches this + * code with a null collection, catching it here — instead of relying on + * a device-side smoke test — is the earliest signal available. + * + * The `Instance` weak_ptr and `MessageQueueThread` shared_ptr are supplied + * as empty on purpose: the guard runs before either is dereferenced, so + * their state must not affect the null-collection contract. + */ +TEST( + ModuleRegistryBuilderTest, + buildNativeModuleListReturnsEmptyForNullCollection) { + std::weak_ptr noInstance; + std::shared_ptr noQueue; + jni::alias_ref::javaobject> + nullCollection; + ASSERT_FALSE(static_cast(nullCollection)) + << "Precondition: default-constructed alias_ref must be null so that " + "the guard under test is actually exercised."; + + auto modules = buildNativeModuleList(noInstance, nullCollection, noQueue); + + EXPECT_TRUE(modules.empty()); +} + +} // namespace facebook::react + +#endif From 347f8d081eee8792ae23d87ae04be1cfc7b3fdcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 7 Jul 2026 07:30:15 -0700 Subject: [PATCH 002/834] Harden `window` and `navigator` global property descriptors (#57447) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57447 The `window` and `navigator` globals were installed with plain property assignments, which produced writable and configurable data properties. This makes the global definitions safer and more spec-compliant: `window` is now non-writable and non-configurable (still enumerable), matching how browsers expose it, and `navigator` is now non-writable (still configurable and enumerable). `self` is unchanged and remains writable, configurable and enumerable. `window` and `self` are now installed on `globalThis`. Also adds Fantom tests that assert the property descriptors of the `window`, `self`, `navigator`, `Infinity`, `NaN` and `undefined` globals so these guarantees don't regress. Changelog: [General][Changed] - Make the `window` global non-writable and non-configurable, and the `navigator` global non-writable Reviewed By: huntie Differential Revision: D110759948 fbshipit-source-id: 7cd79d9ba5a7dc4b6ef9fb61fede0d25723516b5 --- .../Libraries/Core/setUpGlobals.js | 13 ++-- .../Libraries/Core/setUpNavigator.js | 7 +- ...ultReactNativeEnvironment-Globals-itest.js | 69 +++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/react-native/Libraries/Core/setUpGlobals.js b/packages/react-native/Libraries/Core/setUpGlobals.js index 74d8786131dd..0418fc0246ad 100644 --- a/packages/react-native/Libraries/Core/setUpGlobals.js +++ b/packages/react-native/Libraries/Core/setUpGlobals.js @@ -15,14 +15,19 @@ * Sets up global variables for React Native. * You can use this module directly, or just require InitializeCore. */ -if (global.window === undefined) { +if (globalThis.window === undefined) { // $FlowExpectedError[cannot-write] The global isn't writable anywhere but here, where we define it. - global.window = global; + Object.defineProperty(globalThis, 'window', { + value: globalThis, + configurable: false, + enumerable: true, + writable: false, + }); } -if (global.self === undefined) { +if (globalThis.self === undefined) { // $FlowExpectedError[cannot-write] The global isn't writable anywhere but here, where we define it. - global.self = global; + globalThis.self = globalThis; } // Set up process diff --git a/packages/react-native/Libraries/Core/setUpNavigator.js b/packages/react-native/Libraries/Core/setUpNavigator.js index bcf2cbd597b5..1917abd5202c 100644 --- a/packages/react-native/Libraries/Core/setUpNavigator.js +++ b/packages/react-native/Libraries/Core/setUpNavigator.js @@ -15,7 +15,12 @@ const {polyfillObjectProperty} = require('../Utilities/PolyfillFunctions'); const navigator = global.navigator; if (navigator === undefined) { // $FlowExpectedError[cannot-write] The global isn't writable anywhere but here, where we define it. - global.navigator = {product: 'ReactNative'}; + Object.defineProperty(global, 'navigator', { + value: {product: 'ReactNative'}, + configurable: true, + enumerable: true, + writable: false, + }); } else { // see https://github.com/facebook/react-native/issues/10881 polyfillObjectProperty(navigator, 'product', () => 'ReactNative'); diff --git a/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js b/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js index c319ddbdc061..2172cb72b6a4 100644 --- a/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js +++ b/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-itest.js @@ -14,6 +14,25 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; declare var PerformanceObserverEntryList: unknown; declare var EventCounts: unknown; +function expectGlobalDescriptorToMatch( + name: string, + expected: { + enumerable: boolean, + configurable: boolean, + writable: boolean, + }, +) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + if (descriptor == null) { + throw new Error(`Expected globalThis.${name} to be defined`); + } + expect({ + enumerable: descriptor.enumerable, + configurable: descriptor.configurable, + writable: descriptor.writable, + }).toEqual(expected); +} + describe('setUpDefaultReactNativeEnvironment (globals)', () => { describe('global object', () => { it('should be exposed as globalThis, global, window and self', () => { @@ -23,6 +42,56 @@ describe('setUpDefaultReactNativeEnvironment (globals)', () => { }); }); + describe('property descriptors', () => { + it('should define window as enumerable but not configurable or writable', () => { + expectGlobalDescriptorToMatch('window', { + enumerable: true, + configurable: false, + writable: false, + }); + }); + + it('should define self as configurable, enumerable and writable', () => { + expectGlobalDescriptorToMatch('self', { + enumerable: true, + configurable: true, + writable: true, + }); + }); + + it('should define navigator as configurable and enumerable but not writable', () => { + expectGlobalDescriptorToMatch('navigator', { + enumerable: true, + configurable: true, + writable: false, + }); + }); + + it('should define Infinity as not configurable, writable or enumerable', () => { + expectGlobalDescriptorToMatch('Infinity', { + enumerable: false, + configurable: false, + writable: false, + }); + }); + + it('should define NaN as not configurable, writable or enumerable', () => { + expectGlobalDescriptorToMatch('NaN', { + enumerable: false, + configurable: false, + writable: false, + }); + }); + + it('should define undefined as not configurable, writable or enumerable', () => { + expectGlobalDescriptorToMatch('undefined', { + enumerable: false, + configurable: false, + writable: false, + }); + }); + }); + describe('environment', () => { it('should provide process.env.NODE_ENV', () => { expect(process.env.NODE_ENV).toBe('development'); From ef1462904d21924ccfa1710a0c9c24f4b1d9fce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 7 Jul 2026 07:50:39 -0700 Subject: [PATCH 003/834] Add direct-event fast path to EventTarget dispatch (rnIsDirect) (#57448) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57448 Direct events (those that neither bubble nor capture, such as `onLayout`) were still dispatched through the full W3C event-path algorithm when using the EventTarget-based dispatcher: the path was built by walking every ancestor up to the root, and the capture phase then iterated over all of them, even though a direct event only ever fires on its target. This made direct-event dispatch scale with tree depth (O(depth)). This introduces a "direct" event mode on `Event`: - A new RN-specific `rnIsDirect` option on `EventInit` (with a matching `rnIsDirect` getter). A direct event has only an `AT_TARGET` phase and an event path containing just the target node. - Construction validation: an event cannot be both `rnIsDirect` and `bubbles`; the constructor throws a `TypeError` for that combination. - When `rnIsDirect` is set, the event path is restricted to the target, so dispatch skips the ancestor walk and capture-phase traversal (O(1)). The renderer opts native direct events into this fast path (behind a runtime gate) by tagging them with `rnIsDirect` at the dispatch site. Bubbling events that merely skip bubbling (e.g. `onPointerEnter`) still have a capture phase and are intentionally not treated as direct. This is gated and defaults off, so there is no behavior change by default. Benchmark results (median dispatch latency for the new `onLayout` direct-event benchmarks; all use the same stable-tree setup so depth is directly comparable): | onLayout dispatch | EventTarget off (baseline) | EventTarget on, direct off | EventTarget on, direct on | | --- | --- | --- | --- | | flat (1 handler) | 12.7us | 20.7us | 18.2us | | nested 10 deep | 12.7us | 33.4us | 18.1us | | nested 50 deep | 12.7us | 82.2us | 18.2us | Without the fast path, EventTarget dispatch of a direct event scales with tree depth (20.7us -> 33.4us -> 82.2us). The fast path restores O(1): dispatch cost is flat across depth (~18us) and the nested-50-deep case drops from ~82.2us to ~18.2us (~4.5x faster), approaching the legacy baseline (~12.7us). The EventTarget-off numbers are unchanged by the direct-events flag, confirming the flag is correctly gated. Changelog: [Internal] Reviewed By: javache Differential Revision: D110759162 fbshipit-source-id: f6b2a7ece9bf4a00c6534a69c89c84bc713233d2 --- .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 8 +- .../EventDispatching-benchmark-itest.js | 111 ++++++++++ .../__tests__/EventTargetDispatching-itest.js | 193 ++++++++++++++++++ .../renderer/events/dispatchNativeEvent.js | 17 +- .../src/private/webapis/dom/events/Event.js | 25 +++ .../private/webapis/dom/events/EventTarget.js | 9 + 7 files changed, 372 insertions(+), 2 deletions(-) diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 3a34d698f1a8..792f79594b2b 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1014,6 +1014,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableDirectEventsInEventTarget: { + defaultValue: false, + metadata: { + dateAdded: '2026-07-06', + description: + 'When enabled (together with enableNativeEventTargetEventDispatching), direct events (those that neither bubble nor capture, such as onLayout) are dispatched only to the target node via a fast path that skips construction and traversal of the ancestor event path.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enableImperativeEvents: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 573f62436a0e..806a8ad0fcfd 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<2be1f76084eb85288987229421d7585f>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -33,6 +33,7 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{ animatedForceNativeDriver: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, deferFlatListFocusChangeRenderUpdate: Getter, + enableDirectEventsInEventTarget: Getter, enableImperativeEvents: Getter, enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, @@ -161,6 +162,11 @@ export const animatedShouldSyncValueBeforeStartCallback: Getter = creat */ export const deferFlatListFocusChangeRenderUpdate: Getter = createJavaScriptFlagGetter('deferFlatListFocusChangeRenderUpdate', false); +/** + * When enabled (together with enableNativeEventTargetEventDispatching), direct events (those that neither bubble nor capture, such as onLayout) are dispatched only to the target node via a fast path that skips construction and traversal of the ancestor event path. + */ +export const enableDirectEventsInEventTarget: Getter = createJavaScriptFlagGetter('enableDirectEventsInEventTarget', false); + /** * When enabled, ReactNativeElement and ReadOnlyText expose the public EventTarget API (addEventListener, removeEventListener, dispatchEvent). When disabled, those methods are removed from those final classes. */ diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js index 4c6b1309f5f2..da6c8f3087da 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @fantom_flags enableNativeEventTargetEventDispatching:* + * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ @@ -39,6 +40,29 @@ function createNestedViews( ); } +// `onLayout` is a direct (non-bubbling) event, so only the leaf needs a +// handler; ancestors never receive the layout event. +function createNestedViewsWithLayout( + depth: number, + innerRef: {current: React.ElementRef | null}, +): React.MixedElement { + if (depth === 0) { + return ( + {}} + style={{width: 10, height: 10}} + /> + ); + } + return ( + + {createNestedViewsWithLayout(depth - 1, innerRef)} + + ); +} + const {isOSS} = Fantom.getConstants(); if (isOSS) { @@ -322,5 +346,92 @@ if (isOSS) { root.destroy(); }, }, + ) + .test( + 'dispatch onLayout event, flat (1 handler)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {}} + style={{width: 10, height: 10}} + />, + ); + }); + // Flush the layout events emitted during the initial layout pass so + // they are not measured as part of the dispatched event below. + Fantom.flushAllNativeEvents(); + }, + afterAll: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch onLayout event, nested 10 deep (direct, no bubbling)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(10, ref)); + }); + Fantom.flushAllNativeEvents(); + }, + afterAll: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch onLayout event, nested 50 deep (direct, no bubbling)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(50, ref)); + }); + Fantom.flushAllNativeEvents(); + }, + afterAll: () => { + root.destroy(); + }, + }, ); } diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js index e232633f432c..8f6edc9e6d5b 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js @@ -6,6 +6,7 @@ * * @fantom_flags enableNativeEventTargetEventDispatching:* * @fantom_flags enableImperativeEvents:* + * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ @@ -22,6 +23,7 @@ import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {View} from 'react-native'; import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; +import Event from 'react-native/src/private/webapis/dom/events/Event'; // Temporary cast until ReadOnlyNode extends EventTarget ungated. function asEventTarget(node: ?interface {}): ReadOnlyNodeWithEventTarget { @@ -33,6 +35,28 @@ function asEventTarget(node: ?interface {}): ReadOnlyNodeWithEventTarget { return node; } +function createNestedViewsWithLayout( + depth: number, + innerRef: {current: React.ElementRef | null}, + onLayout: () => void, +): React.MixedElement { + if (depth === 0) { + return ( + + ); + } + return ( + + {createNestedViewsWithLayout(depth - 1, innerRef, onLayout)} + + ); +} + const {isOSS} = Fantom.getConstants(); (isOSS ? describe.skip : describe)( @@ -1626,5 +1650,174 @@ const {isOSS} = Fantom.getConstants(); expect(parentSpy.mock.calls.length - parentCallsBefore).toBe(0); }); }); + + describe('direct events (rnIsDirect) — Event construction validation', () => { + it('allows constructing a direct event that does not bubble', () => { + const event = new Event('layout', {rnIsDirect: true}); + expect(event.rnIsDirect).toBe(true); + expect(event.bubbles).toBe(false); + }); + + it('defaults rnIsDirect to false', () => { + const event = new Event('layout'); + expect(event.rnIsDirect).toBe(false); + }); + + it('throws when rnIsDirect and bubbles are both true', () => { + expect(() => { + // eslint-disable-next-line no-new + new Event('layout', {rnIsDirect: true, bubbles: true}); + }).toThrow( + "Failed to construct 'Event': 'rnIsDirect' cannot be true when 'bubbles' is also true.", + ); + }); + }); + + // The dispatch-behavior tests use `addEventListener` on refs and the + // document element, which is only available when both + // `enableNativeEventTargetEventDispatching` and `enableImperativeEvents` + // are enabled. + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + ReactNativeFeatureFlags.enableImperativeEvents() + ? describe + : describe.skip)('direct events (rnIsDirect) — dispatch behavior', () => { + it('dispatches onLayout to the target but never to ancestor onLayout props or bubble listeners', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const childHandler = jest.fn(); + const parentPropHandler = jest.fn(); + const parentImperativeBubble = jest.fn(); + + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + + asEventTarget(root.document.documentElement).addEventListener( + 'layout', + parentImperativeBubble, + ); + + // Drain mount-time layout events. + Fantom.flushAllNativeEvents(); + + const childBefore = childHandler.mock.calls.length; + const parentBefore = parentPropHandler.mock.calls.length; + const parentBubbleBefore = parentImperativeBubble.mock.calls.length; + + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + {category: Fantom.NativeEventCategory.Discrete}, + ); + + // The target's own onLayout fires exactly once. + expect(childHandler.mock.calls.length - childBefore).toBe(1); + // A direct (non-bubbling) event never reaches an ancestor's onLayout + // prop or an ancestor's bubble-phase listener, regardless of the fast + // path. + expect(parentPropHandler.mock.calls.length - parentBefore).toBe(0); + expect( + parentImperativeBubble.mock.calls.length - parentBubbleBefore, + ).toBe(0); + }); + + (ReactNativeFeatureFlags.enableDirectEventsInEventTarget() + ? it + : it.skip)( + 'restricts the event path to the target (AT_TARGET only, no ancestor capture listeners)', + () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const ancestorCapture = jest.fn(); + let observedPhase: number | null = null; + let observedPathLength: number | null = null; + let observedCurrentIsTarget: boolean | null = null; + + const handler = jest.fn((e: $FlowFixMe) => { + observedPhase = e.eventPhase; + observedPathLength = e.composedPath().length; + observedCurrentIsTarget = e.currentTarget === childRef.current; + }); + + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(5, childRef, () => {})); + }); + + asEventTarget(childRef.current).addEventListener('layout', handler); + asEventTarget(root.document.documentElement).addEventListener( + 'layout', + ancestorCapture, + {capture: true}, + ); + Fantom.flushAllNativeEvents(); + + const ancestorCaptureBefore = ancestorCapture.mock.calls.length; + + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + {category: Fantom.NativeEventCategory.Discrete}, + ); + + expect(handler).toHaveBeenCalled(); + expect(observedPhase).toBe(Event.AT_TARGET); + // Event path is just the target node. + expect(observedPathLength).toBe(1); + expect(observedCurrentIsTarget).toBe(true); + // With the fast path, the capture phase does not traverse ancestors. + expect( + ancestorCapture.mock.calls.length - ancestorCaptureBefore, + ).toBe(0); + }, + ); + + (ReactNativeFeatureFlags.enableDirectEventsInEventTarget() + ? it.skip + : it)( + 'without the fast path, the capture phase still traverses ancestors for direct events', + () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const ancestorCapture = jest.fn(); + + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(5, childRef, () => {})); + }); + + asEventTarget(root.document.documentElement).addEventListener( + 'layout', + ancestorCapture, + {capture: true}, + ); + Fantom.flushAllNativeEvents(); + + const ancestorCaptureBefore = ancestorCapture.mock.calls.length; + + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + {category: Fantom.NativeEventCategory.Discrete}, + ); + + // The DOM dispatch algorithm runs the capture phase over every + // ancestor even for non-bubbling events, so the ancestor capture + // listener fires. + expect( + ancestorCapture.mock.calls.length - ancestorCaptureBefore, + ).toBe(1); + }, + ); + }); }, ); diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js index 69efbb30fea4..bc43624f1d8d 100644 --- a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -14,6 +14,7 @@ import { customBubblingEventTypes, customDirectEventTypes, } from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; +import * as ReactNativeFeatureFlags from '../../featureflags/ReactNativeFeatureFlags'; import { setBubbledPropName, setCapturedPropName, @@ -60,10 +61,24 @@ export default function dispatchNativeEvent( bubbleConfig != null && bubbleConfig.phasedRegistrationNames.skipBubbling !== true; + // A "direct" event is one registered only in the direct-event config + // (e.g. `onLayout`): it neither bubbles nor captures. When the feature + // flag is enabled, tag it so the EventTarget dispatch takes the fast + // target-only path. Note that bubbling events with `skipBubbling` (e.g. + // `onPointerEnter`) still have a capture phase and are NOT direct. + const isDirect = bubbleConfig == null && directConfig != null; + const rnIsDirect = + isDirect && ReactNativeFeatureFlags.enableDirectEventsInEventTarget(); + const eventType = topLevelTypeToEventType(type); - const options: {bubbles: boolean, cancelable: boolean} = { + const options: { + bubbles: boolean, + cancelable: boolean, + rnIsDirect?: boolean, + } = { bubbles, cancelable: true, + rnIsDirect, }; // Preserve the native event timestamp for backwards compatibility. diff --git a/packages/react-native/src/private/webapis/dom/events/Event.js b/packages/react-native/src/private/webapis/dom/events/Event.js index 4b161026426e..829ff605c777 100644 --- a/packages/react-native/src/private/webapis/dom/events/Event.js +++ b/packages/react-native/src/private/webapis/dom/events/Event.js @@ -42,6 +42,11 @@ export interface EventInit { readonly bubbles?: boolean; readonly cancelable?: boolean; readonly composed?: boolean; + // React Native-specific. When true, the event is a "direct" event: it is + // dispatched only to the target node (single AT_TARGET phase) and never + // captures or bubbles through ancestors. A direct event must not bubble, so + // `bubbles` cannot also be true. + readonly rnIsDirect?: boolean; } export default class Event { @@ -58,6 +63,7 @@ export default class Event { _bubbles: boolean; _cancelable: boolean; _composed: boolean; + _rnIsDirect: boolean; _type: string; _defaultPrevented: boolean = false; @@ -110,6 +116,16 @@ export default class Event { this._bubbles = Boolean(options?.bubbles); this._cancelable = Boolean(options?.cancelable); this._composed = Boolean(options?.composed); + this._rnIsDirect = Boolean(options?.rnIsDirect); + + // A direct event is dispatched only to its target and never propagates, so + // it cannot bubble. Reject the contradictory combination at construction + // time. + if (this._rnIsDirect && this._bubbles) { + throw new TypeError( + "Failed to construct 'Event': 'rnIsDirect' cannot be true when 'bubbles' is also true.", + ); + } // For internal construction of events using a custom timestamp (instead of // event object creation), for use cases like dispatching events from the @@ -132,6 +148,15 @@ export default class Event { return this._composed; } + /** + * React Native-specific. When true, this event is dispatched only to its + * target (single `AT_TARGET` phase) and never captures or bubbles through + * ancestors. + */ + get rnIsDirect(): boolean { + return this._rnIsDirect; + } + get currentTarget(): EventTarget | null { return getCurrentTarget(this); } diff --git a/packages/react-native/src/private/webapis/dom/events/EventTarget.js b/packages/react-native/src/private/webapis/dom/events/EventTarget.js index 9c7ef58f5fd6..4d879810205a 100644 --- a/packages/react-native/src/private/webapis/dom/events/EventTarget.js +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -366,6 +366,15 @@ function getEventPath( eventTarget: EventTarget, event: Event, ): ReadonlyArray { + // React Native-specific fast path: a "direct" event is dispatched only to its + // target, as a single AT_TARGET phase, with just the target in the event + // path. This skips the O(depth) ancestor walk (and the capture-phase + // traversal over every ancestor) that the DOM dispatch algorithm otherwise + // performs even for non-bubbling events. + if (event.rnIsDirect) { + return [eventTarget]; + } + const path = []; let target: EventTarget | null = eventTarget; From 58ffd188c6686556c4e1b14477fc091ba2ac9bab Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 7 Jul 2026 08:26:41 -0700 Subject: [PATCH 004/834] Bump Hermes version to 250829098.0.15 for 0.87 branch cut (#57462) Summary: ## Changelog: [General] [Changed] - Bump Hermes version to 250829098.0.15 for 0.87 branch cut Pull Request resolved: https://github.com/react/react-native/pull/57462 Reviewed By: cipolleschi Differential Revision: D110891636 Pulled By: zeyap fbshipit-source-id: 927bf9a21c0979c6217d4ea1d891698a9a686fe6 --- packages/react-native/sdks/hermes-engine/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/sdks/hermes-engine/version.properties b/packages/react-native/sdks/hermes-engine/version.properties index 05a54e3d4e77..58f9ace26bb6 100644 --- a/packages/react-native/sdks/hermes-engine/version.properties +++ b/packages/react-native/sdks/hermes-engine/version.properties @@ -1 +1 @@ -HERMES_VERSION_NAME=250829098.0.13 +HERMES_VERSION_NAME=250829098.0.15 From ca5375178085593177cde3f5b5a945828e599cd4 Mon Sep 17 00:00:00 2001 From: Bartlomiej Bloniarz Date: Tue, 7 Jul 2026 09:45:02 -0700 Subject: [PATCH 005/834] Run AnimationBackend commit hook on ReactRevisionMerge commits (#57410) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57410 `AnimationBackendCommitHook` reconciles animated props during React commits to prevent React from overwriting animation-driven prop updates. Commits with source `ReactRevisionMerge` were being excluded, which currently was not a problem, because this source is only used with fabricCommitBranching which is currently disabled. Changelog: [General][Fixed] - Add `ReactRevisionMerge` source to the AnimationBackendCommitHook Reviewed By: rubennorte Differential Revision: D110459778 fbshipit-source-id: 0f15b077d4218cd642df7a670426613545a1d0e7 --- .../renderer/animationbackend/AnimationBackendCommitHook.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp index cb547d9e367d..1d62ea3be3ce 100644 --- a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp @@ -24,7 +24,8 @@ RootShadowNode::Unshared AnimationBackendCommitHook::shadowTreeWillCommit( const RootShadowNode::Unshared& newRootShadowNode, const ShadowTreeCommitOptions& commitOptions) noexcept { if (commitOptions.source != ShadowTreeCommitSource::React && - commitOptions.source != ShadowTreeCommitSource::AnimationEndSync) { + commitOptions.source != ShadowTreeCommitSource::AnimationEndSync && + commitOptions.source != ShadowTreeCommitSource::ReactRevisionMerge) { return newRootShadowNode; } From 857c3d24c1f8fad091c00d906e3a6275d056c4e2 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Tue, 7 Jul 2026 09:52:36 -0700 Subject: [PATCH 006/834] Fix visibility of NativeMap/Array internal symbols (#57455) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57455 `getJNIPassCounter` was visible to Java but not to internal Kotlin targets. Changelog: [internal] Reviewed By: cortinico Differential Revision: D110877445 fbshipit-source-id: 702748777fac9a1c1148d61a8bad82b8403d2cf4 --- .../main/java/com/facebook/react/bridge/PerformanceCounter.kt | 1 + .../main/java/com/facebook/react/bridge/ReadableNativeArray.kt | 2 +- .../main/java/com/facebook/react/bridge/ReadableNativeMap.kt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/PerformanceCounter.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/PerformanceCounter.kt index ef1290ccb670..b9a2f6d34ac6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/PerformanceCounter.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/PerformanceCounter.kt @@ -6,6 +6,7 @@ */ package com.facebook.react.bridge + public interface PerformanceCounter { public fun profileNextBatch() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeArray.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeArray.kt index d3e27bb09997..4c59fb49891a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeArray.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeArray.kt @@ -108,7 +108,7 @@ public open class ReadableNativeArray protected constructor() : NativeArray(), R return arrayList } - private companion object { + internal companion object { @get:JvmStatic @get:JvmName("getJNIPassCounter") var jniPassCounter: Int = 0 diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeMap.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeMap.kt index 2f47805fb9b1..9d6b8064950b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeMap.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReadableNativeMap.kt @@ -215,7 +215,7 @@ public open class ReadableNativeMap protected constructor() : NativeMap(), Reada return hashMap } - private companion object { + internal companion object { @get:JvmStatic @get:JvmName("getJNIPassCounter") var jniPassCounter: Int = 0 From 554baba159e33b414d8b8dee15c5ef2173e97c0b Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Tue, 7 Jul 2026 10:43:04 -0700 Subject: [PATCH 007/834] Expose 'react-native/setup-env' to replace InitializeCore (#57461) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57461 Replace `'react-native/Libraries/Core/InitializeCore'` with an explicit `'react-native/setup-env'` secondary export. This was previously a special-case path excluded from our ESLint warnings. It is now formalised. **Changes** - Add new `src/setup-env.js` entry point and `package.json` mapping. - Replace references in `packages/metro-config/` and `packages/community-cli-plugin/`. - Update `warn-on-deep-imports` ESLint rule. Changelog: [General][Added] - Add `'react-native/setup-env'` entry point. This replaces the previous side-effectful `'react-native/Libraries/Core/InitializeCore'`. [General][Deprecated] - Deprecate `'react-native/Libraries/Core/InitializeCore'`. Use `'react-native/setup-env'` instead. Reviewed By: rubennorte Differential Revision: D110890810 fbshipit-source-id: 3afe25efa45548d593a1b09c436e36038350b4c5 --- .flowconfig | 1 + .../src/utils/loadMetroConfig.js | 13 +++--- .../__tests__/no-deep-imports-test.js | 30 ++++++++++++- .../no-deep-imports.js | 44 ++++++++++++++----- packages/eslint-plugin-react-native/utils.js | 7 +++ packages/jest-preset/jest-preset.js | 5 +++ packages/jest-preset/jest/setup.js | 1 + packages/metro-config/src/index.flow.js | 2 +- .../plugin-warn-on-deep-imports-test.js | 14 ------ .../src/plugin-warn-on-deep-imports.js | 17 ++----- .../Libraries/Core/InitializeCore.js | 1 + packages/react-native/package.json | 1 + packages/react-native/src/asset-registry.js | 2 +- packages/react-native/src/setup-env.js | 22 ++++++++++ .../IntegrationTests/IntegrationTestsApp.js | 3 +- 15 files changed, 113 insertions(+), 50 deletions(-) create mode 100644 packages/react-native/src/setup-env.js diff --git a/.flowconfig b/.flowconfig index 4d01ae0c0e40..915f8b340ad8 100644 --- a/.flowconfig +++ b/.flowconfig @@ -56,6 +56,7 @@ experimental.multi_platform.extensions=.android munge_underscores=true module.name_mapper='^react-native$' -> '/packages/react-native/index.js' +module.name_mapper='^react-native/setup-env$' -> '/packages/react-native/src/setup-env.js' module.name_mapper='^react-native/\(.*\)$' -> '/packages/react-native/\1' module.name_mapper='^@react-native/dev-middleware$' -> '/packages/dev-middleware' module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|heif\)$' -> '/packages/react-native/Libraries/Image/RelativeImageStub' diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index 43d9e4126af7..a41b028fa29a 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -60,16 +60,17 @@ function getCommunityCliDefaultConfig( return { resolver, serializer: { - // We can include multiple copies of InitializeCore here because metro will + // We can include multiple copies of setup-env here because Metro will // only add ones that are already part of the bundle getModulesRunBeforeMainModule: () => [ - require.resolve( - path.join(ctx.reactNativePath, 'Libraries/Core/InitializeCore'), - {paths: [ctx.root]}, - ), + // NOTE: ctx.reactNativePath is an absolute path, therefore we need to + // reference setup-env.js here by exact path specifier. + require.resolve(path.join(ctx.reactNativePath, 'src/setup-env.js'), { + paths: [ctx.root], + }), ...outOfTreePlatforms.map(platform => require.resolve( - `${ctx.platforms[platform].npmPackageName}/Libraries/Core/InitializeCore`, + `${ctx.platforms[platform].npmPackageName}/setup-env`, {paths: [ctx.root]}, ), ), diff --git a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js index d28b33f45835..bf14e917f7bf 100644 --- a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js +++ b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js @@ -29,10 +29,10 @@ eslintTester.run('../no-deep-imports', rule, { "import Foo from 'react-native-foo';", "import Foo from 'react-native-foo/Foo';", "import Foo from 'react/native/Foo';", - "import 'react-native/Libraries/Core/InitializeCore';", - "require('react-native/Libraries/Core/InitializeCore');", "import Foo from 'react-native/src/fb_internal/Foo'", "require('react-native/src/fb_internal/Foo')", + "import 'react-native/setup-env';", + "require('react-native/setup-env');", ], invalid: [ { @@ -125,5 +125,31 @@ eslintTester.run('../no-deep-imports', rule, { ], output: null, }, + { + code: "import 'react-native/Libraries/Core/InitializeCore';", + errors: [ + { + messageId: 'useReplacementSource', + data: { + importPath: 'react-native/Libraries/Core/InitializeCore', + replacementSource: 'react-native/setup-env', + }, + }, + ], + output: "import 'react-native/setup-env';", + }, + { + code: "require('react-native/Libraries/Core/InitializeCore');", + errors: [ + { + messageId: 'useReplacementSource', + data: { + importPath: 'react-native/Libraries/Core/InitializeCore', + replacementSource: 'react-native/setup-env', + }, + }, + ], + output: "require('react-native/setup-env');", + }, ], }); diff --git a/packages/eslint-plugin-react-native/no-deep-imports.js b/packages/eslint-plugin-react-native/no-deep-imports.js index 6446c70ba6ac..e84a1f24d45b 100644 --- a/packages/eslint-plugin-react-native/no-deep-imports.js +++ b/packages/eslint-plugin-react-native/no-deep-imports.js @@ -21,6 +21,8 @@ module.exports = { messages: { deepImport: "'{{importPath}}' React Native deep imports are deprecated. Please use the top level import instead.", + useReplacementSource: + "'{{importPath}}' is deprecated. Please import '{{replacementSource}}' instead.", }, schema: [], fixable: 'code', @@ -31,12 +33,14 @@ module.exports = { ImportDeclaration(node) { if ( !isDeepReactNativeImport(node.source) || - isInitializeCoreImport(node.source) || isSecondaryEntryPoint(node.source) || isFbInternalImport(node.source) ) { return; } + if (reportReplacementSource(node.source)) { + return; + } if (isDefaultImport(node)) { const reactNativeSource = node.source.value.slice( 'react-native/'.length, @@ -88,13 +92,16 @@ module.exports = { CallExpression(node) { if ( !isDeepRequire(node) || - isInitializeCoreImport(node.arguments[0]) || isSecondaryEntryPoint(node.arguments[0]) || isFbInternalImport(node.arguments[0]) ) { return; } + if (reportReplacementSource(node.arguments[0])) { + return; + } + const parent = node.parent; const importPath = node.arguments[0].value; @@ -123,6 +130,26 @@ module.exports = { }, }; + function reportReplacementSource(source) { + const reactNativeSource = source.value.slice('react-native/'.length); + const mapping = publicAPIMapping[reactNativeSource]; + if (!mapping || !mapping.replacementSource) { + return false; + } + context.report({ + node: source, + messageId: 'useReplacementSource', + data: { + importPath: source.value, + replacementSource: mapping.replacementSource, + }, + fix(fixer) { + return fixer.replaceText(source, `'${mapping.replacementSource}'`); + }, + }); + return true; + } + function getStandardReport(source) { return { node: source, @@ -167,20 +194,15 @@ module.exports = { return parts.length > 1 && parts[0] === 'react-native'; } - function isInitializeCoreImport(source) { - if (source.type !== 'Literal' || typeof source.value !== 'string') { - return false; - } - - return source.value === 'react-native/Libraries/Core/InitializeCore'; - } - function isSecondaryEntryPoint(source) { if (source.type !== 'Literal' || typeof source.value !== 'string') { return false; } - return source.value === 'react-native/asset-registry'; + return ( + source.value === 'react-native/asset-registry' || + source.value === 'react-native/setup-env' + ); } function isFbInternalImport(source) { diff --git a/packages/eslint-plugin-react-native/utils.js b/packages/eslint-plugin-react-native/utils.js index 98d1e90ca0b0..728cc26d6ab3 100644 --- a/packages/eslint-plugin-react-native/utils.js +++ b/packages/eslint-plugin-react-native/utils.js @@ -37,6 +37,13 @@ const publicAPIMapping = { default: 'experimental_LayoutConformance', types: ['LayoutConformanceProps'], }, + 'Libraries/Core/InitializeCore': { + // `InitializeCore` has no public named export; the deep import must be + // swapped for the `react-native/setup-env` entry point entirely. + default: null, + types: null, + replacementSource: 'react-native/setup-env', + }, 'Libraries/Lists/FlatList': { default: 'FlatList', types: ['FlatListProps'], diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index 5632cc7fe5c2..bfe131904a21 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -18,6 +18,11 @@ module.exports = { platforms: ['android', 'ios', 'native'], }, moduleNameMapper: { + // `setup-env` is a secondary entry point exposed via the package's + // `exports`, but `./jest/resolver.js` strips `exports` and the generic + // mapper below resolves subpaths as literal directory paths. Alias it + // explicitly so it resolves to its `src/` implementation. + '^react-native/setup-env$': `${path.dirname(require.resolve('react-native'))}/src/setup-env.js`, '^react-native($|/.*)': `${path.dirname(require.resolve('react-native'))}/$1`, }, resolver: require.resolve('./jest/resolver.js'), diff --git a/packages/jest-preset/jest/setup.js b/packages/jest-preset/jest/setup.js index 29f86698a129..483f14cad8f3 100644 --- a/packages/jest-preset/jest/setup.js +++ b/packages/jest-preset/jest/setup.js @@ -133,6 +133,7 @@ mock( 'm#react-native/Libraries/Core/InitializeCore', 'm#./mocks/InitializeCore', ); +mock('m#react-native/setup-env', 'm#./mocks/InitializeCore'); mock('m#react-native/Libraries/Core/NativeExceptionsManager'); mock('m#react-native/Libraries/Image/Image', 'm#./mocks/Image'); mock( diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index 03a9aed5ace4..dcac214b3978 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -61,7 +61,7 @@ export function getDefaultConfig(projectRoot: string): ConfigT { serializer: { // Note: This option is overridden in cli-plugin-metro (getOverrideConfig) getModulesRunBeforeMainModule: () => [ - require.resolve('react-native/Libraries/Core/InitializeCore'), + require.resolve('react-native/setup-env'), ], getPolyfills: () => require('@react-native/js-polyfills')(), isThirdPartyModule({path: modulePath}: Readonly<{path: string, ...}>) { diff --git a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js index 820fc9c59eae..66f247d7112a 100644 --- a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js +++ b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js @@ -82,17 +82,3 @@ test('import from other package', () => { `"import { foo } from 'react-native-foo';"`, ); }); - -test('import react-native/Libraries/Core/InitializeCore', () => { - const code = ` - import 'react-native/Libraries/Core/InitializeCore'; - require('react-native/Libraries/Core/InitializeCore'); - export * from 'react-native/Libraries/Core/InitializeCore'; - `; - - expect(transform(code, [rnDeepImportsWarningPlugin])).toMatchInlineSnapshot(` - "import 'react-native/Libraries/Core/InitializeCore'; - require('react-native/Libraries/Core/InitializeCore'); - export * from 'react-native/Libraries/Core/InitializeCore';" - `); -}); diff --git a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js index 98f317586fa4..5e98ed59ad74 100644 --- a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js +++ b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js @@ -38,10 +38,6 @@ function isDeepReactNativeImport(source) { return parts.length > 1 && parts[0] === 'react-native'; } -function isInitializeCoreImport(source) { - return source === 'react-native/Libraries/Core/InitializeCore'; -} - function withLocation(node, loc) { if (!node.loc) { return {...node, loc}; @@ -55,7 +51,7 @@ module.exports = ({types: t}) => ({ ImportDeclaration(path, state) { const source = path.node.source.value; - if (isDeepReactNativeImport(source) && !isInitializeCoreImport(source)) { + if (isDeepReactNativeImport(source)) { const loc = path.node.loc; state.import.push({source, loc}); } @@ -71,10 +67,7 @@ module.exports = ({types: t}) => ({ ) { const source = args[0].node.type === 'StringLiteral' ? args[0].node.value : ''; - if ( - isDeepReactNativeImport(source) && - !isInitializeCoreImport(source) - ) { + if (isDeepReactNativeImport(source)) { const loc = path.node.loc; state.require.push({source, loc}); } @@ -83,11 +76,7 @@ module.exports = ({types: t}) => ({ ExportNamedDeclaration(path, state) { const source = path.node.source; - if ( - source && - isDeepReactNativeImport(source.value) && - !isInitializeCoreImport(source) - ) { + if (source && isDeepReactNativeImport(source.value)) { const loc = path.node.loc; state.export.push({source: source.value, loc}); } diff --git a/packages/react-native/Libraries/Core/InitializeCore.js b/packages/react-native/Libraries/Core/InitializeCore.js index c3ba83318376..8846ce4dea50 100644 --- a/packages/react-native/Libraries/Core/InitializeCore.js +++ b/packages/react-native/Libraries/Core/InitializeCore.js @@ -22,6 +22,7 @@ * 1. Require system. * 2. Bridged modules. * + * @deprecated Since 0.87. Use `'react-native/setup-env'` instead. */ 'use strict'; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index fe57f01f9109..d3adca8a16c7 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -52,6 +52,7 @@ "types": null, "default": "./src/asset-registry.js" }, + "./setup-env": "./src/setup-env.js", "./jest-preset": "./jest-preset.js", "./rn-get-polyfills": "./rn-get-polyfills.js", "./src/fb_internal/*": "./src/fb_internal/*", diff --git a/packages/react-native/src/asset-registry.js b/packages/react-native/src/asset-registry.js index e98f442e7694..6d3a4cdf34b4 100644 --- a/packages/react-native/src/asset-registry.js +++ b/packages/react-native/src/asset-registry.js @@ -11,7 +11,7 @@ 'use strict'; // ---------------------------------------------------------------------------- -// Secondary react-native/asset-registry entry point. +// react-native/asset-registry // // This is an untyped secondary entry point intended to be referenced from // Metro's `transformer.assetRegistryPath` config option. This entry point may diff --git a/packages/react-native/src/setup-env.js b/packages/react-native/src/setup-env.js new file mode 100644 index 000000000000..fbe7f9178b31 --- /dev/null +++ b/packages/react-native/src/setup-env.js @@ -0,0 +1,22 @@ +/** + * 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'; +'use client'; + +// ---------------------------------------------------------------------------- +// react-native/setup-env +// +// Side-effectful module that sets up the core React Native JavaScript +// environment. This includes global timers (`setTimeout` etc), the global +// `console` object, and hooks for printing stack traces with source maps. +// ---------------------------------------------------------------------------- + +require('./private/setup/setUpDefaultReactNativeEnvironment').default(); diff --git a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js index c1f9cf2c760a..1cbc4335348f 100644 --- a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js +++ b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js @@ -10,7 +10,8 @@ 'use strict'; -require('react-native/Libraries/Core/InitializeCore'); +require('react-native/setup-env'); + const React = require('react'); const ReactNative = require('react-native'); From 0015d1e4f9f812f775b9eb78e6da63beea7dc34c Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Tue, 7 Jul 2026 10:43:04 -0700 Subject: [PATCH 008/834] Remove Touchable (base) from public API (#57420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57420 **Motivation** The public type for `Touchable` has zero overlap with its runtime value. This is an internal implementation object which is undocumented. https://www.internalfb.com/code/fbsource/[86267213ee84]/xplat/js/react-native-github/packages/react-native/Libraries/Components/Touchable/Touchable.d.ts?lines=18-24 https://www.internalfb.com/code/fbsource/[86267213ee84]/xplat/js/react-native-github/packages/react-native/Libraries/Components/Touchable/Touchable.js?lines=966-986 Replaces D110483989 / https://github.com/react/react-native/pull/57419. **Impact** This is a **breaking change** with few open source consumers. The `onTouchStart` etc prop typings are part of `ViewProps` today, which is a suitable replacement. Across projects I've been testing for the Strict TypeScript API rollout, the only load bearing consumer was Uniwind — where I've sent a PR to address this. - https://github.com/uni-stack/uniwind/pull/592. Changelog: [General][Breaking] - The `Touchable` root export (undocumented) is removed. If you are extending `Touchable` as a type, please use `ViewProps` instead. Reviewed By: rubennorte Differential Revision: D110488985 fbshipit-source-id: 394e3996bb45482723a4b7b18c1cf63f49cb49c5 --- packages/react-native/ReactNativeApi.d.ts | 51 +------------------ packages/react-native/__typetests__/index.tsx | 17 ------- packages/react-native/index.js | 18 +++++-- packages/react-native/index.js.flow | 2 - 4 files changed, 16 insertions(+), 72 deletions(-) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index b5cdeaf11758..c4a2d4ff0beb 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<<637f1d0b012ebca68c9e020a52eb2803>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -403,16 +403,6 @@ declare const staggerImpl: ( time: number, animations: Array, ) => CompositeAnimation -declare const States: { - ERROR: "ERROR" - NOT_RESPONDER: "NOT_RESPONDER" - RESPONDER_ACTIVE_LONG_PRESS_IN: "RESPONDER_ACTIVE_LONG_PRESS_IN" - RESPONDER_ACTIVE_LONG_PRESS_OUT: "RESPONDER_ACTIVE_LONG_PRESS_OUT" - RESPONDER_ACTIVE_PRESS_IN: "RESPONDER_ACTIVE_PRESS_IN" - RESPONDER_ACTIVE_PRESS_OUT: "RESPONDER_ACTIVE_PRESS_OUT" - RESPONDER_INACTIVE_PRESS_IN: "RESPONDER_INACTIVE_PRESS_IN" - RESPONDER_INACTIVE_PRESS_OUT: "RESPONDER_INACTIVE_PRESS_OUT" -} declare const subtract: typeof $$AnimatedImplementation.subtract declare const subtractImpl: ( a: AnimatedNode_default | number, @@ -456,7 +446,6 @@ declare const ToastAndroid_default: { yOffset: number, ) => void } -declare const Touchable: typeof TouchableImpl_default declare const Touchable_default: ( props: TouchableOpacityProps & { ref?: React.Ref @@ -468,33 +457,6 @@ declare const TouchableHighlight_default: ( ref?: React.Ref }, ) => React.ReactNode -declare const TouchableImpl_default: { - Mixin: typeof TouchableMixinImpl - renderDebugView: ($$PARAM_0$$: { - color: ColorValue - hitSlop?: EdgeInsetsProp - }) => null | React.ReactNode -} -declare const TouchableMixinImpl: { - withoutDefaultFocusAndBlur: {} - componentDidMount: () => void - componentWillUnmount: () => void - touchableGetInitialState: () => { - touchable: { - responderID: GestureResponderEvent["currentTarget"] | undefined - touchState: TouchableState | undefined - } - } - touchableHandleBlur: (e: BlurEvent) => void - touchableHandleFocus: (e: FocusEvent) => void - touchableHandleResponderGrant: (e: GestureResponderEvent) => void - touchableHandleResponderMove: (e: GestureResponderEvent) => void - touchableHandleResponderRelease: (e: GestureResponderEvent) => void - touchableHandleResponderTerminate: (e: GestureResponderEvent) => void - touchableHandleResponderTerminationRequest: () => any - touchableHandleStartShouldSetResponder: () => any - touchableLongPressCancelsPress: () => boolean -} declare const TouchableOpacity: typeof Touchable_default declare const UIManager: typeof UIManager_default declare const UIManager_default: UIManagerJSInterface @@ -5371,7 +5333,6 @@ declare type TimingAnimationConfig = Readonly< } > declare type ToastAndroid = typeof ToastAndroid -declare type Touchable = typeof Touchable declare type TouchableHighlight = typeof TouchableHighlight declare type TouchableHighlightBaseProps = { readonly activeOpacity?: number @@ -5478,15 +5439,6 @@ declare type TouchableOpacityTVProps = { readonly nextFocusRight?: number readonly nextFocusUp?: number } -declare type TouchableState = - | typeof States.ERROR - | typeof States.NOT_RESPONDER - | typeof States.RESPONDER_ACTIVE_LONG_PRESS_IN - | typeof States.RESPONDER_ACTIVE_LONG_PRESS_OUT - | typeof States.RESPONDER_ACTIVE_PRESS_IN - | typeof States.RESPONDER_ACTIVE_PRESS_OUT - | typeof States.RESPONDER_INACTIVE_PRESS_IN - | typeof States.RESPONDER_INACTIVE_PRESS_OUT declare function TouchableWithoutFeedback( props: TouchableWithoutFeedbackProps, ): React.ReactNode @@ -6174,7 +6126,6 @@ export { TextProps, // 58466ea1 TextStyle, // b62b8399 ToastAndroid, // 88a8969a - Touchable, // c15da0a2 TouchableHighlight, // 20ba0199 TouchableHighlightInstance, // b510c0eb TouchableHighlightProps, // 337a9164 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 1df236c3bc71..54ea8b7694ba 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -124,7 +124,6 @@ import { // @ts-ignore SectionListData, ToastAndroid, - Touchable, LayoutAnimation, processColor, experimental_LayoutConformance as LayoutConformance, @@ -487,22 +486,6 @@ class Welcome extends React.Component< export default Welcome; -// TouchableTest -function TouchableTest() { - function basicUsage() { - return Touchable.renderDebugView({ - color: 'mediumspringgreen', - hitSlop: {bottom: 5, top: 5}, - }); - } - - function defaultHitSlop() { - return Touchable.renderDebugView({ - color: 'red', - }); - } -} - export class TouchableHighlightTest extends React.Component { buttonRef = React.createRef>(); diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 201ee115755d..0fb660e9aaf7 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -149,9 +149,6 @@ module.exports = { get TextInput() { return require('./Libraries/Components/TextInput/TextInput').default; }, - get Touchable() { - return require('./Libraries/Components/Touchable/Touchable').default; - }, get TouchableHighlight() { return require('./Libraries/Components/Touchable/TouchableHighlight') .default; @@ -412,6 +409,21 @@ module.exports = { // #endregion } as ReactNativePublicAPI; +// `Touchable` has been removed from the public API types, but remains +// re-exported at runtime here because of a hanging `react-native-svg` call +// site (fbsource). +// TODO(huntie): Remove this re-export once `react-native-svg` is updated. +/* $FlowFixMe[prop-missing] This is intentional: `Touchable` is a value-only + * re-export that is absent from the public API types. */ +/* $FlowFixMe[invalid-export] This is intentional: `Touchable` is a value-only + * re-export that is absent from the public API types. */ +Object.defineProperty(module.exports, 'Touchable', { + configurable: true, + get() { + return require('./Libraries/Components/Touchable/Touchable').default; + }, +}); + if (__DEV__) { /* $FlowFixMe[prop-missing] This is intentional: Flow will error when * attempting to access InteractionManager. */ diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 9869ed8c5907..51e4a54e0552 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -175,8 +175,6 @@ export type { } from './Libraries/Components/TextInput/TextInput'; export {default as TextInput} from './Libraries/Components/TextInput/TextInput'; -export {default as Touchable} from './Libraries/Components/Touchable/Touchable'; - export type { TouchableHighlightInstance, TouchableHighlightProps, From 5d6776b685c581ecfb8aaba49904b819a09ec9cf Mon Sep 17 00:00:00 2001 From: Emerson Berlik Date: Tue, 7 Jul 2026 12:53:27 -0700 Subject: [PATCH 009/834] Revert D110488985: Remove Touchable (base) from public API Differential Revision: D110488985 Original commit changeset: 394e3996bb45 Original Phabricator Diff: D110488985 fbshipit-source-id: c22ca419b749bceaa63f9978d2f9b2a37a8a383c --- packages/react-native/ReactNativeApi.d.ts | 51 ++++++++++++++++++- packages/react-native/__typetests__/index.tsx | 17 +++++++ packages/react-native/index.js | 18 ++----- packages/react-native/index.js.flow | 2 + 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c4a2d4ff0beb..b5cdeaf11758 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<<637f1d0b012ebca68c9e020a52eb2803>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -403,6 +403,16 @@ declare const staggerImpl: ( time: number, animations: Array, ) => CompositeAnimation +declare const States: { + ERROR: "ERROR" + NOT_RESPONDER: "NOT_RESPONDER" + RESPONDER_ACTIVE_LONG_PRESS_IN: "RESPONDER_ACTIVE_LONG_PRESS_IN" + RESPONDER_ACTIVE_LONG_PRESS_OUT: "RESPONDER_ACTIVE_LONG_PRESS_OUT" + RESPONDER_ACTIVE_PRESS_IN: "RESPONDER_ACTIVE_PRESS_IN" + RESPONDER_ACTIVE_PRESS_OUT: "RESPONDER_ACTIVE_PRESS_OUT" + RESPONDER_INACTIVE_PRESS_IN: "RESPONDER_INACTIVE_PRESS_IN" + RESPONDER_INACTIVE_PRESS_OUT: "RESPONDER_INACTIVE_PRESS_OUT" +} declare const subtract: typeof $$AnimatedImplementation.subtract declare const subtractImpl: ( a: AnimatedNode_default | number, @@ -446,6 +456,7 @@ declare const ToastAndroid_default: { yOffset: number, ) => void } +declare const Touchable: typeof TouchableImpl_default declare const Touchable_default: ( props: TouchableOpacityProps & { ref?: React.Ref @@ -457,6 +468,33 @@ declare const TouchableHighlight_default: ( ref?: React.Ref }, ) => React.ReactNode +declare const TouchableImpl_default: { + Mixin: typeof TouchableMixinImpl + renderDebugView: ($$PARAM_0$$: { + color: ColorValue + hitSlop?: EdgeInsetsProp + }) => null | React.ReactNode +} +declare const TouchableMixinImpl: { + withoutDefaultFocusAndBlur: {} + componentDidMount: () => void + componentWillUnmount: () => void + touchableGetInitialState: () => { + touchable: { + responderID: GestureResponderEvent["currentTarget"] | undefined + touchState: TouchableState | undefined + } + } + touchableHandleBlur: (e: BlurEvent) => void + touchableHandleFocus: (e: FocusEvent) => void + touchableHandleResponderGrant: (e: GestureResponderEvent) => void + touchableHandleResponderMove: (e: GestureResponderEvent) => void + touchableHandleResponderRelease: (e: GestureResponderEvent) => void + touchableHandleResponderTerminate: (e: GestureResponderEvent) => void + touchableHandleResponderTerminationRequest: () => any + touchableHandleStartShouldSetResponder: () => any + touchableLongPressCancelsPress: () => boolean +} declare const TouchableOpacity: typeof Touchable_default declare const UIManager: typeof UIManager_default declare const UIManager_default: UIManagerJSInterface @@ -5333,6 +5371,7 @@ declare type TimingAnimationConfig = Readonly< } > declare type ToastAndroid = typeof ToastAndroid +declare type Touchable = typeof Touchable declare type TouchableHighlight = typeof TouchableHighlight declare type TouchableHighlightBaseProps = { readonly activeOpacity?: number @@ -5439,6 +5478,15 @@ declare type TouchableOpacityTVProps = { readonly nextFocusRight?: number readonly nextFocusUp?: number } +declare type TouchableState = + | typeof States.ERROR + | typeof States.NOT_RESPONDER + | typeof States.RESPONDER_ACTIVE_LONG_PRESS_IN + | typeof States.RESPONDER_ACTIVE_LONG_PRESS_OUT + | typeof States.RESPONDER_ACTIVE_PRESS_IN + | typeof States.RESPONDER_ACTIVE_PRESS_OUT + | typeof States.RESPONDER_INACTIVE_PRESS_IN + | typeof States.RESPONDER_INACTIVE_PRESS_OUT declare function TouchableWithoutFeedback( props: TouchableWithoutFeedbackProps, ): React.ReactNode @@ -6126,6 +6174,7 @@ export { TextProps, // 58466ea1 TextStyle, // b62b8399 ToastAndroid, // 88a8969a + Touchable, // c15da0a2 TouchableHighlight, // 20ba0199 TouchableHighlightInstance, // b510c0eb TouchableHighlightProps, // 337a9164 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..1df236c3bc71 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -124,6 +124,7 @@ import { // @ts-ignore SectionListData, ToastAndroid, + Touchable, LayoutAnimation, processColor, experimental_LayoutConformance as LayoutConformance, @@ -486,6 +487,22 @@ class Welcome extends React.Component< export default Welcome; +// TouchableTest +function TouchableTest() { + function basicUsage() { + return Touchable.renderDebugView({ + color: 'mediumspringgreen', + hitSlop: {bottom: 5, top: 5}, + }); + } + + function defaultHitSlop() { + return Touchable.renderDebugView({ + color: 'red', + }); + } +} + export class TouchableHighlightTest extends React.Component { buttonRef = React.createRef>(); diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 0fb660e9aaf7..201ee115755d 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -149,6 +149,9 @@ module.exports = { get TextInput() { return require('./Libraries/Components/TextInput/TextInput').default; }, + get Touchable() { + return require('./Libraries/Components/Touchable/Touchable').default; + }, get TouchableHighlight() { return require('./Libraries/Components/Touchable/TouchableHighlight') .default; @@ -409,21 +412,6 @@ module.exports = { // #endregion } as ReactNativePublicAPI; -// `Touchable` has been removed from the public API types, but remains -// re-exported at runtime here because of a hanging `react-native-svg` call -// site (fbsource). -// TODO(huntie): Remove this re-export once `react-native-svg` is updated. -/* $FlowFixMe[prop-missing] This is intentional: `Touchable` is a value-only - * re-export that is absent from the public API types. */ -/* $FlowFixMe[invalid-export] This is intentional: `Touchable` is a value-only - * re-export that is absent from the public API types. */ -Object.defineProperty(module.exports, 'Touchable', { - configurable: true, - get() { - return require('./Libraries/Components/Touchable/Touchable').default; - }, -}); - if (__DEV__) { /* $FlowFixMe[prop-missing] This is intentional: Flow will error when * attempting to access InteractionManager. */ diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 51e4a54e0552..9869ed8c5907 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -175,6 +175,8 @@ export type { } from './Libraries/Components/TextInput/TextInput'; export {default as TextInput} from './Libraries/Components/TextInput/TextInput'; +export {default as Touchable} from './Libraries/Components/Touchable/Touchable'; + export type { TouchableHighlightInstance, TouchableHighlightProps, From 904f0aeb66abb484ad876d5f99e8a8cdf7f4a414 Mon Sep 17 00:00:00 2001 From: Emerson Berlik Date: Tue, 7 Jul 2026 12:55:32 -0700 Subject: [PATCH 010/834] Revert D110890810: Expose 'react-native/setup-env' to replace InitializeCore Differential Revision: D110890810 Original commit changeset: 3afe25efa455 Original Phabricator Diff: D110890810 fbshipit-source-id: 551c04d1e6c66e1d29938be3c2a7a57db475d589 --- .flowconfig | 1 - .../src/utils/loadMetroConfig.js | 13 +++--- .../__tests__/no-deep-imports-test.js | 30 +------------ .../no-deep-imports.js | 44 +++++-------------- packages/eslint-plugin-react-native/utils.js | 7 --- packages/jest-preset/jest-preset.js | 5 --- packages/jest-preset/jest/setup.js | 1 - packages/metro-config/src/index.flow.js | 2 +- .../plugin-warn-on-deep-imports-test.js | 14 ++++++ .../src/plugin-warn-on-deep-imports.js | 17 +++++-- .../Libraries/Core/InitializeCore.js | 1 - packages/react-native/package.json | 1 - packages/react-native/src/asset-registry.js | 2 +- packages/react-native/src/setup-env.js | 22 ---------- .../IntegrationTests/IntegrationTestsApp.js | 3 +- 15 files changed, 50 insertions(+), 113 deletions(-) delete mode 100644 packages/react-native/src/setup-env.js diff --git a/.flowconfig b/.flowconfig index 915f8b340ad8..4d01ae0c0e40 100644 --- a/.flowconfig +++ b/.flowconfig @@ -56,7 +56,6 @@ experimental.multi_platform.extensions=.android munge_underscores=true module.name_mapper='^react-native$' -> '/packages/react-native/index.js' -module.name_mapper='^react-native/setup-env$' -> '/packages/react-native/src/setup-env.js' module.name_mapper='^react-native/\(.*\)$' -> '/packages/react-native/\1' module.name_mapper='^@react-native/dev-middleware$' -> '/packages/dev-middleware' module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|heif\)$' -> '/packages/react-native/Libraries/Image/RelativeImageStub' diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index a41b028fa29a..43d9e4126af7 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -60,17 +60,16 @@ function getCommunityCliDefaultConfig( return { resolver, serializer: { - // We can include multiple copies of setup-env here because Metro will + // We can include multiple copies of InitializeCore here because metro will // only add ones that are already part of the bundle getModulesRunBeforeMainModule: () => [ - // NOTE: ctx.reactNativePath is an absolute path, therefore we need to - // reference setup-env.js here by exact path specifier. - require.resolve(path.join(ctx.reactNativePath, 'src/setup-env.js'), { - paths: [ctx.root], - }), + require.resolve( + path.join(ctx.reactNativePath, 'Libraries/Core/InitializeCore'), + {paths: [ctx.root]}, + ), ...outOfTreePlatforms.map(platform => require.resolve( - `${ctx.platforms[platform].npmPackageName}/setup-env`, + `${ctx.platforms[platform].npmPackageName}/Libraries/Core/InitializeCore`, {paths: [ctx.root]}, ), ), diff --git a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js index bf14e917f7bf..d28b33f45835 100644 --- a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js +++ b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js @@ -29,10 +29,10 @@ eslintTester.run('../no-deep-imports', rule, { "import Foo from 'react-native-foo';", "import Foo from 'react-native-foo/Foo';", "import Foo from 'react/native/Foo';", + "import 'react-native/Libraries/Core/InitializeCore';", + "require('react-native/Libraries/Core/InitializeCore');", "import Foo from 'react-native/src/fb_internal/Foo'", "require('react-native/src/fb_internal/Foo')", - "import 'react-native/setup-env';", - "require('react-native/setup-env');", ], invalid: [ { @@ -125,31 +125,5 @@ eslintTester.run('../no-deep-imports', rule, { ], output: null, }, - { - code: "import 'react-native/Libraries/Core/InitializeCore';", - errors: [ - { - messageId: 'useReplacementSource', - data: { - importPath: 'react-native/Libraries/Core/InitializeCore', - replacementSource: 'react-native/setup-env', - }, - }, - ], - output: "import 'react-native/setup-env';", - }, - { - code: "require('react-native/Libraries/Core/InitializeCore');", - errors: [ - { - messageId: 'useReplacementSource', - data: { - importPath: 'react-native/Libraries/Core/InitializeCore', - replacementSource: 'react-native/setup-env', - }, - }, - ], - output: "require('react-native/setup-env');", - }, ], }); diff --git a/packages/eslint-plugin-react-native/no-deep-imports.js b/packages/eslint-plugin-react-native/no-deep-imports.js index e84a1f24d45b..6446c70ba6ac 100644 --- a/packages/eslint-plugin-react-native/no-deep-imports.js +++ b/packages/eslint-plugin-react-native/no-deep-imports.js @@ -21,8 +21,6 @@ module.exports = { messages: { deepImport: "'{{importPath}}' React Native deep imports are deprecated. Please use the top level import instead.", - useReplacementSource: - "'{{importPath}}' is deprecated. Please import '{{replacementSource}}' instead.", }, schema: [], fixable: 'code', @@ -33,14 +31,12 @@ module.exports = { ImportDeclaration(node) { if ( !isDeepReactNativeImport(node.source) || + isInitializeCoreImport(node.source) || isSecondaryEntryPoint(node.source) || isFbInternalImport(node.source) ) { return; } - if (reportReplacementSource(node.source)) { - return; - } if (isDefaultImport(node)) { const reactNativeSource = node.source.value.slice( 'react-native/'.length, @@ -92,16 +88,13 @@ module.exports = { CallExpression(node) { if ( !isDeepRequire(node) || + isInitializeCoreImport(node.arguments[0]) || isSecondaryEntryPoint(node.arguments[0]) || isFbInternalImport(node.arguments[0]) ) { return; } - if (reportReplacementSource(node.arguments[0])) { - return; - } - const parent = node.parent; const importPath = node.arguments[0].value; @@ -130,26 +123,6 @@ module.exports = { }, }; - function reportReplacementSource(source) { - const reactNativeSource = source.value.slice('react-native/'.length); - const mapping = publicAPIMapping[reactNativeSource]; - if (!mapping || !mapping.replacementSource) { - return false; - } - context.report({ - node: source, - messageId: 'useReplacementSource', - data: { - importPath: source.value, - replacementSource: mapping.replacementSource, - }, - fix(fixer) { - return fixer.replaceText(source, `'${mapping.replacementSource}'`); - }, - }); - return true; - } - function getStandardReport(source) { return { node: source, @@ -194,15 +167,20 @@ module.exports = { return parts.length > 1 && parts[0] === 'react-native'; } + function isInitializeCoreImport(source) { + if (source.type !== 'Literal' || typeof source.value !== 'string') { + return false; + } + + return source.value === 'react-native/Libraries/Core/InitializeCore'; + } + function isSecondaryEntryPoint(source) { if (source.type !== 'Literal' || typeof source.value !== 'string') { return false; } - return ( - source.value === 'react-native/asset-registry' || - source.value === 'react-native/setup-env' - ); + return source.value === 'react-native/asset-registry'; } function isFbInternalImport(source) { diff --git a/packages/eslint-plugin-react-native/utils.js b/packages/eslint-plugin-react-native/utils.js index 728cc26d6ab3..98d1e90ca0b0 100644 --- a/packages/eslint-plugin-react-native/utils.js +++ b/packages/eslint-plugin-react-native/utils.js @@ -37,13 +37,6 @@ const publicAPIMapping = { default: 'experimental_LayoutConformance', types: ['LayoutConformanceProps'], }, - 'Libraries/Core/InitializeCore': { - // `InitializeCore` has no public named export; the deep import must be - // swapped for the `react-native/setup-env` entry point entirely. - default: null, - types: null, - replacementSource: 'react-native/setup-env', - }, 'Libraries/Lists/FlatList': { default: 'FlatList', types: ['FlatListProps'], diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index bfe131904a21..5632cc7fe5c2 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -18,11 +18,6 @@ module.exports = { platforms: ['android', 'ios', 'native'], }, moduleNameMapper: { - // `setup-env` is a secondary entry point exposed via the package's - // `exports`, but `./jest/resolver.js` strips `exports` and the generic - // mapper below resolves subpaths as literal directory paths. Alias it - // explicitly so it resolves to its `src/` implementation. - '^react-native/setup-env$': `${path.dirname(require.resolve('react-native'))}/src/setup-env.js`, '^react-native($|/.*)': `${path.dirname(require.resolve('react-native'))}/$1`, }, resolver: require.resolve('./jest/resolver.js'), diff --git a/packages/jest-preset/jest/setup.js b/packages/jest-preset/jest/setup.js index 483f14cad8f3..29f86698a129 100644 --- a/packages/jest-preset/jest/setup.js +++ b/packages/jest-preset/jest/setup.js @@ -133,7 +133,6 @@ mock( 'm#react-native/Libraries/Core/InitializeCore', 'm#./mocks/InitializeCore', ); -mock('m#react-native/setup-env', 'm#./mocks/InitializeCore'); mock('m#react-native/Libraries/Core/NativeExceptionsManager'); mock('m#react-native/Libraries/Image/Image', 'm#./mocks/Image'); mock( diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index dcac214b3978..03a9aed5ace4 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -61,7 +61,7 @@ export function getDefaultConfig(projectRoot: string): ConfigT { serializer: { // Note: This option is overridden in cli-plugin-metro (getOverrideConfig) getModulesRunBeforeMainModule: () => [ - require.resolve('react-native/setup-env'), + require.resolve('react-native/Libraries/Core/InitializeCore'), ], getPolyfills: () => require('@react-native/js-polyfills')(), isThirdPartyModule({path: modulePath}: Readonly<{path: string, ...}>) { diff --git a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js index 66f247d7112a..820fc9c59eae 100644 --- a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js +++ b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js @@ -82,3 +82,17 @@ test('import from other package', () => { `"import { foo } from 'react-native-foo';"`, ); }); + +test('import react-native/Libraries/Core/InitializeCore', () => { + const code = ` + import 'react-native/Libraries/Core/InitializeCore'; + require('react-native/Libraries/Core/InitializeCore'); + export * from 'react-native/Libraries/Core/InitializeCore'; + `; + + expect(transform(code, [rnDeepImportsWarningPlugin])).toMatchInlineSnapshot(` + "import 'react-native/Libraries/Core/InitializeCore'; + require('react-native/Libraries/Core/InitializeCore'); + export * from 'react-native/Libraries/Core/InitializeCore';" + `); +}); diff --git a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js index 5e98ed59ad74..98f317586fa4 100644 --- a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js +++ b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js @@ -38,6 +38,10 @@ function isDeepReactNativeImport(source) { return parts.length > 1 && parts[0] === 'react-native'; } +function isInitializeCoreImport(source) { + return source === 'react-native/Libraries/Core/InitializeCore'; +} + function withLocation(node, loc) { if (!node.loc) { return {...node, loc}; @@ -51,7 +55,7 @@ module.exports = ({types: t}) => ({ ImportDeclaration(path, state) { const source = path.node.source.value; - if (isDeepReactNativeImport(source)) { + if (isDeepReactNativeImport(source) && !isInitializeCoreImport(source)) { const loc = path.node.loc; state.import.push({source, loc}); } @@ -67,7 +71,10 @@ module.exports = ({types: t}) => ({ ) { const source = args[0].node.type === 'StringLiteral' ? args[0].node.value : ''; - if (isDeepReactNativeImport(source)) { + if ( + isDeepReactNativeImport(source) && + !isInitializeCoreImport(source) + ) { const loc = path.node.loc; state.require.push({source, loc}); } @@ -76,7 +83,11 @@ module.exports = ({types: t}) => ({ ExportNamedDeclaration(path, state) { const source = path.node.source; - if (source && isDeepReactNativeImport(source.value)) { + if ( + source && + isDeepReactNativeImport(source.value) && + !isInitializeCoreImport(source) + ) { const loc = path.node.loc; state.export.push({source: source.value, loc}); } diff --git a/packages/react-native/Libraries/Core/InitializeCore.js b/packages/react-native/Libraries/Core/InitializeCore.js index 8846ce4dea50..c3ba83318376 100644 --- a/packages/react-native/Libraries/Core/InitializeCore.js +++ b/packages/react-native/Libraries/Core/InitializeCore.js @@ -22,7 +22,6 @@ * 1. Require system. * 2. Bridged modules. * - * @deprecated Since 0.87. Use `'react-native/setup-env'` instead. */ 'use strict'; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index d3adca8a16c7..fe57f01f9109 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -52,7 +52,6 @@ "types": null, "default": "./src/asset-registry.js" }, - "./setup-env": "./src/setup-env.js", "./jest-preset": "./jest-preset.js", "./rn-get-polyfills": "./rn-get-polyfills.js", "./src/fb_internal/*": "./src/fb_internal/*", diff --git a/packages/react-native/src/asset-registry.js b/packages/react-native/src/asset-registry.js index 6d3a4cdf34b4..e98f442e7694 100644 --- a/packages/react-native/src/asset-registry.js +++ b/packages/react-native/src/asset-registry.js @@ -11,7 +11,7 @@ 'use strict'; // ---------------------------------------------------------------------------- -// react-native/asset-registry +// Secondary react-native/asset-registry entry point. // // This is an untyped secondary entry point intended to be referenced from // Metro's `transformer.assetRegistryPath` config option. This entry point may diff --git a/packages/react-native/src/setup-env.js b/packages/react-native/src/setup-env.js deleted file mode 100644 index fbe7f9178b31..000000000000 --- a/packages/react-native/src/setup-env.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -'use strict'; -'use client'; - -// ---------------------------------------------------------------------------- -// react-native/setup-env -// -// Side-effectful module that sets up the core React Native JavaScript -// environment. This includes global timers (`setTimeout` etc), the global -// `console` object, and hooks for printing stack traces with source maps. -// ---------------------------------------------------------------------------- - -require('./private/setup/setUpDefaultReactNativeEnvironment').default(); diff --git a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js index 1cbc4335348f..c1f9cf2c760a 100644 --- a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js +++ b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js @@ -10,8 +10,7 @@ 'use strict'; -require('react-native/setup-env'); - +require('react-native/Libraries/Core/InitializeCore'); const React = require('react'); const ReactNative = require('react-native'); From b67b7ba621a5a47e6d3be4266a4f87e1bb40c9b2 Mon Sep 17 00:00:00 2001 From: Saad Najmi Date: Tue, 7 Jul 2026 12:57:15 -0700 Subject: [PATCH 011/834] fix(podspec): remove duplicate scrollview subspec in React-FabricComponents (#57452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Upstream version of our react-native-macos fix (microsoft/react-native-macos#2969). The `scrollview` subspec is declared in both `React-Fabric.podspec` (recursive `scrollview/**/*` glob) and `React-FabricComponents.podspec` (`scrollview/*` + `platform/cxx` + `platform/ios`). The globs overlap, so after `pod install` the same sources (`ScrollViewShadowNode.cpp`, `BaseScrollViewProps.cpp`, `ScrollEvent.cpp`, etc.) land in both the `React-Fabric` and `React-FabricComponents` Pods targets, and consumers that link both (e.g. via `-all_load`) hit duplicate symbol errors. `React-Fabric.podspec`'s recursive glob already covers everything FabricComponents listed, so this removes the duplicate subspec. Note on SPM: the SPM manifest (`Package.swift`) handles scrollview differently — it's assigned solely to the `React-Fabric` target, with no FabricComponents scrollview target. This is an existing divergence between the CocoaPods and SPM setups; this PR only touches CocoaPods, so SPM is unaffected. ## Changelog: [Internal] - Pull Request resolved: https://github.com/react/react-native/pull/57452 Test Plan: CI should pass. Reviewed By: christophpurrer Differential Revision: D110914625 Pulled By: cipolleschi fbshipit-source-id: 2617470ef71ba2e827646d087ab876ee008b88c3 --- .../ReactCommon/React-FabricComponents.podspec | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/react-native/ReactCommon/React-FabricComponents.podspec b/packages/react-native/ReactCommon/React-FabricComponents.podspec index 500dd3d8e31b..b6a28b32d05a 100644 --- a/packages/react-native/ReactCommon/React-FabricComponents.podspec +++ b/packages/react-native/ReactCommon/React-FabricComponents.podspec @@ -101,17 +101,6 @@ Pod::Spec.new do |s| sss.header_dir = "react/renderer/components/safeareaview" end - ss.subspec "scrollview" do |sss| - sss.source_files = podspec_sources(["react/renderer/components/scrollview/*.{m,mm,cpp,h}", - "react/renderer/components/scrollview/platform/cxx/**/*.{m,mm,cpp,h}", - "react/renderer/components/scrollview/platform/ios/**/*.{m,mm,cpp,h}"], - ["react/renderer/components/scrollview/*.h", - "react/renderer/components/scrollview/platform/cxx/**/*.h", - "react/renderer/components/scrollview/platform/ios/**/*.h"]) - sss.exclude_files = "react/renderer/components/scrollview/tests" - sss.header_dir = "react/renderer/components/scrollview" - end - ss.subspec "text" do |sss| sss.source_files = podspec_sources(["react/renderer/components/text/*.{m,mm,cpp,h}", "react/renderer/components/text/platform/cxx/**/*.{m,mm,cpp,h}"], From 5e64e9075147050181c4c9afe31f276e9509850b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Wed, 8 Jul 2026 00:45:27 -0700 Subject: [PATCH 012/834] Remove ReactNativeElement from Fantom tests (nullthrows/HostInstance/HTMLElement) (#57468) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57468 Removes the `ensureInstance(ref.current, ReactNativeElement)` pattern from Fantom tests. Host refs are typed as `HostInstance` and read with `nullthrows(ref.current)`; component-specific instance types (e.g. `React.ElementRef`, `React.ElementRef`) are kept only where a test actually uses a component's imperative API (`focus`/`blur`/`clear`, `getInnerViewRef`, etc.). This drops the `ReactNativeElement`/`ensureInstance` imports from tests that only needed a non-null host instance. Where a test genuinely needs a runtime type check, `instanceof ReactNativeElement` / `toBeInstanceOf(ReactNativeElement)` is replaced with the standard `instanceof HTMLElement` (a global), since React Native elements are instances of `HTMLElement` at runtime. This also covers the internal `-itest.fb.js` tests and the shared ShadowNode test utilities (`ShadowNodeReferenceCounter`/`ShadowNodeRevisionGetter`), which now type their refs as `HostInstance`. Known follow-up: the RN Flow global type definitions still need to be fixed. At the type level `HTMLElement` does not match `HostInstance` yet, even though the runtime values do. Because of this, `HTMLElement` cannot be used where a narrowed value flows into RN-internal APIs typed as `HostInstance`/`ReadOnlyNode` (e.g. `VirtualView`, which instead types its helper params as `HostInstance` directly and passes them through without a narrowing check). Aligning the `HTMLElement` global definition with `HostInstance` would let those remaining spots use `HTMLElement` too. This is a test-only change with no runtime or user-facing impact. Changelog: [Internal] Reviewed By: christophpurrer Differential Revision: D110888634 fbshipit-source-id: c570700ed82fd8333093ee01816e872c486f3e8f --- .../Animated/__tests__/Animated-itest.js | 48 +++++++---------- .../__tests__/AnimatedBackend-itest.js | 7 ++- .../__tests__/ActivityIndicator-itest.js | 17 +++--- .../Pressable/__tests__/Pressable-itest.js | 16 +++--- .../ScrollView/__tests__/ScrollView-itest.js | 13 ++--- .../Switch/__tests__/Switch-itest.js | 8 +-- .../__tests__/TouchableHighlight-itest.js | 18 +++---- .../__tests__/TouchableOpacity-itest.js | 14 +++-- .../TouchableWithoutFeedback-itest.js | 13 ++--- .../View-flexBasisFitContent-itest.js | 12 ++--- .../Components/View/__tests__/View-itest.js | 12 ++--- .../Libraries/Image/__tests__/Image-itest.js | 9 ++-- .../Image/__tests__/ImageBackground-itest.js | 5 +- .../Libraries/Modal/__tests__/Modal-itest.js | 8 +-- .../__tests__/Pressability-itest.js | 13 +++-- ...ventHandlersFromInterruptedRender-itest.js | 9 +--- .../State-ForcedCloneCommitHook-itest.js | 8 +-- .../Utilities/__tests__/useMergeRefs-itest.js | 4 +- .../Utilities/__tests__/useRefEffect-itest.js | 4 +- .../utilities/ShadowNodeReferenceCounter.js | 13 +++-- .../utilities/ShadowNodeRevisionGetter.js | 15 +++--- .../ShadowNodeReferenceCounter-itest.js | 19 ++++--- .../__tests__/VirtualView-itest.js | 31 +++++------ .../__tests__/ShadowTreeBranching-itest.js | 8 +-- .../__tests__/UIConsistency-itest.js | 18 ++----- .../mounting/__tests__/Mounting-itest.js | 7 ++- .../__tests__/ReactNativeDocument-itest.js | 18 +++---- ...NativeElement-traversal-benchmark-itest.js | 5 +- .../IntersectionObserver-benchmark-itest.js | 52 ++++++------------- .../__tests__/structuredClone-itest.js | 6 +-- 30 files changed, 166 insertions(+), 264 deletions(-) diff --git a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js index c8637f8bf84e..0b9023e2a81c 100644 --- a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js @@ -13,13 +13,13 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; import {createRef} from 'react'; import {Animated, Easing, View, useAnimatedValue} from 'react-native'; import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; // Deferred start outputs the initial value on the first animation frame and // re-anchors timing on the second. This delays animation progress by one @@ -54,7 +54,7 @@ test('moving box by 100 points', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); expect(viewElement.getBoundingClientRect().x).toBe(0); @@ -119,7 +119,7 @@ test('native-driven interpolation honors custom easing', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); Fantom.runTask(() => { Animated.timing(_progress, { @@ -179,7 +179,7 @@ test('native-driven interpolation preserves easing overshoot under clamp', () => root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); Fantom.runTask(() => { Animated.timing(_progress, { @@ -231,7 +231,7 @@ function startTimingAnimationAndGetTranslateXAfterFirstFrame(): number { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); Fantom.runTask(() => { Animated.timing(_translateX, { @@ -326,11 +326,8 @@ test('animation driven by onScroll event', () => { root.render(); }); - const scrollViewelement = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const scrollViewelement = nullthrows(scrollViewRef.current); + const viewElement = nullthrows(viewRef.current); Fantom.scrollTo(scrollViewelement, { x: 0, @@ -397,10 +394,7 @@ test('animation driven by onScroll event when animated view is unmounted', () => root.render(); }); - const scrollViewelement = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); + const scrollViewelement = nullthrows(scrollViewRef.current); Fantom.scrollTo(scrollViewelement, { x: 0, @@ -438,7 +432,7 @@ test('animated opacity', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); expect(viewElement.getBoundingClientRect().x).toBe(0); @@ -492,7 +486,7 @@ test('moving box by 50 points with offset 10', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); expect(viewElement.getBoundingClientRect().x).toBe(0); @@ -592,11 +586,8 @@ describe('Value.flattenOffset', () => { _onScroll.addListener(fn); }); - const scrollViewelement = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const scrollViewelement = nullthrows(scrollViewRef.current); + const viewElement = nullthrows(viewRef.current); Fantom.scrollTo(scrollViewelement, { x: 0, @@ -676,11 +667,8 @@ describe('Value.extractOffset', () => { _onScroll.addListener(fn); }); - const scrollViewelement = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const scrollViewelement = nullthrows(scrollViewRef.current); + const viewElement = nullthrows(viewRef.current); Fantom.scrollTo(scrollViewelement, { x: 0, @@ -751,7 +739,7 @@ test('animate layout props', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); Fantom.runTask(() => { _heightAnimation = Animated.timing(_animatedHeight, { @@ -819,7 +807,7 @@ test('AnimatedValue.interpolate', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); expect(_valueX?.__getValue()).toBe(0.5); expect(_interpolatedValueX?.__getValue()).toBe(50); @@ -891,7 +879,7 @@ test('Animated.sequence', () => { root.render(); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.getBoundingClientRect().y).toBe(0); diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index 57b72ea67715..59515ac1eaa0 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -13,13 +13,12 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {Component, createRef, memo, useEffect, useMemo, useState} from 'react'; import {Animated, View, useAnimatedValue} from 'react-native'; import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; // marginLeft (and the other margin props) are only on the native animated // allowlist when the shared backend is enabled. This test deliberately does NOT @@ -108,7 +107,7 @@ test('animated opacity', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); expect(viewElement.getBoundingClientRect().x).toBe(0); @@ -414,7 +413,7 @@ test('animate non-layout props and rerender', () => { root.render(); }); - const viewElement = ensureInstance(viewRef.current, ReactNativeElement); + const viewElement = nullthrows(viewRef.current); Fantom.runTask(() => { _opacityAnimation = Animated.timing(_animatedOpacity, { diff --git a/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js b/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js index a9137dcc33c4..f36831c26737 100644 --- a/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js +++ b/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js @@ -10,12 +10,13 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import ensureInstance from '../../../../src/private/__tests__/utilities/ensureInstance'; +import type {HostInstance} from 'react-native'; + import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {ActivityIndicator} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { it('sets displayName', () => { @@ -167,28 +168,26 @@ describe('', () => { }); describe('ref', () => { - it('provides a valid ReactNativeElement instance', () => { - const elementRef = - createRef>(); + it('provides a valid HTMLElement instance', () => { + const elementRef = createRef(); const root = Fantom.createRoot(); Fantom.runTask(() => { root.render(); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); }); it('has the correct tag name', () => { - const elementRef = - createRef>(); + const elementRef = createRef(); const root = Fantom.createRoot(); Fantom.runTask(() => { root.render(); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:AndroidProgressBar'); }); }); diff --git a/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-itest.js b/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-itest.js index a053b8ee05b4..d2650db3a402 100644 --- a/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-itest.js +++ b/packages/react-native/Libraries/Components/Pressable/__tests__/Pressable-itest.js @@ -11,16 +11,16 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import type {AccessibilityProps, HostInstance} from 'react-native'; +import type {HostInstance} from 'react-native'; +import type {AccessibilityProps} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Pressable} from 'react-native'; import {PlatformColor, Text} from 'react-native'; import accessibilityPropsSuite from 'react-native/src/private/__tests__/utilities/accessibilityPropsSuite'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('props', () => { @@ -94,7 +94,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPressCallback).toHaveBeenCalledTimes(1); @@ -118,7 +118,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'change', {value: true}); expect(onPressCallback).toHaveBeenCalledTimes(0); @@ -263,7 +263,7 @@ describe('', () => { , ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.childNodes.length).toBe(1); @@ -302,7 +302,7 @@ describe('', () => { root.render(); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); }); it('uses the "RN:View" tag name', () => { @@ -314,7 +314,7 @@ describe('', () => { root.render(); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); // Pressable is implemented with a under the hood expect(element.tagName).toBe('RN:View'); }); diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js index cb11743a4464..2357a7b29ebb 100644 --- a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js @@ -17,8 +17,6 @@ import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {ScrollView, Text, View} from 'react-native'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('rendering', () => { @@ -93,7 +91,7 @@ describe('', () => { root.render(); }); - expect(ref.mock.lastCall[0]).toBeInstanceOf(ReactNativeElement); + expect(ref.mock.lastCall[0]).toBeInstanceOf(HTMLElement); Fantom.runTask(() => { root.render(<>); @@ -111,14 +109,14 @@ describe('', () => { root.render(); }); - expect(refA.mock.lastCall[0]).toBeInstanceOf(ReactNativeElement); + expect(refA.mock.lastCall[0]).toBeInstanceOf(HTMLElement); Fantom.runTask(() => { root.render(); }); expect(refA.mock.lastCall[0]).toBe(null); - expect(refB.mock.lastCall[0]).toBeInstanceOf(ReactNativeElement); + expect(refB.mock.lastCall[0]).toBeInstanceOf(HTMLElement); }); }); @@ -131,10 +129,7 @@ describe('', () => { root.render(); }); - const innerView = ensureInstance( - nullthrows(ref.current).getInnerViewRef(), - ReactNativeElement, - ); + const innerView = nullthrows(nullthrows(ref.current).getInnerViewRef()); expect(innerView.tagName).toBe('RN:View'); }); }); diff --git a/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js b/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js index ad7c6207fc08..710d0e93d323 100644 --- a/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js +++ b/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js @@ -9,14 +9,14 @@ */ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + import type {HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Switch} from 'react-native'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('props', () => { @@ -38,8 +38,8 @@ describe('', () => { root.render(); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); - const element = ensureInstance(elementRef.current, ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:Switch'); }); }); diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js index d1e163350dee..e44bc2cd6a1f 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js @@ -11,15 +11,15 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import type {AccessibilityProps, HostInstance} from 'react-native'; +import type {HostInstance} from 'react-native'; +import type {AccessibilityProps} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Text, TouchableHighlight, View} from 'react-native'; import accessibilityPropsSuite from 'react-native/src/private/__tests__/utilities/accessibilityPropsSuite'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('props', () => { @@ -214,7 +214,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPressCallback).toHaveBeenCalledTimes(1); @@ -240,7 +240,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onShowUnderlayCallback).toHaveBeenCalledTimes(1); @@ -265,7 +265,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPressCallback).toHaveBeenCalledTimes(0); @@ -370,7 +370,7 @@ describe('', () => { , ); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPressCallback).toHaveBeenCalledTimes(0); @@ -455,7 +455,7 @@ describe('', () => { ); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); }); it('uses the "RN:View" tag name', () => { @@ -471,7 +471,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:View'); }); }); diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js index f64e8e02689e..4e10ef80d4f3 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js @@ -14,11 +14,10 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Text, TouchableOpacity} from 'react-native'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { it('sets displayName', () => { @@ -116,7 +115,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPressCallback).toHaveBeenCalledTimes(1); @@ -140,7 +139,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPressCallback).toHaveBeenCalledTimes(0); @@ -189,9 +188,8 @@ describe('', () => { ); }); - const element = ensureInstance( + const element = nullthrows( root.document.documentElement.firstElementChild, - ReactNativeElement, ); expect(element.childNodes.length).toBe(1); @@ -215,7 +213,7 @@ describe('', () => { root.render(); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); }); it('uses the "RN:View" tag name', () => { @@ -227,7 +225,7 @@ describe('', () => { root.render(); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:View'); }); }); diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js index aae49186d504..c90fd9798851 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js @@ -13,11 +13,10 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {AccessibilityProps} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {Text, TouchableWithoutFeedback, View} from 'react-native'; import accessibilityPropsSuite from 'react-native/src/private/__tests__/utilities/accessibilityPropsSuite'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('props', () => { @@ -142,10 +141,7 @@ describe('', () => { }); expect( - ensureInstance( - root.document.documentElement.firstElementChild, - ReactNativeElement, - ).tagName, + nullthrows(root.document.documentElement.firstElementChild).tagName, ).toBe('RN:Paragraph'); Fantom.runTask(() => { @@ -159,10 +155,7 @@ describe('', () => { }); expect( - ensureInstance( - root.document.documentElement.firstElementChild, - ReactNativeElement, - ).tagName, + nullthrows(root.document.documentElement.firstElementChild).tagName, ).toBe('RN:View'); }); }); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js index 1d5b6008a48c..d0a7803904a0 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js @@ -13,12 +13,11 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {ScrollView, StyleSheet, View} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; const VIEWPORT_WIDTH = 402; const VIEWPORT_HEIGHT = 760; @@ -53,12 +52,9 @@ test('auto-height wrapper around a feed ScrollView stays bounded by the viewport ); }); - const wrapper = ensureInstance(wrapperRef.current, ReactNativeElement); - const scrollView = ensureInstance(scrollViewRef.current, ReactNativeElement); - const feedContent = ensureInstance( - feedContentRef.current, - ReactNativeElement, - ); + const wrapper = nullthrows(wrapperRef.current); + const scrollView = nullthrows(scrollViewRef.current); + const feedContent = nullthrows(feedContentRef.current); const wrapperRect = wrapper.getBoundingClientRect(); const scrollViewRect = scrollView.getBoundingClientRect(); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js index 4ca19b624719..3c46277f3a84 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js @@ -14,12 +14,11 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {View} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { it('has displayName', () => { @@ -222,10 +221,7 @@ describe('', () => { ); }); - const viewElement = ensureInstance( - viewRef.current, - ReactNativeElement, - ); + const viewElement = nullthrows(viewRef.current); const viewBounds = viewElement.getBoundingClientRect(); expect(viewBounds.x).toBe(expectedBounds.x); @@ -1119,7 +1115,7 @@ describe('', () => { root.render(); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); }); it('uses the "RN:View" tag name', () => { @@ -1131,7 +1127,7 @@ describe('', () => { root.render(); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:View'); }); }); diff --git a/packages/react-native/Libraries/Image/__tests__/Image-itest.js b/packages/react-native/Libraries/Image/__tests__/Image-itest.js index 68b1e9938da8..98466bd85f80 100644 --- a/packages/react-native/Libraries/Image/__tests__/Image-itest.js +++ b/packages/react-native/Libraries/Image/__tests__/Image-itest.js @@ -13,15 +13,14 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {AccessibilityProps, HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Image} from 'react-native'; import * as ImageInjection from 'react-native/Libraries/Image/ImageInjection'; import accessibilityPropsSuite from 'react-native/src/private/__tests__/utilities/accessibilityPropsSuite'; import {testIDPropSuite} from 'react-native/src/private/__tests__/utilities/commonPropsSuite'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; import NativeFantom from 'react-native/src/private/testing/fantom/specs/NativeFantom'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; const LOGO_SOURCE = {uri: 'https://reactnative.dev/img/tiny_logo.png'}; @@ -214,7 +213,7 @@ describe('', () => { expect(onPropCallback).toHaveBeenCalledTimes(0); - const image = ensureInstance(ref.current, ReactNativeElement); + const image = nullthrows(ref.current); Fantom.dispatchNativeEvent(image, onProp, {}); expect(onPropCallback).toHaveBeenCalledTimes(1); @@ -640,7 +639,7 @@ describe('', () => { root.render(); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); }); it('uses the "RN:Image" tag name', () => { @@ -652,7 +651,7 @@ describe('', () => { root.render(); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:Image'); }); }); diff --git a/packages/react-native/Libraries/Image/__tests__/ImageBackground-itest.js b/packages/react-native/Libraries/Image/__tests__/ImageBackground-itest.js index 048171f1c40c..38240eaca7ba 100644 --- a/packages/react-native/Libraries/Image/__tests__/ImageBackground-itest.js +++ b/packages/react-native/Libraries/Image/__tests__/ImageBackground-itest.js @@ -13,11 +13,10 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {ImageBackground} from 'react-native'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('props', () => { @@ -147,7 +146,7 @@ describe('', () => { root.render(); }); - const image = ensureInstance(elementRef.current, ReactNativeElement); + const image = nullthrows(elementRef.current); expect(image.tagName).toBe('RN:Image'); }); }); diff --git a/packages/react-native/Libraries/Modal/__tests__/Modal-itest.js b/packages/react-native/Libraries/Modal/__tests__/Modal-itest.js index ce4ea810932f..34e268d2442a 100644 --- a/packages/react-native/Libraries/Modal/__tests__/Modal-itest.js +++ b/packages/react-native/Libraries/Modal/__tests__/Modal-itest.js @@ -9,14 +9,14 @@ */ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + import type {HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Modal} from 'react-native'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; const DEFAULT_MODAL_CHILD_VIEW = ( @@ -315,8 +315,8 @@ describe('', () => { root.render(); }); - expect(elementRef.current).toBeInstanceOf(ReactNativeElement); - const element = ensureInstance(elementRef.current, ReactNativeElement); + expect(elementRef.current).toBeInstanceOf(HTMLElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:ModalHostView'); }); }); diff --git a/packages/react-native/Libraries/Pressability/__tests__/Pressability-itest.js b/packages/react-native/Libraries/Pressability/__tests__/Pressability-itest.js index bd6a72b0593f..ea752a093c93 100644 --- a/packages/react-native/Libraries/Pressability/__tests__/Pressability-itest.js +++ b/packages/react-native/Libraries/Pressability/__tests__/Pressability-itest.js @@ -16,11 +16,10 @@ import type {HostInstance} from 'react-native'; import usePressability from '../usePressability'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {View} from 'react-native'; -import ensureInstance from 'react-native/src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; function PressabilityTestView({ config, @@ -52,7 +51,7 @@ describe('Pressability', () => { ); }); - const element = ensureInstance(ref.current, ReactNativeElement); + const element = nullthrows(ref.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPress).toHaveBeenCalledTimes(1); @@ -73,7 +72,7 @@ describe('Pressability', () => { ); }); - const element = ensureInstance(ref.current, ReactNativeElement); + const element = nullthrows(ref.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPress).toHaveBeenCalledTimes(0); @@ -94,7 +93,7 @@ describe('Pressability', () => { ); }); - const element = ensureInstance(ref.current, ReactNativeElement); + const element = nullthrows(ref.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPress).toHaveBeenCalledTimes(0); @@ -187,7 +186,7 @@ describe('Pressability', () => { ); }); - const element = ensureInstance(ref.current, ReactNativeElement); + const element = nullthrows(ref.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPressFirst).toHaveBeenCalledTimes(1); expect(onPressSecond).toHaveBeenCalledTimes(0); @@ -232,7 +231,7 @@ describe('Pressability', () => { root.render(); }); - const element = ensureInstance(ref.current, ReactNativeElement); + const element = nullthrows(ref.current); Fantom.dispatchNativeEvent(element, 'click'); expect(onPress).toHaveBeenCalledTimes(1); diff --git a/packages/react-native/Libraries/ReactNative/__tests__/StaleEventHandlersFromInterruptedRender-itest.js b/packages/react-native/Libraries/ReactNative/__tests__/StaleEventHandlersFromInterruptedRender-itest.js index 060241b19734..770b9e881e43 100644 --- a/packages/react-native/Libraries/ReactNative/__tests__/StaleEventHandlersFromInterruptedRender-itest.js +++ b/packages/react-native/Libraries/ReactNative/__tests__/StaleEventHandlersFromInterruptedRender-itest.js @@ -12,17 +12,12 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef, startTransition, useDeferredValue, useState} from 'react'; import {View} from 'react-native'; import {NativeEventCategory} from 'react-native/src/private/testing/fantom/specs/NativeFantom'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; - -function ensureReactNativeElement(value: unknown): ReactNativeElement { - return ensureInstance(value, ReactNativeElement); -} describe('stale event handlers from interrupted render', () => { // This test demonstrates a bug where canonical.currentProps (which stores @@ -79,7 +74,7 @@ describe('stale event handlers from interrupted render', () => { }) { if (shouldDispatchDuringRender && deferredLabel === label) { shouldDispatchDuringRender = false; - const element = ensureReactNativeElement(viewRef.current); + const element = nullthrows(viewRef.current); Fantom.dispatchNativeEvent( element, 'onPointerUp', diff --git a/packages/react-native/Libraries/ReactNative/__tests__/State-ForcedCloneCommitHook-itest.js b/packages/react-native/Libraries/ReactNative/__tests__/State-ForcedCloneCommitHook-itest.js index 3378a8e72c67..93a8ef61b252 100644 --- a/packages/react-native/Libraries/ReactNative/__tests__/State-ForcedCloneCommitHook-itest.js +++ b/packages/react-native/Libraries/ReactNative/__tests__/State-ForcedCloneCommitHook-itest.js @@ -13,13 +13,12 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {ScrollView, View} from 'react-native'; import NativeFantomTestSpecificMethods from 'react-native/src/private/testing/fantom/specs/NativeFantomTestSpecificMethods'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; NativeFantomTestSpecificMethods.registerForcedCloneCommitHook(); @@ -37,10 +36,7 @@ describe('ScrollViewShadowNode', () => { ); }); - const scrollViewElement = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); + const scrollViewElement = nullthrows(scrollViewRef.current); // Scrolling triggers a state update to store the scroll position // - the state update gets committed diff --git a/packages/react-native/Libraries/Utilities/__tests__/useMergeRefs-itest.js b/packages/react-native/Libraries/Utilities/__tests__/useMergeRefs-itest.js index 3e730c50e5e7..7c1ec37bc31f 100644 --- a/packages/react-native/Libraries/Utilities/__tests__/useMergeRefs-itest.js +++ b/packages/react-native/Libraries/Utilities/__tests__/useMergeRefs-itest.js @@ -12,8 +12,6 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from '../../../src/private/types/HostInstance'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from '../../../src/private/webapis/dom/nodes/ReactNativeElement'; import View from '../../Components/View/View'; import useMergeRefs from '../useMergeRefs'; import * as Fantom from '@react-native/fantom'; @@ -29,7 +27,7 @@ function id(instance: HostInstance | null): string | null { if (instance == null) { return null; } - return ensureInstance(instance, ReactNativeElement).id; + return instance.id; } test('accepts a ref callback', () => { diff --git a/packages/react-native/Libraries/Utilities/__tests__/useRefEffect-itest.js b/packages/react-native/Libraries/Utilities/__tests__/useRefEffect-itest.js index 06e85b476f81..d1fd9f0398d3 100644 --- a/packages/react-native/Libraries/Utilities/__tests__/useRefEffect-itest.js +++ b/packages/react-native/Libraries/Utilities/__tests__/useRefEffect-itest.js @@ -12,8 +12,6 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from '../../../src/private/types/HostInstance'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; -import ReactNativeElement from '../../../src/private/webapis/dom/nodes/ReactNativeElement'; import View from '../../Components/View/View'; import useRefEffect from '../useRefEffect'; import * as Fantom from '@react-native/fantom'; @@ -39,7 +37,7 @@ function keyOf(instance: ?HostInstance): ?string { if (instance == null) { return null; } - return ensureInstance(instance, ReactNativeElement).id; + return instance.id; } function effectEntry(name: string, key: ?string): RegistryEntry { diff --git a/packages/react-native/src/private/__tests__/utilities/ShadowNodeReferenceCounter.js b/packages/react-native/src/private/__tests__/utilities/ShadowNodeReferenceCounter.js index 8e3ef6e11b3c..fc2dfdf552db 100644 --- a/packages/react-native/src/private/__tests__/utilities/ShadowNodeReferenceCounter.js +++ b/packages/react-native/src/private/__tests__/utilities/ShadowNodeReferenceCounter.js @@ -8,12 +8,12 @@ * @format */ -import ReactNativeElement from '../../webapis/dom/nodes/ReadOnlyNode'; -import ensureInstance from './ensureInstance'; +import type {HostInstance} from 'react-native'; + import * as Fantom from '@react-native/fantom'; export function createShadowNodeReferenceCounter( - element: ReactNativeElement, + element: HostInstance, ): () => number { const getReferenceCount = Fantom.createShadowNodeReferenceCounter(element); // Create the reference counting function in a helper instead of creating a @@ -34,7 +34,7 @@ function createExpirationChecker( export function createShadowNodeReferenceCountingRef(): [ () => number, - React.RefSetter, + React.RefSetter, ] { let getReferenceCount: ?() => number; @@ -45,15 +45,14 @@ export function createShadowNodeReferenceCountingRef(): [ return getReferenceCount(); } - function ref(instance: unknown | null) { + function ref(instance: HostInstance | null) { if (instance == null) { return; } - const element = ensureInstance(instance, ReactNativeElement); if (getReferenceCount != null) { throw new Error('ShadowNode reference counter was already initialized.'); } - getReferenceCount = createShadowNodeReferenceCounter(element); + getReferenceCount = createShadowNodeReferenceCounter(instance); } return [getShadowNodeReferenceCount, ref]; diff --git a/packages/react-native/src/private/__tests__/utilities/ShadowNodeRevisionGetter.js b/packages/react-native/src/private/__tests__/utilities/ShadowNodeRevisionGetter.js index 0e0944b62ace..fbaea739565a 100644 --- a/packages/react-native/src/private/__tests__/utilities/ShadowNodeRevisionGetter.js +++ b/packages/react-native/src/private/__tests__/utilities/ShadowNodeRevisionGetter.js @@ -8,13 +8,11 @@ * @format */ -import ReactNativeElement from '../../webapis/dom/nodes/ReadOnlyNode'; -import ensureInstance from './ensureInstance'; +import type {HostInstance} from 'react-native'; + import * as Fantom from '@react-native/fantom'; -function createShadowNodeRevisionGetter( - element: ReactNativeElement, -): () => ?number { +function createShadowNodeRevisionGetter(element: HostInstance): () => ?number { const getRevision = Fantom.createShadowNodeRevisionGetter(element); return () => { return getRevision(); @@ -23,7 +21,7 @@ function createShadowNodeRevisionGetter( export function createShadowNodeReferenceGetterRef(): [ () => ?number, - React.RefSetter, + React.RefSetter, ] { let getRevision: ?() => ?number; @@ -34,15 +32,14 @@ export function createShadowNodeReferenceGetterRef(): [ return getRevision(); } - function ref(instance: unknown | null) { + function ref(instance: HostInstance | null) { if (instance == null) { return; } - const element = ensureInstance(instance, ReactNativeElement); if (getRevision != null) { throw new Error('ShadowNode revision getter was already initialized.'); } - getRevision = createShadowNodeRevisionGetter(element); + getRevision = createShadowNodeRevisionGetter(instance); } return [getShadowNodeReferenceCount, ref]; diff --git a/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js b/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js index 00112dd29008..6b21cd1df753 100644 --- a/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js +++ b/packages/react-native/src/private/__tests__/utilities/__tests__/ShadowNodeReferenceCounter-itest.js @@ -12,10 +12,9 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {Node} from '../../../../../Libraries/Renderer/shims/ReactNativeTypes'; +import type {HostInstance} from 'react-native'; import {getNodeFromPublicInstance} from '../../../../../Libraries/ReactPrivate/ReactNativePrivateInterface'; -import ReactNativeElement from '../../../webapis/dom/nodes/ReactNativeElement'; -import ensureInstance from '../ensureInstance'; import isUnreachable from '../isUnreachable'; import { createShadowNodeReferenceCounter, @@ -47,14 +46,14 @@ test('shadow node expires when root is destroyed', () => { test('element is not retained by `createShadowNodeReferenceCounter`', () => { const root = Fantom.createRoot(); - let elementWeakRef: ?WeakRef; + let elementWeakRef: ?WeakRef; let getReferenceCount: ?() => number; - function ref(instance: React.ElementRef | null) { + function ref(instance: HostInstance | null) { if (instance == null) { return; } - const element = ensureInstance(instance, ReactNativeElement); + const element = nullthrows(instance); elementWeakRef = new WeakRef(element); getReferenceCount = createShadowNodeReferenceCounter(element); } @@ -81,14 +80,14 @@ test('element is not retained by `createShadowNodeReferenceCounter`', () => { test('shadow node expires when JavaScript element is reachable', () => { const root = Fantom.createRoot(); - let element: ?ReactNativeElement; + let element: ?HostInstance; let getReferenceCount: ?() => number; - function ref(instance: React.ElementRef | null) { + function ref(instance: HostInstance | null) { if (instance == null) { return; } - element = ensureInstance(instance, ReactNativeElement); + element = nullthrows(instance); getReferenceCount = createShadowNodeReferenceCounter(element); } @@ -119,11 +118,11 @@ test('shadow node is retained when JavaScript node is reachable', () => { let node: ?Node; let getReferenceCount: ?() => number; - function ref(instance: React.ElementRef | null) { + function ref(instance: HostInstance | null) { if (instance == null) { return; } - const element = ensureInstance(instance, ReactNativeElement); + const element = nullthrows(instance); node = getNodeFromPublicInstance(element); getReferenceCount = createShadowNodeReferenceCounter(element); } diff --git a/packages/react-native/src/private/components/virtualview/__tests__/VirtualView-itest.js b/packages/react-native/src/private/components/virtualview/__tests__/VirtualView-itest.js index 06436e9e4175..1f539bb27d25 100644 --- a/packages/react-native/src/private/components/virtualview/__tests__/VirtualView-itest.js +++ b/packages/react-native/src/private/components/virtualview/__tests__/VirtualView-itest.js @@ -12,12 +12,11 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {Rect} from '../VirtualView'; import type {NativeModeChangeEvent} from '../VirtualViewNativeComponent'; +import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../__tests__/utilities/ensureInstance'; import isUnreachable from '../../../__tests__/utilities/isUnreachable'; import {createShadowNodeReferenceCountingRef} from '../../../__tests__/utilities/ShadowNodeReferenceCounter'; import {getNodeFromPublicInstance} from '../../../../../Libraries/ReactPrivate/ReactNativePrivateInterface'; -import ReactNativeElement from '../../../webapis/dom/nodes/ReactNativeElement'; import VirtualView, {_logs, VirtualViewMode} from '../VirtualView'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; @@ -234,7 +233,7 @@ describe('memory management', () => { const root = Fantom.createRoot(); let weakRef: ?WeakRef; - const callbackRef = (instance: React.ElementRef | null) => { + const callbackRef = (instance: HostInstance | null) => { if (instance !== null) { weakRef = new WeakRef(getNodeAsObjectFromPublicInstance(instance)); } @@ -320,7 +319,7 @@ describe('memory management', () => { * Helper to reduce duplication of the mock event payload. */ export function dispatchModeChangeEvent( - instance: unknown, + instance: ?HostInstance, mode: VirtualViewMode, ): void { const targetRect = { @@ -363,16 +362,12 @@ export function dispatchModeChangeEvent( } } - Fantom.dispatchNativeEvent( - ensureInstance(instance, ReactNativeElement), - 'onModeChange', - { - mode: mode as number, - targetRect, - // $FlowFixMe[incompatible-type] - https://fburl.com/workplace/t8a3yvuo - thresholdRect, - } as NativeModeChangeEvent, - ); + Fantom.dispatchNativeEvent(nullthrows(instance), 'onModeChange', { + mode: mode as number, + targetRect, + // $FlowFixMe[incompatible-type] - https://fburl.com/workplace/t8a3yvuo + thresholdRect, + } as NativeModeChangeEvent); } /** @@ -398,10 +393,10 @@ function createWeakRefCallback< /** * Gets the shadow node via `instance.__internalInstanceHandle.stateNode.node`. */ -function getNodeAsObjectFromPublicInstance(instance: unknown): interface {} { - const node = getNodeFromPublicInstance( - ensureInstance(instance, ReactNativeElement), - ); +function getNodeAsObjectFromPublicInstance( + instance: HostInstance, +): interface {} { + const node = getNodeFromPublicInstance(instance); if (node == null || typeof node !== 'object') { throw new Error('Expected node to be an object, got: ' + typeof node); } diff --git a/packages/react-native/src/private/renderer/branching/__tests__/ShadowTreeBranching-itest.js b/packages/react-native/src/private/renderer/branching/__tests__/ShadowTreeBranching-itest.js index 25ea2e459e49..464595e39ca8 100644 --- a/packages/react-native/src/private/renderer/branching/__tests__/ShadowTreeBranching-itest.js +++ b/packages/react-native/src/private/renderer/branching/__tests__/ShadowTreeBranching-itest.js @@ -13,12 +13,11 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {View} from 'react-native'; import NativeFantomTestSpecificMethods from 'react-native/src/private/testing/fantom/specs/NativeFantomTestSpecificMethods'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('ShadowTreeBranching', () => { function LayoutEffectObserver({onCommit}: {onCommit: () => void}) { @@ -177,10 +176,7 @@ describe('ShadowTreeBranching', () => { // The React revision is not merged yet but the new dimensions // should be visible from JS - const viewNode = ensureInstance( - viewRef.current, - ReactNativeElement, - ); + const viewNode = nullthrows(viewRef.current); const rect = viewNode.getBoundingClientRect(); dimensions = [rect.width, rect.height]; }} diff --git a/packages/react-native/src/private/renderer/consistency/__tests__/UIConsistency-itest.js b/packages/react-native/src/private/renderer/consistency/__tests__/UIConsistency-itest.js index 7ecac92e61ec..d05c9d0b6ac0 100644 --- a/packages/react-native/src/private/renderer/consistency/__tests__/UIConsistency-itest.js +++ b/packages/react-native/src/private/renderer/consistency/__tests__/UIConsistency-itest.js @@ -13,12 +13,11 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {useLayoutEffect} from 'react'; import {ScrollView, Text} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('UIConsistency', () => { it('should provide consistent data from the tree within the same synchronous function', () => { @@ -36,10 +35,7 @@ describe('UIConsistency', () => { ); }); - const scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); + const scrollViewNode = nullthrows(scrollViewRef.current); Fantom.runTask(() => { expect(scrollViewNode.scrollTop).toBe(0); @@ -78,10 +74,7 @@ describe('UIConsistency', () => { ); }); - const scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); + const scrollViewNode = nullthrows(scrollViewRef.current); Fantom.runTask(() => { // We never accessed the tree before the state update @@ -127,10 +120,7 @@ describe('UIConsistency', () => { ); }); - const scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); + const scrollViewNode = nullthrows(scrollViewRef.current); Fantom.runTask(() => { root.render( diff --git a/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js b/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js index 2558435e085e..affe86ef97bb 100644 --- a/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js +++ b/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js @@ -13,11 +13,10 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; -import ensureInstance from '../../../__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {View} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('ViewFlattening', () => { /** @@ -474,7 +473,7 @@ describe('reconciliation of setNativeProps and React commit', () => { , ); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); Fantom.runTask(() => { // Calling `setNativeProps` forces bug https://github.com/facebook/react-native/issues/47476 to manifest. @@ -534,7 +533,7 @@ describe('reconciliation of setNativeProps and React commit', () => { .toJSX(), ).toEqual(); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); Fantom.runTask(() => { element.setNativeProps({nativeID: 'second native id'}); diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeDocument-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeDocument-itest.js index 8aeab00fd9f9..a0b0fcd4454e 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeDocument-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeDocument-itest.js @@ -32,7 +32,7 @@ describe('ReactNativeDocument', () => { root.render(); }); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); const document = ensureInstance(element.ownerDocument, ReactNativeDocument); expect(document.isConnected).toBe(true); @@ -58,7 +58,7 @@ describe('ReactNativeDocument', () => { root.render(); }); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); const document = ensureInstance(element.ownerDocument, ReactNativeDocument); expect(document.childNodes.length).toBe(1); @@ -77,7 +77,7 @@ describe('ReactNativeDocument', () => { root.render(); }); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); const document = ensureInstance(element.ownerDocument, ReactNativeDocument); expect(document.childElementCount).toBe(1); @@ -95,7 +95,7 @@ describe('ReactNativeDocument', () => { root.render(); }); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); const document = ensureInstance(element.ownerDocument, ReactNativeDocument); expect(document.nodeName).toBe('#document'); @@ -117,7 +117,7 @@ describe('ReactNativeDocument', () => { root.render(); }); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); const document = ensureInstance(element.ownerDocument, ReactNativeDocument); const {x, y, width, height} = @@ -141,7 +141,7 @@ describe('ReactNativeDocument', () => { root.render(); }); - const element = ensureInstance(nodeRef.current, ReactNativeElement); + const element = nullthrows(nodeRef.current); const document = ensureInstance(element.ownerDocument, ReactNativeDocument); const documentElement = document.documentElement; @@ -193,13 +193,11 @@ describe('ReactNativeDocument', () => { Fantom.runTask(() => { maybeWeakDocument = new WeakRef( ensureInstance( - ensureInstance(nodeRef.current, ReactNativeElement).ownerDocument, + nullthrows(nodeRef.current).ownerDocument, ReactNativeDocument, ), ); - maybeWeakNode = new WeakRef( - ensureInstance(nodeRef.current, ReactNativeElement), - ); + maybeWeakNode = new WeakRef(nullthrows(nodeRef.current)); }); const weakDocument = nullthrows(maybeWeakDocument); diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js index 562fd550bbf8..985a8b0fac18 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js @@ -14,9 +14,8 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; import type {FnOptions} from 'tinybench'; -import ensureInstance from '../../../../__tests__/utilities/ensureInstance'; -import ReactNativeElement from '../ReactNativeElement'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {View} from 'react-native'; @@ -41,7 +40,7 @@ const commonOptions: FnOptions = { ); }); - node = ensureInstance(viewRef.current, ReactNativeElement); + node = nullthrows(viewRef.current); }, afterAll: () => { Fantom.runTask(() => root.destroy()); diff --git a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-benchmark-itest.js b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-benchmark-itest.js index 11d6efaa7c38..41688ec0aa15 100644 --- a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-benchmark-itest.js +++ b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-benchmark-itest.js @@ -14,8 +14,8 @@ import type {Root} from '@react-native/fantom'; import type {HostInstance} from 'react-native'; import type IntersectionObserverType from 'react-native/src/private/webapis/intersectionobserver/IntersectionObserver'; -import ensureInstance from '../../../__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import ScrollView from 'react-native/Libraries/Components/ScrollView/ScrollView'; @@ -109,7 +109,7 @@ Fantom.unstable_benchmark Fantom.runTask(() => { root.render(); }); - rootNode = ensureInstance(rootRef.current, ReactNativeElement); + rootNode = nullthrows(rootRef.current); }, afterEach: () => { expect(mockCallback).not.toHaveBeenCalled(); @@ -131,7 +131,7 @@ Fantom.unstable_benchmark root.render(); observer = new IntersectionObserver(mockCallback); }); - node = ensureInstance(nodeRef.current, ReactNativeElement); + node = nullthrows(nodeRef.current); }, afterEach: () => { expect(mockCallback).toHaveBeenCalledTimes(1); @@ -160,8 +160,8 @@ Fantom.unstable_benchmark , ); }); - node = ensureInstance(nodeRef.current, ReactNativeElement); - rootNode = ensureInstance(rootRef.current, ReactNativeElement); + node = nullthrows(nodeRef.current); + rootNode = nullthrows(rootRef.current); Fantom.runTask(() => { observer = new IntersectionObserver(mockCallback, {root: rootNode}); }); @@ -193,8 +193,8 @@ Fantom.unstable_benchmark , ); }); - node = ensureInstance(nodeRef.current, ReactNativeElement); - rootNode = ensureInstance(rootRef.current, ReactNativeElement); + node = nullthrows(nodeRef.current); + rootNode = nullthrows(rootRef.current); Fantom.runTask(() => { observer = new IntersectionObserver(mockCallback, { root: rootNode, @@ -229,10 +229,7 @@ Fantom.unstable_benchmark , ); }); - scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); + scrollViewNode = nullthrows(scrollViewRef.current); }, afterEach: () => { cleanup(root, observer); @@ -256,10 +253,7 @@ Fantom.unstable_benchmark , ); }); - scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); + scrollViewNode = nullthrows(scrollViewRef.current); }, afterEach: () => { cleanup(root); @@ -285,11 +279,8 @@ Fantom.unstable_benchmark , ); }); - scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); - node = ensureInstance(nodeRef.current, ReactNativeElement); + scrollViewNode = nullthrows(scrollViewRef.current); + node = nullthrows(nodeRef.current); Fantom.runTask(() => { observer = new IntersectionObserver(mockCallback, {}); observer.observe(node); @@ -325,11 +316,8 @@ Fantom.unstable_benchmark , ); }); - scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); - node = ensureInstance(nodeRef.current, ReactNativeElement); + scrollViewNode = nullthrows(scrollViewRef.current); + node = nullthrows(nodeRef.current); Fantom.runTask(() => { observer = new IntersectionObserver(mockCallback, { root: scrollViewNode, @@ -367,11 +355,8 @@ Fantom.unstable_benchmark , ); }); - scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); - node = ensureInstance(nodeRef.current, ReactNativeElement); + scrollViewNode = nullthrows(scrollViewRef.current); + node = nullthrows(nodeRef.current); Fantom.runTask(() => { observer = new IntersectionObserver(mockCallback, {threshold: 1}); observer.observe(node); @@ -415,11 +400,8 @@ Fantom.unstable_benchmark , ); }); - scrollViewNode = ensureInstance( - scrollViewRef.current, - ReactNativeElement, - ); - node = ensureInstance(nodeRef.current, ReactNativeElement); + scrollViewNode = nullthrows(scrollViewRef.current); + node = nullthrows(nodeRef.current); Fantom.runTask(() => { observer = new IntersectionObserver(mockCallback, { threshold: 1, diff --git a/packages/react-native/src/private/webapis/structuredClone/__tests__/structuredClone-itest.js b/packages/react-native/src/private/webapis/structuredClone/__tests__/structuredClone-itest.js index 8409cb8afb9e..402e055048da 100644 --- a/packages/react-native/src/private/webapis/structuredClone/__tests__/structuredClone-itest.js +++ b/packages/react-native/src/private/webapis/structuredClone/__tests__/structuredClone-itest.js @@ -14,13 +14,13 @@ import type {HostInstance} from 'react-native'; import ensureInstance from '../../../__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {View} from 'react-native'; import setUpIntersectionObserver from 'react-native/src/private/setup/setUpIntersectionObserver'; import setUpMutationObserver from 'react-native/src/private/setup/setUpMutationObserver'; import EventTarget from 'react-native/src/private/webapis/dom/events/EventTarget'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import DOMException from 'react-native/src/private/webapis/errors/DOMException'; import IntersectionObserver from 'react-native/src/private/webapis/intersectionobserver/IntersectionObserver'; import IntersectionObserverEntry from 'react-native/src/private/webapis/intersectionobserver/IntersectionObserverEntry'; @@ -396,7 +396,7 @@ describe('structuredClone', () => { self.disconnect(); }); - observer.observe(ensureInstance(ref.current, ReactNativeElement)); + observer.observe(nullthrows(ref.current)); }); const entry = ensureInstance(entries[0], IntersectionObserverEntry); @@ -424,7 +424,7 @@ describe('structuredClone', () => { records.push(...e); }); - observer.observe(ensureInstance(ref.current, ReactNativeElement), { + observer.observe(nullthrows(ref.current), { childList: true, }); }); From 7a5ab34451caec6145454d16a2fce56e752ad26b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Wed, 8 Jul 2026 00:45:28 -0700 Subject: [PATCH 013/834] Document Fantom convention: prefer testing through the public API (#57467) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57467 Adds a convention to the Fantom README recommending that tests exercise React Native through its public API (importing from `react-native`) whenever possible, rather than reaching into internal modules. Exercising the public surface also drives the underlying native (Fabric/TurboModules) code, so tests stay close to real usage and are more resilient to internal refactors. Changelog: [Internal] Reviewed By: christophpurrer Differential Revision: D110918650 fbshipit-source-id: b19dc4b4a380030196a4e83890aae6720a64c77f --- private/react-native-fantom/__docs__/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/private/react-native-fantom/__docs__/README.md b/private/react-native-fantom/__docs__/README.md index e54b19180750..d5997c130e8d 100644 --- a/private/react-native-fantom/__docs__/README.md +++ b/private/react-native-fantom/__docs__/README.md @@ -180,6 +180,19 @@ hi ### Conventions +- Prefer testing React Native through its public API (import from + `'react-native'`) whenever possible, rather than reaching into internal + modules or relying on private/protected members (e.g. `__`-prefixed methods + and fields such as `__getValue()`). Exercising the public API also drives the + underlying native (Fabric/TurboModules) code, so tests stay close to real + usage and are more resilient to internal refactors. Observe results through + the public surface too — e.g. assert the rendered output + (`root.getRenderedOutput(...)`) or values delivered to public listeners + rather than reading private state. + - When Fantom doesn't support something (a native module, a capability, a way + to observe a result, etc.), it's fine to reach into internals to work around + that limitation. Prefer a short comment explaining why the internal access is + necessary. - Place test files in `__tests__` directories alongside the code being tested. - Benchmark tests use the `-benchmark-itest.js` suffix. - Use `Fantom.runTask()` to render and run synchronous operations; it ensures From 537d8673165071e35d7443853b391ab0894fbd63 Mon Sep 17 00:00:00 2001 From: Sam Zhou Date: Wed, 8 Jul 2026 05:24:04 -0700 Subject: [PATCH 014/834] Bump to latest prettier (#57473) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57473 Changelog: [Internal] Reviewed By: gkz Differential Revision: D110952668 fbshipit-source-id: a2278dbe978b42d42f57ddea10a947703a181797 --- package.json | 4 +- packages/debugger-frontend/index.js | 9 +- .../eslint-config-react-native/package.json | 2 +- packages/react-native-codegen/package.json | 2 +- .../react-native/Libraries/Alert/Alert.d.ts | 5 +- .../Libraries/Animated/Animated.d.ts | 14 +- .../Libraries/AppState/AppState.d.ts | 6 +- .../AccessibilityInfo/AccessibilityInfo.d.ts | 5 +- .../Libraries/Components/Button.d.ts | 31 +- .../DrawerAndroid/DrawerLayoutAndroid.d.ts | 6 +- .../Components/Keyboard/Keyboard.d.ts | 6 +- .../Components/Pressable/Pressable.d.ts | 11 +- .../RefreshControl/RefreshControl.d.ts | 3 +- .../Components/ScrollView/ScrollView.d.ts | 38 +- .../Components/StatusBar/StatusBar.d.ts | 5 +- .../Libraries/Components/Switch/Switch.d.ts | 7 +- .../Components/TextInput/TextInput.d.ts | 57 +-- .../Touchable/TouchableNativeFeedback.d.ts | 6 +- .../Touchable/TouchableOpacity.d.ts | 3 +- .../Touchable/TouchableWithoutFeedback.d.ts | 3 +- .../Components/View/ViewAccessibility.d.ts | 15 +- .../Components/View/ViewPropTypes.d.ts | 3 +- .../react-native/Libraries/Image/Image.d.ts | 8 +- .../Libraries/Image/ImageResizeMode.d.ts | 7 +- .../Libraries/Image/ImageSource.d.ts | 4 +- .../LayoutAnimation/LayoutAnimation.d.ts | 12 +- .../Libraries/Lists/SectionList.d.ts | 28 +- .../react-native/Libraries/Modal/Modal.d.ts | 9 +- .../PermissionsAndroid.d.ts | 92 ++-- .../PushNotificationIOS.d.ts | 13 +- .../Libraries/ReactNative/RendererProxy.d.ts | 5 +- .../Libraries/ReactNative/UIManager.d.ts | 3 +- .../Libraries/StyleSheet/StyleSheet.d.ts | 5 +- .../Libraries/StyleSheet/StyleSheetTypes.d.ts | 58 +-- .../react-native/Libraries/Text/Text.d.ts | 21 +- .../Libraries/Types/CoreEventTypes.d.ts | 10 +- .../Libraries/Utilities/Platform.d.ts | 7 +- packages/react-native/ReactNativeApi.d.ts | 471 ++++++------------ .../__typetests__/fabric-component-sample.ts | 3 +- packages/react-native/metro.config.js | 6 +- .../generate-artifacts-executor/index.js | 2 +- .../scripts/compose-source-maps.js | 2 +- .../react-native/scripts/packager-reporter.js | 3 +- packages/react-native/src/types/globals.d.ts | 26 +- .../types/public/ReactNativeRenderer.d.ts | 15 +- .../types/public/ReactNativeTypes.d.ts | 9 +- .../Lists/VirtualizedList.d.ts | 38 +- private/helloworld/.prettierrc.js | 8 +- private/helloworld/metro.config.js | 6 +- .../config/metro.config.js | 4 +- yarn.lock | 18 +- 51 files changed, 388 insertions(+), 746 deletions(-) diff --git a/package.json b/package.json index 74b02e44c8f5..6c0b4a3cb097 100644 --- a/package.json +++ b/package.json @@ -106,8 +106,8 @@ "micromatch": "^4.0.4", "node-fetch": "^2.2.0", "nullthrows": "^1.1.1", - "prettier": "3.6.2", - "prettier-plugin-hermes-parser": "0.36.0", + "prettier": "3.9.4", + "prettier-plugin-hermes-parser": "0.37.0", "react": "19.2.3", "react-test-renderer": "19.2.3", "rimraf": "^3.0.2", diff --git a/packages/debugger-frontend/index.js b/packages/debugger-frontend/index.js index ecd558a5d286..38903035b351 100644 --- a/packages/debugger-frontend/index.js +++ b/packages/debugger-frontend/index.js @@ -10,7 +10,12 @@ const path = require('path'); -let frontEndPath = path.join(__dirname, 'dist', 'third-party', 'front_end'); +let frontEndPath /*:string */ = path.join( + __dirname, + 'dist', + 'third-party', + 'front_end', +); if (process.env.REACT_NATIVE_DEBUGGER_FRONTEND_PATH != null) { frontEndPath = process.env.REACT_NATIVE_DEBUGGER_FRONTEND_PATH; @@ -33,4 +38,4 @@ if (process.env.REACT_NATIVE_DEBUGGER_FRONTEND_PATH != null) { ); } -module.exports = frontEndPath /*:: as string */; +module.exports = frontEndPath; diff --git a/packages/eslint-config-react-native/package.json b/packages/eslint-config-react-native/package.json index 4f405f5c2d96..937b51e14be1 100644 --- a/packages/eslint-config-react-native/package.json +++ b/packages/eslint-config-react-native/package.json @@ -43,6 +43,6 @@ }, "devDependencies": { "eslint": "^8.57.0", - "prettier": "3.6.2" + "prettier": "3.9.4" } } diff --git a/packages/react-native-codegen/package.json b/packages/react-native-codegen/package.json index 1c7631f78fca..59c181994b2b 100644 --- a/packages/react-native-codegen/package.json +++ b/packages/react-native-codegen/package.json @@ -48,7 +48,7 @@ "babel-plugin-syntax-hermes-parser": "0.36.1", "hermes-estree": "0.36.1", "micromatch": "^4.0.4", - "prettier": "3.6.2", + "prettier": "3.9.4", "rimraf": "^3.0.2" }, "peerDependencies": { diff --git a/packages/react-native/Libraries/Alert/Alert.d.ts b/packages/react-native/Libraries/Alert/Alert.d.ts index c6a84a820a48..1692b6c4b568 100644 --- a/packages/react-native/Libraries/Alert/Alert.d.ts +++ b/packages/react-native/Libraries/Alert/Alert.d.ts @@ -85,10 +85,7 @@ export interface AlertStatic { } export type AlertType = - | 'default' - | 'plain-text' - | 'secure-text' - | 'login-password'; + 'default' | 'plain-text' | 'secure-text' | 'login-password'; export const Alert: AlertStatic; export type Alert = AlertStatic; diff --git a/packages/react-native/Libraries/Animated/Animated.d.ts b/packages/react-native/Libraries/Animated/Animated.d.ts index 6432625bdee9..86f905ee6966 100644 --- a/packages/react-native/Libraries/Animated/Animated.d.ts +++ b/packages/react-native/Libraries/Animated/Animated.d.ts @@ -78,11 +78,7 @@ export namespace Animated { }; type AnimatedColorInputValue = - | RgbaValue - | RgbaAnimatedValue - | ColorValue - | null - | undefined; + RgbaValue | RgbaAnimatedValue | ColorValue | null | undefined; class AnimatedColor extends AnimatedWithChildren { r: AnimatedValue; @@ -542,8 +538,7 @@ export namespace Animated { ): (...args: any[]) => void; export type ComponentProps = T extends - | React.ComponentType - | React.Component + React.ComponentType | React.Component ? P : never; @@ -586,8 +581,9 @@ export namespace Animated { : WithAnimatedValue; }; - export interface AnimatedComponent> - extends React.FC>> {} + export interface AnimatedComponent< + T extends React.ComponentType, + > extends React.FC>> {} export type AnimatedComponentOptions = { collapsable?: boolean | undefined; diff --git a/packages/react-native/Libraries/AppState/AppState.d.ts b/packages/react-native/Libraries/AppState/AppState.d.ts index ddc5ad0a511a..3bdd45e280f9 100644 --- a/packages/react-native/Libraries/AppState/AppState.d.ts +++ b/packages/react-native/Libraries/AppState/AppState.d.ts @@ -37,11 +37,7 @@ import {NativeEventSubscription} from '../EventEmitter/RCTNativeAppEventEmitter' */ export type AppStateEvent = 'change' | 'memoryWarning' | 'blur' | 'focus'; export type AppStateStatus = - | 'active' - | 'background' - | 'inactive' - | 'unknown' - | 'extension'; + 'active' | 'background' | 'inactive' | 'unknown' | 'extension'; export interface AppStateStatic { currentState: AppStateStatus; diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index 2c3f50530069..f2405fd59b5e 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -46,10 +46,7 @@ type AccessibilityAnnouncementFinishedEventHandler = ( ) => void; type AccessibilityEventTypes = - | 'click' - | 'focus' - | 'viewHoverEnter' - | 'windowStateChange'; + 'click' | 'focus' | 'viewHoverEnter' | 'windowStateChange'; /** * @see https://reactnative.dev/docs/accessibilityinfo diff --git a/packages/react-native/Libraries/Components/Button.d.ts b/packages/react-native/Libraries/Components/Button.d.ts index 706307156b85..a76b883a2d64 100644 --- a/packages/react-native/Libraries/Components/Button.d.ts +++ b/packages/react-native/Libraries/Components/Button.d.ts @@ -12,22 +12,21 @@ import {ColorValue} from '../StyleSheet/StyleSheet'; import {TouchableNativeFeedbackProps} from './Touchable/TouchableNativeFeedback'; import {TouchableOpacityProps} from './Touchable/TouchableOpacity'; -export interface ButtonProps - extends Pick< - TouchableNativeFeedbackProps & TouchableOpacityProps, - | 'accessibilityLabel' - | 'accessibilityState' - | 'hasTVPreferredFocus' - | 'nextFocusDown' - | 'nextFocusForward' - | 'nextFocusLeft' - | 'nextFocusRight' - | 'nextFocusUp' - | 'testID' - | 'disabled' - | 'onPress' - | 'touchSoundDisabled' - > { +export interface ButtonProps extends Pick< + TouchableNativeFeedbackProps & TouchableOpacityProps, + | 'accessibilityLabel' + | 'accessibilityState' + | 'hasTVPreferredFocus' + | 'nextFocusDown' + | 'nextFocusForward' + | 'nextFocusLeft' + | 'nextFocusRight' + | 'nextFocusUp' + | 'testID' + | 'disabled' + | 'onPress' + | 'touchSoundDisabled' +> { /** * Text to display inside the button. On Android the given title will be converted to the uppercased form. */ diff --git a/packages/react-native/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.d.ts b/packages/react-native/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.d.ts index 26fdec88e432..6a5f7a7adb15 100644 --- a/packages/react-native/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.d.ts +++ b/packages/react-native/Libraries/Components/DrawerAndroid/DrawerLayoutAndroid.d.ts @@ -17,8 +17,7 @@ import { } from '../../Types/CoreEventTypes'; import {ViewProps} from '../View/ViewPropTypes'; -export interface DrawerSlideEvent - extends NativeSyntheticEvent {} +export interface DrawerSlideEvent extends NativeSyntheticEvent {} /** * DrawerLayoutAndroid is deprecated and will be removed in a future release. @@ -101,8 +100,7 @@ export interface DrawerLayoutAndroidProps extends ViewProps { * it's closing or opening animation */ onDrawerStateChanged?: - | ((event: 'Idle' | 'Dragging' | 'Settling') => void) - | undefined; + ((event: 'Idle' | 'Dragging' | 'Settling') => void) | undefined; /** * The navigation view that will be rendered to the side of the diff --git a/packages/react-native/Libraries/Components/Keyboard/Keyboard.d.ts b/packages/react-native/Libraries/Components/Keyboard/Keyboard.d.ts index 062016e57ecd..211fd6f32b48 100644 --- a/packages/react-native/Libraries/Components/Keyboard/Keyboard.d.ts +++ b/packages/react-native/Libraries/Components/Keyboard/Keyboard.d.ts @@ -19,11 +19,7 @@ export type KeyboardEventName = | 'keyboardDidChangeFrame'; export type KeyboardEventEasing = - | 'easeIn' - | 'easeInEaseOut' - | 'easeOut' - | 'linear' - | 'keyboard'; + 'easeIn' | 'easeInEaseOut' | 'easeOut' | 'linear' | 'keyboard'; type KeyboardMetrics = { screenX: number; diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts b/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts index 00d131f4aa11..aad64fe3183e 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.d.ts @@ -34,7 +34,8 @@ export interface PressableAndroidRippleConfig { } export interface PressableProps - extends AccessibilityProps, + extends + AccessibilityProps, Omit { /** * Called when the hover is activated to provide visual feedback. @@ -76,18 +77,14 @@ export interface PressableProps * @platform macos windows */ onBlur?: - | null - | ((event: NativeSyntheticEvent) => void) - | undefined; + null | ((event: NativeSyntheticEvent) => void) | undefined; /** * Called after the element is focused. * @platform macos windows */ onFocus?: - | null - | ((event: NativeSyntheticEvent) => void) - | undefined; + null | ((event: NativeSyntheticEvent) => void) | undefined; /** * Either children or a render prop that receives a boolean reflecting whether diff --git a/packages/react-native/Libraries/Components/RefreshControl/RefreshControl.d.ts b/packages/react-native/Libraries/Components/RefreshControl/RefreshControl.d.ts index 5fc0711b47c0..99a1357825f7 100644 --- a/packages/react-native/Libraries/Components/RefreshControl/RefreshControl.d.ts +++ b/packages/react-native/Libraries/Components/RefreshControl/RefreshControl.d.ts @@ -53,8 +53,7 @@ export interface RefreshControlPropsAndroid extends ViewProps { } export interface RefreshControlProps - extends RefreshControlPropsIOS, - RefreshControlPropsAndroid { + extends RefreshControlPropsIOS, RefreshControlPropsAndroid { /** * Called when the view starts refreshing. */ diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts index 9e36eeec115d..6d9a90b3d5a9 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts @@ -28,8 +28,7 @@ export interface PointProp { y: number; } -export interface ScrollResponderEvent - extends NativeSyntheticEvent {} +export interface ScrollResponderEvent extends NativeSyntheticEvent {} interface SubscribableMixin { /** @@ -410,11 +409,7 @@ export interface ScrollViewPropsIOS { * The default value of this property is "never". */ contentInsetAdjustmentBehavior?: - | 'automatic' - | 'scrollableAxes' - | 'never' - | 'always' - | undefined; + 'automatic' | 'scrollableAxes' | 'never' | 'always' | undefined; /** * When true the ScrollView will try to lock to only vertical or horizontal @@ -523,8 +518,7 @@ export interface ScrollViewPropsIOS { * @platform ios */ onScrollToTop?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + ((event: NativeSyntheticEvent) => void) | undefined; /** * The current scale of the scroll view content. The default value is 1.0. @@ -598,10 +592,7 @@ export interface ScrollViewPropsAndroid { } export interface ScrollViewProps - extends ViewProps, - ScrollViewPropsIOS, - ScrollViewPropsAndroid, - Touchable { + extends ViewProps, ScrollViewPropsIOS, ScrollViewPropsAndroid, Touchable { /** * These styles will be applied to the scroll view content container which * wraps all of the child views. Example: @@ -681,43 +672,37 @@ export interface ScrollViewProps * */ onContentSizeChange?: - | ((contentWidth: number, contentHeight: number) => void) - | undefined; + ((contentWidth: number, contentHeight: number) => void) | undefined; /** * Fires at most once per frame during scrolling. */ onScroll?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + ((event: NativeSyntheticEvent) => void) | undefined; /** * Fires if a user initiates a scroll gesture. */ onScrollBeginDrag?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + ((event: NativeSyntheticEvent) => void) | undefined; /** * Fires when a user has finished scrolling. */ onScrollEndDrag?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + ((event: NativeSyntheticEvent) => void) | undefined; /** * Fires when scroll view has finished moving */ onMomentumScrollEnd?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + ((event: NativeSyntheticEvent) => void) | undefined; /** * Fires when scroll view has begun moving */ onMomentumScrollBegin?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + ((event: NativeSyntheticEvent) => void) | undefined; /** * When true the scroll view stops on multiples of the scroll view's size @@ -900,8 +885,7 @@ export interface ScrollViewImperativeMethods { export type ScrollResponderType = ScrollViewImperativeMethods; export interface PublicScrollViewInstance - extends HostInstance, - ScrollViewImperativeMethods {} + extends HostInstance, ScrollViewImperativeMethods {} declare class ScrollViewComponent extends React.Component {} export declare const ScrollViewBase: Constructor & diff --git a/packages/react-native/Libraries/Components/StatusBar/StatusBar.d.ts b/packages/react-native/Libraries/Components/StatusBar/StatusBar.d.ts index d383d62ea562..b53c8a43624d 100644 --- a/packages/react-native/Libraries/Components/StatusBar/StatusBar.d.ts +++ b/packages/react-native/Libraries/Components/StatusBar/StatusBar.d.ts @@ -10,10 +10,7 @@ import type * as React from 'react'; export type StatusBarStyle = - | 'default' - | 'auto' - | 'light-content' - | 'dark-content'; + 'default' | 'auto' | 'light-content' | 'dark-content'; export type StatusBarAnimation = 'none' | 'fade' | 'slide'; diff --git a/packages/react-native/Libraries/Components/Switch/Switch.d.ts b/packages/react-native/Libraries/Components/Switch/Switch.d.ts index 7ffe96e663f6..35f501b70b66 100644 --- a/packages/react-native/Libraries/Components/Switch/Switch.d.ts +++ b/packages/react-native/Libraries/Components/Switch/Switch.d.ts @@ -45,8 +45,7 @@ export interface SwitchChangeEventData extends TargetedEvent { value: boolean; } -export interface SwitchChangeEvent - extends NativeSyntheticEvent {} +export interface SwitchChangeEvent extends NativeSyntheticEvent {} export interface SwitchProps extends SwitchPropsIOS { /** @@ -76,9 +75,7 @@ export interface SwitchProps extends SwitchPropsIOS { * Invoked with the change event as an argument when the value changes. */ onChange?: - | ((event: SwitchChangeEvent) => Promise | void) - | null - | undefined; + ((event: SwitchChangeEvent) => Promise | void) | null | undefined; /** * Invoked with the new value when the value changes. diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts index 6f24c1d360ba..9cb01c24c646 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts @@ -40,43 +40,25 @@ export type KeyboardTypeIOS = | 'web-search'; export type KeyboardTypeAndroid = 'visible-password'; export type KeyboardTypeOptions = - | KeyboardType - | KeyboardTypeAndroid - | KeyboardTypeIOS; + KeyboardType | KeyboardTypeAndroid | KeyboardTypeIOS; export type InputModeOptions = - | 'none' - | 'text' - | 'decimal' - | 'numeric' - | 'tel' - | 'search' - | 'email' - | 'url'; + 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; export type ReturnKeyType = 'done' | 'go' | 'next' | 'search' | 'send'; export type ReturnKeyTypeAndroid = 'none' | 'previous'; export type ReturnKeyTypeIOS = - | 'default' - | 'google' - | 'join' - | 'route' - | 'yahoo' - | 'emergency-call'; + 'default' | 'google' | 'join' | 'route' | 'yahoo' | 'emergency-call'; export type ReturnKeyTypeOptions = - | ReturnKeyType - | ReturnKeyTypeAndroid - | ReturnKeyTypeIOS; + ReturnKeyType | ReturnKeyTypeAndroid | ReturnKeyTypeIOS; export type EnterKeyHintTypeAndroid = 'previous'; export type EnterKeyHintTypeIOS = 'enter'; export type EnterKeyHintType = 'done' | 'go' | 'next' | 'search' | 'send'; export type EnterKeyHintTypeOptions = - | EnterKeyHintType - | EnterKeyHintTypeAndroid - | EnterKeyHintTypeIOS; + EnterKeyHintType | EnterKeyHintTypeAndroid | EnterKeyHintTypeIOS; type DataDetectorTypes = | 'phoneNumber' @@ -145,11 +127,7 @@ export interface TextInputIOSProps { * When the clear button should appear on the right side of the text view */ clearButtonMode?: - | 'never' - | 'while-editing' - | 'unless-editing' - | 'always' - | undefined; + 'never' | 'while-editing' | 'unless-editing' | 'always' | undefined; /** * If true, clears the text field automatically when editing begins @@ -324,24 +302,14 @@ export interface TextInputIOSProps { * Set line break strategy on iOS. */ lineBreakStrategyIOS?: - | 'none' - | 'standard' - | 'hangul-word' - | 'push-out' - | undefined; + 'none' | 'standard' | 'hangul-word' | 'push-out' | undefined; /** * Set line break mode on iOS. * @platform ios */ lineBreakModeIOS?: - | 'wordWrapping' - | 'char' - | 'clip' - | 'head' - | 'middle' - | 'tail' - | undefined; + 'wordWrapping' | 'char' | 'clip' | 'head' | 'middle' | 'tail' | undefined; /** * If `false`, the iOS system will not insert an extra space after a paste operation @@ -573,7 +541,8 @@ export type TextInputSubmitEditingEvent = * @see https://reactnative.dev/docs/textinput#props */ export interface TextInputProps - extends ViewProps, + extends + ViewProps, TextInputIOSProps, TextInputAndroidProps, AccessibilityProps { @@ -865,8 +834,7 @@ export interface TextInputProps * Only called for multiline text inputs. */ onContentSizeChange?: - | ((e: TextInputContentSizeChangeEvent) => void) - | undefined; + ((e: TextInputContentSizeChangeEvent) => void) | undefined; /** * Callback that is called when text input ends. @@ -887,8 +855,7 @@ export interface TextInputProps * Callback that is called when a touch is released. */ onPressOut?: - | ((e: NativeSyntheticEvent) => void) - | undefined; + ((e: NativeSyntheticEvent) => void) | undefined; /** * Callback that is called when the text input is focused diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.d.ts b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.d.ts index 9de3953b3150..e4b600844546 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.d.ts +++ b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.d.ts @@ -32,15 +32,13 @@ interface ThemeAttributeBackgroundPropType extends BaseBackgroundPropType { } type BackgroundPropType = - | RippleBackgroundPropType - | ThemeAttributeBackgroundPropType; + RippleBackgroundPropType | ThemeAttributeBackgroundPropType; /** * @see https://reactnative.dev/docs/touchablenativefeedback#props */ export interface TouchableNativeFeedbackProps - extends TouchableWithoutFeedbackProps, - TVProps { + extends TouchableWithoutFeedbackProps, TVProps { /** * Determines the type of background drawable that's going to be used to display feedback. * It takes an object with type property and extra data depending on the type. diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.d.ts b/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.d.ts index d579a8d66b12..0436416e6ed7 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.d.ts +++ b/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.d.ts @@ -60,8 +60,7 @@ export interface TVProps { * @see https://reactnative.dev/docs/touchableopacity#props */ export interface TouchableOpacityProps - extends TouchableWithoutFeedbackProps, - TVProps { + extends TouchableWithoutFeedbackProps, TVProps { /** * Determines what the opacity of the wrapped view should be when touch is active. * Defaults to 0.2 diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.d.ts b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.d.ts index 572a96901d5d..c47baa7c57d8 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.d.ts +++ b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.d.ts @@ -37,7 +37,8 @@ export interface TouchableWithoutFeedbackPropsAndroid { * @see https://reactnative.dev/docs/touchablewithoutfeedback#props */ export interface TouchableWithoutFeedbackProps - extends TouchableWithoutFeedbackPropsIOS, + extends + TouchableWithoutFeedbackPropsIOS, TouchableWithoutFeedbackPropsAndroid, AccessibilityProps { children?: React.ReactNode | undefined; diff --git a/packages/react-native/Libraries/Components/View/ViewAccessibility.d.ts b/packages/react-native/Libraries/Components/View/ViewAccessibility.d.ts index 7b22fb120e8d..ab4fd7a62b05 100644 --- a/packages/react-native/Libraries/Components/View/ViewAccessibility.d.ts +++ b/packages/react-native/Libraries/Components/View/ViewAccessibility.d.ts @@ -13,8 +13,7 @@ import {NativeSyntheticEvent} from '../../Types/CoreEventTypes'; * @see https://reactnative.dev/docs/accessibility#accessibility-properties */ export interface AccessibilityProps - extends AccessibilityPropsAndroid, - AccessibilityPropsIOS { + extends AccessibilityPropsAndroid, AccessibilityPropsIOS { /** * When true, indicates that the view is an accessibility element. * By default, all the touchable elements are accessible. @@ -76,15 +75,13 @@ export interface AccessibilityProps * When `accessible` is true, the system will try to invoke this function when the user performs an accessibility custom action. */ onAccessibilityAction?: - | ((event: AccessibilityActionEvent) => void) - | undefined; + ((event: AccessibilityActionEvent) => void) | undefined; /** * [Android] Controlling if a view fires accessibility events and if it is reported to accessibility services. */ importantForAccessibility?: - | ('auto' | 'yes' | 'no' | 'no-hide-descendants') - | undefined; + ('auto' | 'yes' | 'no' | 'no-hide-descendants') | undefined; /** * A value indicating whether the accessibility elements contained within @@ -278,11 +275,7 @@ export interface AccessibilityPropsAndroid { * @platform android */ importantForAccessibility?: - | 'auto' - | 'yes' - | 'no' - | 'no-hide-descendants' - | undefined; + 'auto' | 'yes' | 'no' | 'no-hide-descendants' | undefined; /** * Enables the view to be screen reader focusable, not keyboard focusable. diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.d.ts b/packages/react-native/Libraries/Components/View/ViewPropTypes.d.ts index 62803cabce7e..86e72f419fdd 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.d.ts +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.d.ts @@ -129,7 +129,8 @@ export interface ViewPropsAndroid { * @see https://reactnative.dev/docs/view#props */ export interface ViewProps - extends ViewPropsAndroid, + extends + ViewPropsAndroid, ViewPropsIOS, GestureResponderHandlers, Touchable, diff --git a/packages/react-native/Libraries/Image/Image.d.ts b/packages/react-native/Libraries/Image/Image.d.ts index 03e70b518173..e889dcbb0e3b 100644 --- a/packages/react-native/Libraries/Image/Image.d.ts +++ b/packages/react-native/Libraries/Image/Image.d.ts @@ -99,9 +99,7 @@ interface ImagePropsAndroid { * @see https://reactnative.dev/docs/image#source */ export type ImageSourcePropType = - | ImageURISource - | ImageURISource[] - | ImageRequireSource; + ImageURISource | ImageURISource[] | ImageRequireSource; /** * @deprecated Use `ImageLoadEvent` instead. @@ -145,9 +143,7 @@ export interface ImageResolvedAssetSource { * @see https://reactnative.dev/docs/image */ export interface ImagePropsBase - extends ImagePropsIOS, - ImagePropsAndroid, - AccessibilityProps { + extends ImagePropsIOS, ImagePropsAndroid, AccessibilityProps { /** * Used to reference react managed images from native code. */ diff --git a/packages/react-native/Libraries/Image/ImageResizeMode.d.ts b/packages/react-native/Libraries/Image/ImageResizeMode.d.ts index 5df952b678de..024ef61ab103 100644 --- a/packages/react-native/Libraries/Image/ImageResizeMode.d.ts +++ b/packages/react-native/Libraries/Image/ImageResizeMode.d.ts @@ -8,12 +8,7 @@ */ export type ImageResizeMode = - | 'cover' - | 'contain' - | 'stretch' - | 'repeat' - | 'center' - | 'none'; + 'cover' | 'contain' | 'stretch' | 'repeat' | 'center' | 'none'; /** * @see ImageResizeMode.js diff --git a/packages/react-native/Libraries/Image/ImageSource.d.ts b/packages/react-native/Libraries/Image/ImageSource.d.ts index 98b9423175d8..43449f47309a 100644 --- a/packages/react-native/Libraries/Image/ImageSource.d.ts +++ b/packages/react-native/Libraries/Image/ImageSource.d.ts @@ -74,6 +74,4 @@ export interface ImageURISource { export type ImageRequireSource = number; export type ImageSource = - | ImageRequireSource - | ImageURISource - | ReadonlyArray; + ImageRequireSource | ImageURISource | ReadonlyArray; diff --git a/packages/react-native/Libraries/LayoutAnimation/LayoutAnimation.d.ts b/packages/react-native/Libraries/LayoutAnimation/LayoutAnimation.d.ts index 38ba92f73d52..73a353c4395d 100644 --- a/packages/react-native/Libraries/LayoutAnimation/LayoutAnimation.d.ts +++ b/packages/react-native/Libraries/LayoutAnimation/LayoutAnimation.d.ts @@ -8,22 +8,14 @@ */ export type LayoutAnimationType = - | 'spring' - | 'linear' - | 'easeInEaseOut' - | 'easeIn' - | 'easeOut' - | 'keyboard'; + 'spring' | 'linear' | 'easeInEaseOut' | 'easeIn' | 'easeOut' | 'keyboard'; export type LayoutAnimationTypes = { [type in LayoutAnimationType]: type; }; export type LayoutAnimationProperty = - | 'opacity' - | 'scaleX' - | 'scaleY' - | 'scaleXY'; + 'opacity' | 'scaleX' | 'scaleY' | 'scaleXY'; export type LayoutAnimationProperties = { [prop in LayoutAnimationProperty]: prop; diff --git a/packages/react-native/Libraries/Lists/SectionList.d.ts b/packages/react-native/Libraries/Lists/SectionList.d.ts index d21a0589bbe9..25529ed887a6 100644 --- a/packages/react-native/Libraries/Lists/SectionList.d.ts +++ b/packages/react-native/Libraries/Lists/SectionList.d.ts @@ -36,10 +36,7 @@ export interface SectionBase { renderItem?: SectionListRenderItem | undefined; ItemSeparatorComponent?: - | React.ComponentType - | React.ReactElement - | null - | undefined; + React.ComponentType | React.ReactElement | null | undefined; keyExtractor?: ((item: ItemT, index: number) => string) | undefined; } @@ -54,8 +51,10 @@ export type SectionListData = SectionBase< * @see https://reactnative.dev/docs/sectionlist.html#props */ -export interface SectionListRenderItemInfo - extends ListRenderItemInfo { +export interface SectionListRenderItemInfo< + ItemT, + SectionT = DefaultSectionT, +> extends ListRenderItemInfo { section: SectionListData; } @@ -68,16 +67,15 @@ type VirtualizedListWithoutPreConfiguredProps = Omit< 'stickyHeaderIndices' >; -export interface SectionListProps - extends VirtualizedListWithoutPreConfiguredProps { +export interface SectionListProps< + ItemT, + SectionT = DefaultSectionT, +> extends VirtualizedListWithoutPreConfiguredProps { /** * Rendered in between each section. */ SectionSeparatorComponent?: - | React.ComponentType - | React.ReactElement - | null - | undefined; + React.ComponentType | React.ReactElement | null | undefined; /** * A marker property for telling the list to re-render (since it implements PureComponent). @@ -260,8 +258,10 @@ export class SectionList< > extends SectionListComponent> {} /* This definition is deprecated because it extends the wrong base type */ -export interface SectionListStatic - extends React.ComponentClass> { +export interface SectionListStatic< + ItemT, + SectionT = DefaultSectionT, +> extends React.ComponentClass> { /** * Scrolls to the item at the specified sectionIndex and itemIndex (within the section) * positioned in the viewable area such that viewPosition 0 places it at the top diff --git a/packages/react-native/Libraries/Modal/Modal.d.ts b/packages/react-native/Libraries/Modal/Modal.d.ts index db4e023be707..e60f63145fa5 100644 --- a/packages/react-native/Libraries/Modal/Modal.d.ts +++ b/packages/react-native/Libraries/Modal/Modal.d.ts @@ -58,11 +58,7 @@ export interface ModalPropsIOS { * The `presentationStyle` determines the style of modal to show */ presentationStyle?: - | 'fullScreen' - | 'pageSheet' - | 'formSheet' - | 'overFullScreen' - | undefined; + 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen' | undefined; /** * The `supportedOrientations` prop allows the modal to be rotated to any of the specified orientations. @@ -88,8 +84,7 @@ export interface ModalPropsIOS { * The orientation provided is only 'portrait' or 'landscape'. This callback is also called on initial render, regardless of the current orientation. */ onOrientationChange?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + ((event: NativeSyntheticEvent) => void) | undefined; /** * Controls whether the modal can be dismissed by swiping down on iOS. diff --git a/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts b/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts index 924c3d3f131f..96ad77f17ecf 100644 --- a/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts +++ b/packages/react-native/Libraries/PermissionsAndroid/PermissionsAndroid.d.ts @@ -74,51 +74,53 @@ export interface PermissionsAndroidStatic { * A list of specified "dangerous" permissions that require prompting the user */ PERMISSIONS: { - [key in - | 'READ_CALENDAR' - | 'WRITE_CALENDAR' - | 'CAMERA' - | 'READ_CONTACTS' - | 'WRITE_CONTACTS' - | 'GET_ACCOUNTS' - | 'ACCESS_FINE_LOCATION' - | 'ACCESS_COARSE_LOCATION' - | 'ACCESS_BACKGROUND_LOCATION' - | 'RECORD_AUDIO' - | 'READ_PHONE_STATE' - | 'CALL_PHONE' - | 'READ_CALL_LOG' - | 'WRITE_CALL_LOG' - | 'ADD_VOICEMAIL' - | 'READ_VOICEMAIL' - | 'WRITE_VOICEMAIL' - | 'USE_SIP' - | 'PROCESS_OUTGOING_CALLS' - | 'BODY_SENSORS' - | 'BODY_SENSORS_BACKGROUND' - | 'SEND_SMS' - | 'RECEIVE_SMS' - | 'READ_SMS' - | 'RECEIVE_WAP_PUSH' - | 'RECEIVE_MMS' - | 'READ_EXTERNAL_STORAGE' - | 'READ_MEDIA_IMAGES' - | 'READ_MEDIA_VIDEO' - | 'READ_MEDIA_AUDIO' - | 'READ_MEDIA_VISUAL_USER_SELECTED' - | 'WRITE_EXTERNAL_STORAGE' - | 'BLUETOOTH_CONNECT' - | 'BLUETOOTH_SCAN' - | 'BLUETOOTH_ADVERTISE' - | 'ACCESS_MEDIA_LOCATION' - | 'ACCEPT_HANDOVER' - | 'ACTIVITY_RECOGNITION' - | 'ANSWER_PHONE_CALLS' - | 'READ_PHONE_NUMBERS' - | 'UWB_RANGING' - | 'POST_NOTIFICATIONS' - | 'NEARBY_WIFI_DEVICES' - | 'ACCESS_LOCAL_NETWORK']: Permission; + [ + key in + | 'READ_CALENDAR' + | 'WRITE_CALENDAR' + | 'CAMERA' + | 'READ_CONTACTS' + | 'WRITE_CONTACTS' + | 'GET_ACCOUNTS' + | 'ACCESS_FINE_LOCATION' + | 'ACCESS_COARSE_LOCATION' + | 'ACCESS_BACKGROUND_LOCATION' + | 'RECORD_AUDIO' + | 'READ_PHONE_STATE' + | 'CALL_PHONE' + | 'READ_CALL_LOG' + | 'WRITE_CALL_LOG' + | 'ADD_VOICEMAIL' + | 'READ_VOICEMAIL' + | 'WRITE_VOICEMAIL' + | 'USE_SIP' + | 'PROCESS_OUTGOING_CALLS' + | 'BODY_SENSORS' + | 'BODY_SENSORS_BACKGROUND' + | 'SEND_SMS' + | 'RECEIVE_SMS' + | 'READ_SMS' + | 'RECEIVE_WAP_PUSH' + | 'RECEIVE_MMS' + | 'READ_EXTERNAL_STORAGE' + | 'READ_MEDIA_IMAGES' + | 'READ_MEDIA_VIDEO' + | 'READ_MEDIA_AUDIO' + | 'READ_MEDIA_VISUAL_USER_SELECTED' + | 'WRITE_EXTERNAL_STORAGE' + | 'BLUETOOTH_CONNECT' + | 'BLUETOOTH_SCAN' + | 'BLUETOOTH_ADVERTISE' + | 'ACCESS_MEDIA_LOCATION' + | 'ACCEPT_HANDOVER' + | 'ACTIVITY_RECOGNITION' + | 'ANSWER_PHONE_CALLS' + | 'READ_PHONE_NUMBERS' + | 'UWB_RANGING' + | 'POST_NOTIFICATIONS' + | 'NEARBY_WIFI_DEVICES' + | 'ACCESS_LOCAL_NETWORK' + ]: Permission; }; new (): PermissionsAndroidStatic; /** diff --git a/packages/react-native/Libraries/PushNotificationIOS/PushNotificationIOS.d.ts b/packages/react-native/Libraries/PushNotificationIOS/PushNotificationIOS.d.ts index 364bd8aa01ed..f68bd1ff85b1 100644 --- a/packages/react-native/Libraries/PushNotificationIOS/PushNotificationIOS.d.ts +++ b/packages/react-native/Libraries/PushNotificationIOS/PushNotificationIOS.d.ts @@ -80,22 +80,13 @@ type ScheduleLocalNotificationDetails = { fireDate?: number | string | undefined; isSilent?: boolean | undefined; repeatInterval?: - | 'year' - | 'month' - | 'week' - | 'day' - | 'hour' - | 'minute' - | undefined; + 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | undefined; soundName?: string | undefined; userInfo?: Object | undefined; }; export type PushNotificationEventName = - | 'notification' - | 'localNotification' - | 'register' - | 'registrationError'; + 'notification' | 'localNotification' | 'register' | 'registrationError'; type FetchResult = { NewData: 'UIBackgroundFetchResultNewData'; diff --git a/packages/react-native/Libraries/ReactNative/RendererProxy.d.ts b/packages/react-native/Libraries/ReactNative/RendererProxy.d.ts index f7f451c09934..3e0fa1c1c9c3 100644 --- a/packages/react-native/Libraries/ReactNative/RendererProxy.d.ts +++ b/packages/react-native/Libraries/ReactNative/RendererProxy.d.ts @@ -13,8 +13,5 @@ export type NodeHandle = number; export function findNodeHandle( componentOrHandle: - | null - | number - | React.Component - | React.ComponentClass, + null | number | React.Component | React.ComponentClass, ): null | NodeHandle; diff --git a/packages/react-native/Libraries/ReactNative/UIManager.d.ts b/packages/react-native/Libraries/ReactNative/UIManager.d.ts index 773419bccf9c..f6c59166b5f7 100644 --- a/packages/react-native/Libraries/ReactNative/UIManager.d.ts +++ b/packages/react-native/Libraries/ReactNative/UIManager.d.ts @@ -86,8 +86,7 @@ export interface UIManagerStatic { * UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); */ setLayoutAnimationEnabledExperimental?: - | ((value: boolean) => void) - | undefined; + ((value: boolean) => void) | undefined; getViewManagerConfig: (name: string) => { Commands: {[key: string]: number}; diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheet.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheet.d.ts index 2b69bd1dc216..04583bc23f59 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheet.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheet.d.ts @@ -15,8 +15,9 @@ export interface StyleSheetProperties { } type Falsy = undefined | null | false | ''; -interface RecursiveArray - extends Array | RecursiveArray> {} +interface RecursiveArray extends Array< + T | ReadonlyArray | RecursiveArray +> {} export type StyleProp = T | RecursiveArray | Falsy; type OpaqueColorValue = symbol & {__TYPE__: 'Color'}; diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index a34ff98222e7..80c004612b10 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -12,18 +12,10 @@ import {ImageResizeMode} from '../Image/ImageResizeMode'; import {ColorValue} from './StyleSheet'; type FlexAlignType = - | 'flex-start' - | 'flex-end' - | 'center' - | 'stretch' - | 'baseline'; + 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'; export type DimensionValue = - | number - | 'auto' - | `${number}%` - | Animated.AnimatedNode - | null; + number | 'auto' | `${number}%` | Animated.AnimatedNode | null; type AnimatableNumericValue = number | Animated.AnimatedNode; type AnimatableStringValue = string | Animated.AnimatedNode; @@ -61,11 +53,7 @@ export interface FlexStyle { flex?: number | undefined; flexBasis?: DimensionValue | undefined; flexDirection?: - | 'row' - | 'column' - | 'row-reverse' - | 'column-reverse' - | undefined; + 'row' | 'column' | 'row-reverse' | 'column-reverse' | undefined; rowGap?: number | string | undefined; gap?: number | string | undefined; columnGap?: number | string | undefined; @@ -381,10 +369,7 @@ export type LinearGradientValue = { export type GradientValue = LinearGradientValue; type RadialExtent = - | 'closest-corner' - | 'closest-side' - | 'farthest-corner' - | 'farthest-side'; + 'closest-corner' | 'closest-side' | 'farthest-corner' | 'farthest-side'; export type RadialGradientPosition = | { top: number | string; @@ -430,10 +415,7 @@ export type BackgroundSizeValue = { }; export type BackgroundRepeatKeyword = - | 'repeat' - | 'space' - | 'round' - | 'no-repeat'; + 'repeat' | 'space' | 'round' | 'no-repeat'; export type BackgroundPositionValue = | { @@ -519,21 +501,13 @@ export interface ViewStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle { mixBlendMode?: BlendMode | undefined; backgroundImage?: ReadonlyArray | string | undefined; experimental_backgroundImage?: - | ReadonlyArray - | string - | undefined; + ReadonlyArray | string | undefined; experimental_backgroundSize?: - | ReadonlyArray - | string - | undefined; + ReadonlyArray | string | undefined; experimental_backgroundPosition?: - | ReadonlyArray - | string - | undefined; + ReadonlyArray | string | undefined; experimental_backgroundRepeat?: - | ReadonlyArray - | string - | undefined; + ReadonlyArray | string | undefined; } export type FontVariant = @@ -574,12 +548,7 @@ export interface TextStyleIOS extends ViewStyle { fontVariant?: FontVariant[] | undefined; textDecorationColor?: ColorValue | undefined; textDecorationStyle?: - | 'solid' - | 'double' - | 'dotted' - | 'dashed' - | 'wavy' - | undefined; + 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy' | undefined; writingDirection?: 'auto' | 'ltr' | 'rtl' | undefined; } @@ -650,12 +619,7 @@ export interface TextStyle extends TextStyleIOS, TextStyleAndroid, ViewStyle { | 'underline line-through' | undefined; textDecorationStyle?: - | 'solid' - | 'double' - | 'dotted' - | 'dashed' - | 'wavy' - | undefined; + 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy' | undefined; textDecorationColor?: ColorValue | undefined; textShadowColor?: ColorValue | undefined; textShadowOffset?: {width: number; height: number} | undefined; diff --git a/packages/react-native/Libraries/Text/Text.d.ts b/packages/react-native/Libraries/Text/Text.d.ts index fe77abfcc43c..cdb26800b76c 100644 --- a/packages/react-native/Libraries/Text/Text.d.ts +++ b/packages/react-native/Libraries/Text/Text.d.ts @@ -52,11 +52,7 @@ export interface TextPropsIOS { * Set line break strategy on iOS. */ lineBreakStrategyIOS?: - | 'none' - | 'standard' - | 'hangul-word' - | 'push-out' - | undefined; + 'none' | 'standard' | 'hangul-word' | 'push-out' | undefined; } export interface TextPropsAndroid { @@ -86,13 +82,7 @@ export interface TextPropsAndroid { * By default no data types are detected. */ dataDetectorType?: - | null - | 'phoneNumber' - | 'link' - | 'email' - | 'none' - | 'all' - | undefined; + null | 'phoneNumber' | 'link' | 'email' | 'none' | 'all' | undefined; /** * Hyphenation strategy @@ -102,9 +92,7 @@ export interface TextPropsAndroid { // https://reactnative.dev/docs/text#props export interface TextProps - extends TextPropsIOS, - TextPropsAndroid, - AccessibilityProps { + extends TextPropsIOS, TextPropsAndroid, AccessibilityProps { /** * Specifies whether fonts should scale to respect Text Size accessibility settings. * The default is `true`. @@ -216,8 +204,7 @@ export interface TextProps * Defines how far your touch may move off of the button, before deactivating the button. */ pressRetentionOffset?: - | {top: number; left: number; bottom: number; right: number} - | undefined; + {top: number; left: number; bottom: number; right: number} | undefined; } /** diff --git a/packages/react-native/Libraries/Types/CoreEventTypes.d.ts b/packages/react-native/Libraries/Types/CoreEventTypes.d.ts index 3bcef4f26b9e..e55fae6b7b8d 100644 --- a/packages/react-native/Libraries/Types/CoreEventTypes.d.ts +++ b/packages/react-native/Libraries/Types/CoreEventTypes.d.ts @@ -45,8 +45,11 @@ export interface TextLayoutEventData extends TargetedEvent { export type TextLayoutEvent = NativeSyntheticEvent; // Similar to React.SyntheticEvent except for nativeEvent -export interface NativeSyntheticEvent - extends React.BaseSyntheticEvent {} +export interface NativeSyntheticEvent extends React.BaseSyntheticEvent< + T, + HostInstance, + HostInstance +> {} export interface NativeTouchEvent { /** @@ -239,8 +242,7 @@ export interface NativePointerEvent extends NativeMouseEvent { export type PointerEvent = NativeSyntheticEvent; -export interface GestureResponderEvent - extends NativeSyntheticEvent {} +export interface GestureResponderEvent extends NativeSyntheticEvent {} export interface MouseEvent extends NativeSyntheticEvent {} diff --git a/packages/react-native/Libraries/Utilities/Platform.d.ts b/packages/react-native/Libraries/Utilities/Platform.d.ts index b7252bd27c63..01da20012a04 100644 --- a/packages/react-native/Libraries/Utilities/Platform.d.ts +++ b/packages/react-native/Libraries/Utilities/Platform.d.ts @@ -11,12 +11,7 @@ * @see https://reactnative.dev/docs/platform-specific-code#content */ export type PlatformOSType = - | 'ios' - | 'android' - | 'macos' - | 'windows' - | 'web' - | 'native'; + 'ios' | 'android' | 'macos' | 'windows' | 'web' | 'native'; type PlatformConstants = { isTesting: boolean; isDisableAnimations?: boolean | undefined; diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index b5cdeaf11758..214d2651245e 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<<637f1d0b012ebca68c9e020a52eb2803>> + * @generated SignedSource<<0ef11b701c83f2414d61f7ec37c78df1>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -178,9 +178,7 @@ declare const createAnimatedComponent: typeof $$AnimatedImplementation.createAni declare const decay: typeof $$AnimatedImplementation.decay declare const decayImpl: ( value: - | AnimatedColor_default - | AnimatedValue_default - | AnimatedValueXY_default, + AnimatedColor_default | AnimatedValue_default | AnimatedValueXY_default, config: DecayAnimationConfig, ) => CompositeAnimation declare const delay: typeof $$AnimatedImplementation.delay @@ -291,8 +289,7 @@ declare let NativeModules_default: { [moduleName: string]: any } declare const NativeTouchable: - | typeof TouchableNativeFeedback - | typeof TouchableOpacity + typeof TouchableNativeFeedback | typeof TouchableOpacity declare const Networking: typeof RCTNetworking_default declare const PanResponder: typeof PanResponder_default declare const PanResponder_default: { @@ -393,9 +390,7 @@ declare let Settings_default: { declare const spring: typeof $$AnimatedImplementation.spring declare const springImpl: ( value: - | AnimatedColor_default - | AnimatedValue_default - | AnimatedValueXY_default, + AnimatedColor_default | AnimatedValue_default | AnimatedValueXY_default, config: SpringAnimationConfig, ) => CompositeAnimation declare const stagger: typeof $$AnimatedImplementation.stagger @@ -434,9 +429,7 @@ declare const TextInput: TextInputType declare const timing: typeof $$AnimatedImplementation.timing declare const timingImpl: ( value: - | AnimatedColor_default - | AnimatedValue_default - | AnimatedValueXY_default, + AnimatedColor_default | AnimatedValue_default | AnimatedValueXY_default, config: TimingAnimationConfig, ) => CompositeAnimation declare const ToastAndroid: typeof ToastAndroid_default @@ -530,10 +523,8 @@ declare const VirtualizedSectionList: typeof VirtualizedSectionList_default declare const VirtualizedSectionList_default: VirtualizedSectionListType declare const VirtualizedSectionListComponent_default: < ItemT, - SectionT extends SectionBase< - ItemT, - DefaultVirtualizedSectionT - > = DefaultVirtualizedSectionT, + SectionT extends SectionBase = + DefaultVirtualizedSectionT, >( props: VirtualizedSectionListProps & { ref?: React.Ref<{ @@ -688,18 +679,9 @@ declare type ____LayoutStyle_Internal = { | "space-evenly" | "stretch" readonly alignItems?: - | "baseline" - | "center" - | "flex-end" - | "flex-start" - | "stretch" + "baseline" | "center" | "flex-end" | "flex-start" | "stretch" readonly alignSelf?: - | "auto" - | "baseline" - | "center" - | "flex-end" - | "flex-start" - | "stretch" + "auto" | "baseline" | "center" | "flex-end" | "flex-start" | "stretch" readonly aspectRatio?: number | string readonly borderBottomWidth?: number readonly borderEndWidth?: number @@ -810,26 +792,13 @@ declare type ____TextStyle_InternalBase = { readonly letterSpacing?: number readonly lineHeight?: number readonly textAlign?: - | "auto" - | "center" - | "end" - | "justify" - | "left" - | "right" - | "start" + "auto" | "center" | "end" | "justify" | "left" | "right" | "start" readonly textAlignVertical?: "auto" | "bottom" | "center" | "top" readonly textDecorationColor?: ____ColorValue_Internal readonly textDecorationLine?: - | "line-through" - | "none" - | "underline line-through" - | "underline" + "line-through" | "none" | "underline line-through" | "underline" readonly textDecorationStyle?: - | "dashed" - | "dotted" - | "double" - | "solid" - | "wavy" + "dashed" | "dotted" | "double" | "solid" | "wavy" readonly textShadowColor?: ____ColorValue_Internal readonly textShadowOffset?: { readonly height: number @@ -909,8 +878,7 @@ declare type ____TransformStyle_Internal = { > | string readonly transformOrigin?: - | [number | string, number | string, number | string] - | string + [number | string, number | string, number | string] | string } declare type ____ViewStyle_Internal = Readonly< ____ViewStyle_InternalCore & ____ViewStyle_InternalOverrides @@ -1018,12 +986,7 @@ declare type AccessibilityActionInfo = { readonly name: AccessibilityActionName | string } declare type AccessibilityActionName = - | "activate" - | "decrement" - | "escape" - | "increment" - | "longpress" - | "magicTap" + "activate" | "decrement" | "escape" | "increment" | "longpress" | "magicTap" declare type AccessibilityEventDefinitions = AccessibilityEventDefinitionsAndroid & AccessibilityEventDefinitionsIOS & { @@ -1049,10 +1012,7 @@ declare type AccessibilityEventDefinitionsIOS = { reduceTransparencyChanged: [boolean] } declare type AccessibilityEventTypes = - | "click" - | "focus" - | "viewHoverEnter" - | "windowStateChange" + "click" | "focus" | "viewHoverEnter" | "windowStateChange" declare type AccessibilityInfo = typeof AccessibilityInfo declare type AccessibilityProps = Readonly< AccessibilityPropsAndroid & @@ -1080,16 +1040,12 @@ declare type AccessibilityProps = Readonly< > declare type AccessibilityPropsAndroid = { readonly accessibilityLabelledBy?: - | (Array | undefined) - | (string | undefined) + (Array | undefined) | (string | undefined) readonly accessibilityLiveRegion?: "assertive" | "none" | "polite" readonly "aria-labelledby"?: string readonly "aria-live"?: "assertive" | "off" | "polite" readonly importantForAccessibility?: - | "auto" - | "no-hide-descendants" - | "no" - | "yes" + "auto" | "no-hide-descendants" | "no" | "yes" readonly screenReaderFocusable?: boolean } declare type AccessibilityPropsIOS = { @@ -1163,8 +1119,7 @@ declare type ActionSheetIOSOptions = { readonly cancelButtonIndex?: number readonly cancelButtonTintColor?: ColorValue | ProcessedColorValue readonly destructiveButtonIndex?: - | (Array | undefined) - | (number | undefined) + (Array | undefined) | (number | undefined) readonly disabledButtonIndices?: Array readonly disabledButtonTintColor?: ColorValue | ProcessedColorValue readonly message?: string @@ -1182,8 +1137,9 @@ declare type ActivityIndicatorInstance = HostInstance declare type ActivityIndicatorIOSProps = { readonly hidesWhenStopped?: boolean } -declare interface ActivityIndicatorProps - extends Readonly { +declare interface ActivityIndicatorProps extends Readonly< + ViewProps & ActivityIndicatorIOSProps +> { readonly animating?: boolean readonly color?: ColorValue readonly size?: IndicatorSize @@ -1203,9 +1159,7 @@ declare class Alert { title: null | string | undefined, message?: null | string | undefined, callbackOrButtons?: - | (((text: string) => void) | AlertButtons) - | null - | undefined, + (((text: string) => void) | AlertButtons) | null | undefined, type?: AlertType | null | undefined, defaultValue?: string, keyboardType?: string, @@ -1226,10 +1180,7 @@ declare type AlertOptions = { userInterfaceStyle?: "dark" | "light" | "unspecified" } declare type AlertType = - | "default" - | "login-password" - | "plain-text" - | "secure-text" + "default" | "login-password" | "plain-text" | "secure-text" declare type AndroidDrawable = AndroidDrawableRipple | AndroidDrawableThemeAttr declare type AndroidDrawableRipple = { readonly alpha?: number @@ -1640,11 +1591,7 @@ declare class AppStateImpl { constructor() } declare type AppStateStatus = - | "active" - | "background" - | "extension" - | "inactive" - | "unknown" + "active" | "background" | "extension" | "inactive" | "unknown" declare interface ArrayLike_2 extends Iterable { [indexer: number]: T readonly length: number @@ -1739,10 +1686,7 @@ declare interface ButtonProps { readonly disabled?: boolean readonly hasTVPreferredFocus?: boolean readonly importantForAccessibility?: - | "auto" - | "no-hide-descendants" - | "no" - | "yes" + "auto" | "no-hide-descendants" | "no" | "yes" readonly nextFocusDown?: number readonly nextFocusForward?: number readonly nextFocusLeft?: number @@ -2123,9 +2067,7 @@ declare type EnterKeyHintType = "done" | "go" | "next" | "search" | "send" declare type EnterKeyHintTypeAndroid = "previous" declare type EnterKeyHintTypeIOS = "enter" declare type EnterKeyHintTypeOptions = - | EnterKeyHintType - | EnterKeyHintTypeAndroid - | EnterKeyHintTypeIOS + EnterKeyHintType | EnterKeyHintTypeAndroid | EnterKeyHintTypeIOS declare type ErrorHandler = (error: unknown, isFatal: boolean) => void declare type ErrorUtils = { applyWithGuard, TOut>( @@ -2167,8 +2109,7 @@ declare class EventEmitter< TEventToArgsMap extends Readonly< Record> > = Readonly>>, -> implements IEventEmitter -{ +> implements IEventEmitter { addListener( eventType: TEvent, listener: (...args: TEventToArgsMap[TEvent]) => unknown, @@ -2311,19 +2252,18 @@ declare class FlatList extends React.PureComponent< declare type FlatListBaseProps = RequiredFlatListProps & OptionalFlatListProps declare type FlatListInstance = FlatList -declare interface FlatListProps - extends Readonly< - Omit< - VirtualizedListProps, - | "data" - | "getItem" - | "getItemCount" - | "getItemLayout" - | "keyExtractor" - | "renderItem" - > & - FlatListBaseProps - > {} +declare interface FlatListProps extends Readonly< + Omit< + VirtualizedListProps, + | "data" + | "getItem" + | "getItemCount" + | "getItemLayout" + | "keyExtractor" + | "renderItem" + > & + FlatListBaseProps +> {} declare type flatten = typeof flatten declare type FlattenDepthLimiter = [void, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9] declare function flattenStyle_default< @@ -2492,8 +2432,9 @@ declare class ImageBackground extends React.Component { setNativeProps(props: {}): void } declare type ImageBackgroundInstance = ImageBackground -declare interface ImageBackgroundProps - extends Readonly> { +declare interface ImageBackgroundProps extends Readonly< + Omit +> { readonly children?: React.ReactNode readonly imageRef?: React.Ref> readonly imageStyle?: ImageStyleProp @@ -2573,19 +2514,18 @@ declare type ImagePropsAndroid = { readonly resizeMethod?: "auto" | "none" | "resize" | "scale" readonly resizeMultiplier?: number } -declare interface ImagePropsBase - extends Readonly< - Omit< - Omit, - | "accessibilityLabel" - | "accessible" - | "aria-label" - | "aria-labelledby" - | "children" - | "onLayout" - | "testID" - > - > { +declare interface ImagePropsBase extends Readonly< + Omit< + Omit, + | "accessibilityLabel" + | "accessible" + | "aria-label" + | "aria-labelledby" + | "children" + | "onLayout" + | "testID" + > +> { readonly accessibilityLabel?: string readonly accessible?: boolean readonly alt?: string @@ -2626,21 +2566,14 @@ declare type ImagePropsIOS = { } declare type ImageRequireSource = number declare type ImageResizeMode = - | "center" - | "contain" - | "cover" - | "none" - | "repeat" - | "stretch" + "center" | "contain" | "cover" | "none" | "repeat" | "stretch" declare type ImageResolvedAssetSource = ResolvedAssetSource declare type ImageSize = { height: number width: number } declare type ImageSource = - | ImageRequireSource - | ImageURISource - | ReadonlyArray + ImageRequireSource | ImageURISource | ReadonlyArray declare type ImageSourcePropType = ImageSource declare type ImageStyle = ____ImageStyle_Internal declare type ImageStyleProp = ____ImageStyleProp_Internal @@ -2691,18 +2624,9 @@ declare interface InputAccessoryViewProps { readonly style?: ViewStyleProp } declare type InputModeOptions = - | "decimal" - | "email" - | "none" - | "numeric" - | "search" - | "tel" - | "text" - | "url" + "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" declare type InputValue = - | (ColorValue | RgbaAnimatedValue | RgbaValue) - | null - | undefined + (ColorValue | RgbaAnimatedValue | RgbaValue) | null | undefined declare type Insets = Rect declare type Int32 = number declare type InternalInstanceHandle = symbol & { @@ -2714,9 +2638,7 @@ declare type InternalTextInput = ( }, ) => React.ReactNode declare type InterpolationConfigSupportedOutputType = - | NativeColorValue - | number - | string + NativeColorValue | number | string declare type InterpolationConfigType< OutputT extends InterpolationConfigSupportedOutputType, > = Readonly< @@ -2837,11 +2759,7 @@ declare type KeyboardEventDefinitions = { keyboardWillShow: [KeyboardEvent] } declare type KeyboardEventEasing = - | "easeIn" - | "easeInEaseOut" - | "easeOut" - | "keyboard" - | "linear" + "easeIn" | "easeInEaseOut" | "easeOut" | "keyboard" | "linear" declare type KeyboardEventName = keyof KeyboardEventDefinitions declare class KeyboardImpl { addListener( @@ -2881,9 +2799,7 @@ declare type KeyboardTypeIOS = | "twitter" | "web-search" declare type KeyboardTypeOptions = - | KeyboardType - | KeyboardTypeAndroid - | KeyboardTypeIOS + KeyboardType | KeyboardTypeAndroid | KeyboardTypeIOS declare type KeyDownEvent = NativeSyntheticEvent declare type KeyEvent = { readonly altKey: boolean @@ -2923,17 +2839,9 @@ declare type LayoutAnimationProperties = Readonly<{ [prop in LayoutAnimationProperty]: prop }> declare type LayoutAnimationProperty = - | "opacity" - | "scaleX" - | "scaleXY" - | "scaleY" + "opacity" | "scaleX" | "scaleXY" | "scaleY" declare type LayoutAnimationType = - | "easeIn" - | "easeInEaseOut" - | "easeOut" - | "keyboard" - | "linear" - | "spring" + "easeIn" | "easeInEaseOut" | "easeOut" | "keyboard" | "linear" | "spring" declare type LayoutAnimationTypes = Readonly<{ [type in LayoutAnimationType]: type }> @@ -3134,10 +3042,7 @@ declare type ModalPropsIOS = { onDismiss?: () => void onOrientationChange?: DirectEventHandler presentationStyle?: - | "formSheet" - | "fullScreen" - | "overFullScreen" - | "pageSheet" + "formSheet" | "fullScreen" | "overFullScreen" | "pageSheet" supportedOrientations?: ReadonlyArray< | "landscape-left" | "landscape-right" @@ -3191,8 +3096,7 @@ declare class NativeEventEmitter< TEventToArgsMap extends Readonly< Record> > = Readonly>>, -> implements IEventEmitter -{ +> implements IEventEmitter { addListener( eventType: TEvent, listener: (...args: TEventToArgsMap[TEvent]) => unknown, @@ -3633,23 +3537,14 @@ declare type Platform = typeof Platform declare function PlatformColor(...names: Array): NativeColorValue declare type PlatformConfig = {} declare type PlatformOSType = - | "android" - | "ios" - | "macos" - | "native" - | "web" - | "windows" + "android" | "ios" | "macos" | "native" | "web" | "windows" declare type PlatformSelectSpec = | (Omit, "default"> & { default: T }) | OptionalPlatformSelectSpec declare type PlatformType = - | AndroidPlatform - | IOSPlatform - | MacOSPlatform - | WebPlatform - | WindowsPlatform + AndroidPlatform | IOSPlatform | MacOSPlatform | WebPlatform | WindowsPlatform declare type PointerEvent = NativeSyntheticEvent declare type PointerEventProps = { readonly onClick?: (event: PointerEvent) => void @@ -3753,8 +3648,7 @@ declare type PressableBaseProps = { readonly blockNativeResponder?: boolean readonly cancelable?: boolean readonly children?: - | ((state: PressableStateCallbackType) => React.ReactNode) - | React.ReactNode + ((state: PressableStateCallbackType) => React.ReactNode) | React.ReactNode readonly delayHoverIn?: number readonly delayHoverOut?: number readonly delayLongPress?: number @@ -3770,16 +3664,14 @@ declare type PressableBaseProps = { readonly onPressOut?: (event: GestureResponderEvent) => unknown readonly pressRetentionOffset?: RectOrSize readonly style?: - | ((state: PressableStateCallbackType) => ViewStyleProp) - | ViewStyleProp + ((state: PressableStateCallbackType) => ViewStyleProp) | ViewStyleProp readonly testID?: string readonly testOnly_pressed?: boolean } declare type PressableInstance = HostInstance -declare interface PressableProps - extends Readonly< - Omit & PressableBaseProps - > {} +declare interface PressableProps extends Readonly< + Omit & PressableBaseProps +> {} declare type PressableStateCallbackType = { readonly pressed: boolean } @@ -3890,10 +3782,7 @@ declare type PushNotificationPermissions = { sound: boolean } declare type RadialExtent = - | "closest-corner" - | "closest-side" - | "farthest-corner" - | "farthest-side" + "closest-corner" | "closest-side" | "farthest-corner" | "farthest-side" declare type RadialGradientPosition = | { bottom: number | string @@ -4128,13 +4017,12 @@ declare type RefreshControlBaseProps = { readonly refreshing: boolean } declare type RefreshControlInstance = RefreshControl -declare interface RefreshControlProps - extends Readonly< - ViewProps & - RefreshControlPropsIOS & - RefreshControlPropsAndroid & - RefreshControlBaseProps - > {} +declare interface RefreshControlProps extends Readonly< + ViewProps & + RefreshControlPropsIOS & + RefreshControlPropsAndroid & + RefreshControlBaseProps +> {} declare type RefreshControlPropsAndroid = { readonly colors?: ReadonlyArray readonly enabled?: boolean @@ -4254,16 +4142,9 @@ declare type ResponderSyntheticEvent = Readonly< declare type ReturnKeyType = "done" | "go" | "next" | "search" | "send" declare type ReturnKeyTypeAndroid = "none" | "previous" declare type ReturnKeyTypeIOS = - | "default" - | "emergency-call" - | "google" - | "join" - | "route" - | "yahoo" + "default" | "emergency-call" | "google" | "join" | "route" | "yahoo" declare type ReturnKeyTypeOptions = - | ReturnKeyType - | ReturnKeyTypeAndroid - | ReturnKeyTypeIOS + ReturnKeyType | ReturnKeyTypeAndroid | ReturnKeyTypeIOS declare type RgbaAnimatedValue = { readonly a: AnimatedValue_default readonly b: AnimatedValue_default @@ -4455,15 +4336,10 @@ declare interface ScrollViewImperativeMethods { ) => void } declare interface ScrollViewInstance - extends HostInstance, - ScrollViewImperativeMethods {} -declare interface ScrollViewProps - extends Readonly< - ViewProps & - ScrollViewPropsIOS & - ScrollViewPropsAndroid & - ScrollViewBaseProps - > {} + extends HostInstance, ScrollViewImperativeMethods {} +declare interface ScrollViewProps extends Readonly< + ViewProps & ScrollViewPropsIOS & ScrollViewPropsAndroid & ScrollViewBaseProps +> {} declare type ScrollViewPropsAndroid = { readonly endFillColor?: ColorValue readonly fadingEdgeLength?: @@ -4490,10 +4366,7 @@ declare type ScrollViewPropsIOS = { readonly centerContent?: boolean readonly contentInset?: EdgeInsetsProp readonly contentInsetAdjustmentBehavior?: - | "always" - | "automatic" - | "never" - | "scrollableAxes" + "always" | "automatic" | "never" | "scrollableAxes" readonly directionalLockEnabled?: boolean readonly disableScrollViewPanResponder?: boolean readonly indicatorStyle?: "black" | "default" | "white" @@ -4565,15 +4438,17 @@ declare type SectionListData< | (SectionBase & SectionT) | SectionT declare type SectionListInstance = SectionList -declare interface SectionListProps - extends Readonly< - Omit< - VirtualizedSectionListProps, - "getItem" | "getItemCount" | "keyExtractor" | "renderItem" - > & - RequiredSectionListProps & - OptionalSectionListProps - > {} +declare interface SectionListProps< + ItemT, + SectionT = DefaultSectionT, +> extends Readonly< + Omit< + VirtualizedSectionListProps, + "getItem" | "getItemCount" | "keyExtractor" | "renderItem" + > & + RequiredSectionListProps & + OptionalSectionListProps +> {} declare type SectionListRenderItem = ( info: SectionListRenderItemInfo, ) => null | React.ReactNode @@ -4896,12 +4771,7 @@ declare type StickyHeaderComponentType = ( }, ) => React.ReactNode declare type StyleProp = - | "" - | false - | null - | ReadonlyArray> - | T - | void + "" | false | null | ReadonlyArray> | T | void declare namespace StyleSheet { export { AbsoluteFillStyle, @@ -4922,8 +4792,9 @@ declare type SwitchChangeEventData = { readonly value: boolean } declare type SwitchInstance = HostInstance -declare interface SwitchProps - extends Readonly {} +declare interface SwitchProps extends Readonly< + ViewProps & SwitchPropsIOS & SwitchPropsBase +> {} declare type SwitchPropsBase = { disabled?: boolean ios_backgroundColor?: ColorValue @@ -5056,11 +4927,7 @@ declare type TextInputAndroidProps = { readonly cursorColor?: ColorValue readonly disableFullscreenUI?: boolean readonly importantForAutofill?: - | "auto" - | "no" - | "noExcludeDescendants" - | "yes" - | "yesExcludeDescendants" + "auto" | "no" | "noExcludeDescendants" | "yes" | "yesExcludeDescendants" readonly inlineImageLeft?: string readonly inlineImagePadding?: number readonly numberOfLines?: number @@ -5226,31 +5093,19 @@ declare type TextInputFocusEvent = FocusEvent declare type TextInputInstance = _TextInputInstance declare type TextInputIOSProps = { readonly clearButtonMode?: - | "always" - | "never" - | "unless-editing" - | "while-editing" + "always" | "never" | "unless-editing" | "while-editing" readonly clearTextOnFocus?: boolean readonly dataDetectorTypes?: - | (DataDetectorTypesType | undefined) - | ReadonlyArray + (DataDetectorTypesType | undefined) | ReadonlyArray readonly disableKeyboardShortcuts?: boolean readonly enablesReturnKeyAutomatically?: boolean readonly inputAccessoryViewButtonLabel?: string readonly inputAccessoryViewID?: string readonly keyboardAppearance?: "dark" | "default" | "light" readonly lineBreakModeIOS?: - | "char" - | "clip" - | "head" - | "middle" - | "tail" - | "wordWrapping" + "char" | "clip" | "head" | "middle" | "tail" | "wordWrapping" readonly lineBreakStrategyIOS?: - | "hangul-word" - | "none" - | "push-out" - | "standard" + "hangul-word" | "none" | "push-out" | "standard" readonly passwordRules?: PasswordRules readonly rejectResponderTermination?: boolean readonly scrollEnabled?: boolean @@ -5267,13 +5122,12 @@ declare type TextInputKeyPressEventData = Readonly< target?: number } > -declare interface TextInputProps - extends Readonly< - Omit & - TextInputIOSProps & - TextInputAndroidProps & - TextInputBaseProps - > {} +declare interface TextInputProps extends Readonly< + Omit & + TextInputIOSProps & + TextInputAndroidProps & + TextInputBaseProps +> {} declare type TextInputSelectionChangeEvent = NativeSyntheticEvent declare type TextInputSelectionChangeEventData = Readonly< @@ -5309,14 +5163,13 @@ declare type TextPointerEventProps = { readonly onPointerLeave?: (event: PointerEvent) => void readonly onPointerMove?: (event: PointerEvent) => void } -declare interface TextProps - extends Readonly< - TextPointerEventProps & - TextPropsIOS & - TextPropsAndroid & - TextBaseProps & - AccessibilityProps - > {} +declare interface TextProps extends Readonly< + TextPointerEventProps & + TextPropsIOS & + TextPropsAndroid & + TextBaseProps & + AccessibilityProps +> {} declare type TextPropsAndroid = { adjustsFontSizeToFit?: boolean dataDetectorType?: "all" | "email" | "link" | "none" | "phoneNumber" @@ -5383,13 +5236,12 @@ declare type TouchableHighlightBaseProps = { readonly underlayColor?: ColorValue } declare type TouchableHighlightInstance = HostInstance -declare interface TouchableHighlightProps - extends Readonly< - TouchableWithoutFeedbackProps & - AndroidProps & - IOSProps & - TouchableHighlightBaseProps - > {} +declare interface TouchableHighlightProps extends Readonly< + TouchableWithoutFeedbackProps & + AndroidProps & + IOSProps & + TouchableHighlightBaseProps +> {} declare class TouchableNativeFeedback extends React.Component< TouchableNativeFeedbackProps, TouchableNativeFeedbackState @@ -5433,8 +5285,7 @@ declare type TouchableNativeFeedbackProps = Readonly< background?: | { readonly attribute: - | "selectableItemBackground" - | "selectableItemBackgroundBorderless" + "selectableItemBackground" | "selectableItemBackgroundBorderless" readonly rippleRadius: number | undefined readonly type: "ThemeAttrAndroid" } @@ -5490,12 +5341,11 @@ declare type TouchableState = declare function TouchableWithoutFeedback( props: TouchableWithoutFeedbackProps, ): React.ReactNode -declare interface TouchableWithoutFeedbackProps - extends Readonly< - TouchableWithoutFeedbackPropsAndroid & - TouchableWithoutFeedbackPropsIOS & - AccessibilityProps - > { +declare interface TouchableWithoutFeedbackProps extends Readonly< + TouchableWithoutFeedbackPropsAndroid & + TouchableWithoutFeedbackPropsIOS & + AccessibilityProps +> { readonly children?: React.ReactNode readonly delayLongPress?: number readonly delayPressIn?: number @@ -5505,10 +5355,7 @@ declare interface TouchableWithoutFeedbackProps readonly hitSlop?: EdgeInsetsOrSizeProp readonly id?: string readonly importantForAccessibility?: - | "auto" - | "no-hide-descendants" - | "no" - | "yes" + "auto" | "no-hide-descendants" | "no" | "yes" readonly nativeID?: string readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown readonly onBlur?: (event: BlurEvent) => unknown @@ -5703,20 +5550,19 @@ declare type ViewConfig = { readonly validAttributes: AttributeConfiguration } declare type ViewInstance = HostInstance -declare interface ViewProps - extends Readonly< - DirectEventProps & - GestureResponderHandlers & - MouseEventProps & - PointerEventProps & - FocusEventProps & - KeyEventProps & - TouchEventProps & - ViewPropsAndroid & - ViewPropsIOS & - AccessibilityProps & - ViewBaseProps - > {} +declare interface ViewProps extends Readonly< + DirectEventProps & + GestureResponderHandlers & + MouseEventProps & + PointerEventProps & + FocusEventProps & + KeyEventProps & + TouchEventProps & + ViewPropsAndroid & + ViewPropsIOS & + AccessibilityProps & + ViewBaseProps +> {} declare type ViewPropsAndroid = { readonly focusable?: boolean readonly hasTVPreferredFocus?: boolean @@ -5873,20 +5719,19 @@ declare type WindowsPlatform = { declare type WithAnimatedValue = T extends Builtin | Nullable ? T : T extends Primitive - ? - | AnimatedAddition_default - | AnimatedDiffClamp_default - | AnimatedDivision_default - | AnimatedInterpolation_default - | AnimatedInterpolation_default - | AnimatedInterpolation_default - | AnimatedInterpolation_default - | AnimatedModulo_default - | AnimatedMultiplication_default - | AnimatedNode_default - | AnimatedSubtraction_default - | AnimatedValue_default - | T + ? | AnimatedAddition_default + | AnimatedDiffClamp_default + | AnimatedDivision_default + | AnimatedInterpolation_default + | AnimatedInterpolation_default + | AnimatedInterpolation_default + | AnimatedInterpolation_default + | AnimatedModulo_default + | AnimatedMultiplication_default + | AnimatedNode_default + | AnimatedSubtraction_default + | AnimatedValue_default + | T : T extends ReadonlyArray ? ReadonlyArray> : T extends {} diff --git a/packages/react-native/__typetests__/fabric-component-sample.ts b/packages/react-native/__typetests__/fabric-component-sample.ts index 91e8b8137c8c..c2f515c9bc3a 100644 --- a/packages/react-native/__typetests__/fabric-component-sample.ts +++ b/packages/react-native/__typetests__/fabric-component-sample.ts @@ -24,8 +24,7 @@ interface NativeProps extends ViewProps { number?: number | undefined; boolean?: boolean | undefined; default?: - | CodegenTypes.WithDefault<'option1' | 'option2', 'option1'> - | undefined; + CodegenTypes.WithDefault<'option1' | 'option2', 'option1'> | undefined; double?: CodegenTypes.Double | undefined; float?: CodegenTypes.Float | undefined; int32?: CodegenTypes.Int32 | undefined; diff --git a/packages/react-native/metro.config.js b/packages/react-native/metro.config.js index d3a136dd0a36..892b54a2f452 100644 --- a/packages/react-native/metro.config.js +++ b/packages/react-native/metro.config.js @@ -39,7 +39,9 @@ const config = { }, }; -module.exports = mergeConfig( +const mergedConfig /*: ConfigT */ = mergeConfig( getDefaultConfig(__dirname), config, -) /*:: as ConfigT */; +); + +module.exports = mergedConfig; diff --git a/packages/react-native/scripts/codegen/generate-artifacts-executor/index.js b/packages/react-native/scripts/codegen/generate-artifacts-executor/index.js index 780f58c03955..b75b39cc5230 100644 --- a/packages/react-native/scripts/codegen/generate-artifacts-executor/index.js +++ b/packages/react-native/scripts/codegen/generate-artifacts-executor/index.js @@ -225,7 +225,7 @@ function computeBaseOutputPath( ) { return process.env.RCT_SCRIPT_OUTPUT_DIR; } - let baseOutputPath /*: string */; + let baseOutputPath; if (optionalBaseOutputPath == null) { const outputDirFromPkgJson = readOutputDirFromPkgJson(pkgJson, platform); if (outputDirFromPkgJson != null) { diff --git a/packages/react-native/scripts/compose-source-maps.js b/packages/react-native/scripts/compose-source-maps.js index 034b7165af1b..dd1c39999b1f 100755 --- a/packages/react-native/scripts/compose-source-maps.js +++ b/packages/react-native/scripts/compose-source-maps.js @@ -15,7 +15,7 @@ const fs = require('fs'); const {composeSourceMaps} = require('metro-source-map'); const argv = process.argv.slice(2); -let outputPath /*: ?string */; +let outputPath; for (let i = 0; i < argv.length; ) { if (argv[i] === '-o') { outputPath = argv[i + 1]; diff --git a/packages/react-native/scripts/packager-reporter.js b/packages/react-native/scripts/packager-reporter.js index dc4066c967e7..0bda84f1017c 100644 --- a/packages/react-native/scripts/packager-reporter.js +++ b/packages/react-native/scripts/packager-reporter.js @@ -10,7 +10,8 @@ 'use strict'; -let reporter /*: $FlowFixMe */; +// $FlowFixMe[signature-verification-failure] +let reporter; const logPath = process.env.RCT_PACKAGER_LOG_PATH; if (logPath != null && logPath !== '') { diff --git a/packages/react-native/src/types/globals.d.ts b/packages/react-native/src/types/globals.d.ts index 76f45042ee28..98f824c62b84 100644 --- a/packages/react-native/src/types/globals.d.ts +++ b/packages/react-native/src/types/globals.d.ts @@ -322,12 +322,7 @@ declare global { type RequestCredentials_ = 'omit' | 'same-origin' | 'include'; type RequestMode_ = 'navigate' | 'same-origin' | 'no-cors' | 'cors'; type ResponseType_ = - | 'basic' - | 'cors' - | 'default' - | 'error' - | 'opaque' - | 'opaqueredirect'; + 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; // #endregion // #region XMLHttpRequest @@ -429,8 +424,7 @@ declare global { } interface XMLHttpRequestUpload - extends EventTarget, - XMLHttpRequestEventTarget { + extends EventTarget, XMLHttpRequestEventTarget { addEventListener( type: K, listener: ( @@ -453,12 +447,7 @@ declare global { }; type XMLHttpRequestResponseType = - | '' - | 'arraybuffer' - | 'blob' - | 'document' - | 'json' - | 'text'; + '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; interface URL { readonly hash: string; @@ -690,14 +679,11 @@ declare global { onerror: ((this: FileReader, ev: ProgressEvent) => any) | null; onload: ((this: FileReader, ev: ProgressEvent) => any) | null; onloadend: - | ((this: FileReader, ev: ProgressEvent) => any) - | null; + ((this: FileReader, ev: ProgressEvent) => any) | null; onloadstart: - | ((this: FileReader, ev: ProgressEvent) => any) - | null; + ((this: FileReader, ev: ProgressEvent) => any) | null; onprogress: - | ((this: FileReader, ev: ProgressEvent) => any) - | null; + ((this: FileReader, ev: ProgressEvent) => any) | null; readonly readyState: number; readonly result: string | ArrayBuffer; abort(): void; diff --git a/packages/react-native/types/public/ReactNativeRenderer.d.ts b/packages/react-native/types/public/ReactNativeRenderer.d.ts index eb136cbc3996..aa2959a04c99 100644 --- a/packages/react-native/types/public/ReactNativeRenderer.d.ts +++ b/packages/react-native/types/public/ReactNativeRenderer.d.ts @@ -44,15 +44,13 @@ export interface GestureResponderHandlers { * Does this view want to become responder on the start of a touch? */ onStartShouldSetResponder?: - | ((event: GestureResponderEvent) => boolean) - | undefined; + ((event: GestureResponderEvent) => boolean) | undefined; /** * Called for every touch move on the View when it is not the responder: does this view want to "claim" touch responsiveness? */ onMoveShouldSetResponder?: - | ((event: GestureResponderEvent) => boolean) - | undefined; + ((event: GestureResponderEvent) => boolean) | undefined; /** * If the View returns true and attempts to become the responder, one of the following will happen: @@ -92,8 +90,7 @@ export interface GestureResponderHandlers { * Should this view release the responder? Returning true allows release */ onResponderTerminationRequest?: - | ((event: GestureResponderEvent) => boolean) - | undefined; + ((event: GestureResponderEvent) => boolean) | undefined; /** * The responder has been taken from the View. @@ -116,8 +113,7 @@ export interface GestureResponderHandlers { * it should have a onStartShouldSetResponderCapture handler which returns true. */ onStartShouldSetResponderCapture?: - | ((event: GestureResponderEvent) => boolean) - | undefined; + ((event: GestureResponderEvent) => boolean) | undefined; /** * onStartShouldSetResponder and onMoveShouldSetResponder are called with a bubbling pattern, @@ -133,8 +129,7 @@ export interface GestureResponderHandlers { * it should have a onStartShouldSetResponderCapture handler which returns true. */ onMoveShouldSetResponderCapture?: - | ((event: GestureResponderEvent) => boolean) - | undefined; + ((event: GestureResponderEvent) => boolean) | undefined; } /** diff --git a/packages/react-native/types/public/ReactNativeTypes.d.ts b/packages/react-native/types/public/ReactNativeTypes.d.ts index 74d7602d017a..beeb86c2cc47 100644 --- a/packages/react-native/types/public/ReactNativeTypes.d.ts +++ b/packages/react-native/types/public/ReactNativeTypes.d.ts @@ -130,11 +130,10 @@ export type NativeMethodsMixinType = NativeMethods; * @todo This should eventually be defined as an AbstractComponent, but that * should first be introduced in the React typings. */ -export interface HostComponent

- extends Pick< - React.ComponentClass

, - Exclude, 'new'> - > { +export interface HostComponent

extends Pick< + React.ComponentClass

, + Exclude, 'new'> +> { new (props: P, context?: any): React.Component

& HostInstance; } diff --git a/packages/virtualized-lists/Lists/VirtualizedList.d.ts b/packages/virtualized-lists/Lists/VirtualizedList.d.ts index 31a95a3b4ad9..87ef2ba8e082 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.d.ts +++ b/packages/virtualized-lists/Lists/VirtualizedList.d.ts @@ -142,41 +142,34 @@ export class VirtualizedList extends React.Component< * @see https://reactnative.dev/docs/virtualizedlist#props */ -export interface VirtualizedListProps - extends VirtualizedListWithoutRenderItemProps { +export interface VirtualizedListProps< + ItemT, +> extends VirtualizedListWithoutRenderItemProps { renderItem: ListRenderItem | null | undefined; } -export interface VirtualizedListWithoutRenderItemProps - extends ScrollViewProps { +export interface VirtualizedListWithoutRenderItemProps< + ItemT, +> extends ScrollViewProps { /** * Rendered in between each item, but not at the top or bottom */ ItemSeparatorComponent?: - | React.ComponentType - | React.ReactElement - | null - | undefined; + React.ComponentType | React.ReactElement | null | undefined; /** * Rendered when the list is empty. Can be a React Component Class, a render function, or * a rendered element. */ ListEmptyComponent?: - | React.ComponentType - | React.ReactElement - | null - | undefined; + React.ComponentType | React.ReactElement | null | undefined; /** * Rendered at the bottom of all the items. Can be a React Component Class, a render function, or * a rendered element. */ ListFooterComponent?: - | React.ComponentType - | React.ReactElement - | null - | undefined; + React.ComponentType | React.ReactElement | null | undefined; /** * Styling for internal View for ListFooterComponent @@ -188,10 +181,7 @@ export interface VirtualizedListWithoutRenderItemProps * a rendered element. */ ListHeaderComponent?: - | React.ComponentType - | React.ReactElement - | null - | undefined; + React.ComponentType | React.ReactElement | null | undefined; /** * Styling for internal View for ListHeaderComponent @@ -316,9 +306,7 @@ export interface VirtualizedListWithoutRenderItemProps * from the logical start of the list. */ onStartReached?: - | ((info: {distanceFromStart: number}) => void) - | null - | undefined; + ((info: {distanceFromStart: number}) => void) | null | undefined; /** * How far from the start (in units of visible length of the list) the leading edge of the @@ -391,7 +379,5 @@ export interface VirtualizedListWithoutRenderItemProps * notify VirtualizedList of changes within the cell. */ CellRendererComponent?: - | React.ComponentType> - | null - | undefined; + React.ComponentType> | null | undefined; } diff --git a/private/helloworld/.prettierrc.js b/private/helloworld/.prettierrc.js index b31d9b06b2a9..4d779e02f83c 100644 --- a/private/helloworld/.prettierrc.js +++ b/private/helloworld/.prettierrc.js @@ -7,23 +7,17 @@ * @format */ -let plugins = ['prettier-plugin-hermes-parser']; -try { - plugins = require('../../.prettier-plugins.fb.js'); -} catch {} - module.exports = { arrowParens: 'avoid', bracketSameLine: true, bracketSpacing: false, singleQuote: true, trailingComma: 'all', - plugins, overrides: [ { files: ['*.js', '*.js.flow'], options: { - parser: 'hermes', + parser: 'flow', }, }, ], diff --git a/private/helloworld/metro.config.js b/private/helloworld/metro.config.js index a3d6323f8bc0..0bdd4933ebef 100644 --- a/private/helloworld/metro.config.js +++ b/private/helloworld/metro.config.js @@ -43,7 +43,9 @@ const config = { }, }; -module.exports = mergeConfig( +const mergedConfig /*: Readonly */ = mergeConfig( getDefaultConfig(__dirname), config, -) /*:: as Readonly */; +); + +module.exports = mergedConfig; diff --git a/private/react-native-fantom/config/metro.config.js b/private/react-native-fantom/config/metro.config.js index 282eb6e8b9d9..18afbea82c97 100644 --- a/private/react-native-fantom/config/metro.config.js +++ b/private/react-native-fantom/config/metro.config.js @@ -74,4 +74,6 @@ const config /*: InputConfigT */ = { : [], }; -module.exports = mergeConfig(rnTesterConfig, config) /*:: as ConfigT */; +const mergedConfig /*: ConfigT */ = mergeConfig(rnTesterConfig, config); + +module.exports = mergedConfig; diff --git a/yarn.lock b/yarn.lock index a6ff74db2d2d..305197286a82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7570,15 +7570,15 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier-plugin-hermes-parser@0.36.0: - version "0.36.0" - resolved "https://registry.yarnpkg.com/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.36.0.tgz#8839207d3a4290b7afe1bd54fbc5154f963b8cf2" - integrity sha512-CtlJ5l0DC48SN3OWSht5jAEIFU92xwHSe87/Bl005/9TlHyN2aAmPa6T/A2JLiIgRT/lGsCtc6srwEMM3/5k4w== - -prettier@3.6.2: - version "3.6.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" - integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== +prettier-plugin-hermes-parser@0.37.0: + version "0.37.0" + resolved "https://registry.yarnpkg.com/prettier-plugin-hermes-parser/-/prettier-plugin-hermes-parser-0.37.0.tgz#454ec7317a1d26d049b7ed0c06117a0aaaff03a6" + integrity sha512-qVPK/q4Unp9/sGBTiqkg5aPno7tKgHZK7SEvaWFpy8ircHiapeOM9PUPaS0uu2DvNjgPlINoJHGiHtgEEooT3w== + +prettier@3.9.4: + version "3.9.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.4.tgz#a9c477cf1614376bd1f6bbc593d8c0d414bcec87" + integrity sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg== pretty-format@^29.0.0, pretty-format@^29.7.0: version "29.7.0" From 9ee21ddd9b0d6b997709786fda4bd0b67be1ec7d Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 8 Jul 2026 06:28:17 -0700 Subject: [PATCH 015/834] Remove react-native/jest-preset subpath (#57481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57481 It's been two releases since we separated `react-native/jest-preset` in 0.85 and this has been updated in the template. Clean up by removing internal redirect module, public export, and peer dependency. Changelog: [General][Breaking] - `react-native/jest-preset` is removed — all projects must now migrate to `react-native/jest-preset` (package) Reviewed By: rubennorte Differential Revision: D111035221 fbshipit-source-id: d66fed9218c801201c1e965f8163f048934d4200 --- packages/jest-preset/README.md | 6 ------ packages/react-native/jest-preset.js | 26 -------------------------- packages/react-native/package.json | 6 ------ 3 files changed, 38 deletions(-) delete mode 100644 packages/react-native/jest-preset.js diff --git a/packages/jest-preset/README.md b/packages/jest-preset/README.md index de420553b828..c53da96056d0 100644 --- a/packages/jest-preset/README.md +++ b/packages/jest-preset/README.md @@ -36,9 +36,3 @@ module.exports = { ``` You can further customize your Jest configuration by specifying other options. See [Jest's `jest.config.js` documentation](https://jestjs.io/docs/configuration) to learn more. - -### Migration Note - -This Jest preset used to be part of the core `react-native` package and accessible at `react-native/jest-preset.js`. As long as `@react-native/jest-preset` is installed, `react-native/jest-preset.js` will be aliased to this package and continue to work but is deprecated. - -Follow the installation instructions above to migrate to `@react-native/jest-preset` and change `preset: 'react-native'` to `preset: '@react-native/jest-preset` to migrate. diff --git a/packages/react-native/jest-preset.js b/packages/react-native/jest-preset.js deleted file mode 100644 index 5251fcdbd0d2..000000000000 --- a/packages/react-native/jest-preset.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @noflow - * @format - */ - -'use strict'; - -try { - module.exports = require('@react-native/jest-preset'); -} catch (error) { - if (error.code === 'MODULE_NOT_FOUND') { - throw new Error( - `The React Native Jest preset has moved to a separate package. -To migrate, please install "@react-native/jest-preset" and update your -jest.config.js to reference: - preset: '@react-native/jest-preset'`, - ); - } else { - throw error; - } -} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index fe57f01f9109..429675458c65 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -52,7 +52,6 @@ "types": null, "default": "./src/asset-registry.js" }, - "./jest-preset": "./jest-preset.js", "./rn-get-polyfills": "./rn-get-polyfills.js", "./src/fb_internal/*": "./src/fb_internal/*", "./package.json": "./package.json" @@ -69,7 +68,6 @@ "gradle/libs.versions.toml", "index.js", "index.js.flow", - "jest-preset.js", "Libraries", "LICENSE", "React-Core.podspec", @@ -136,16 +134,12 @@ "featureflags": "node ./scripts/featureflags/index.js" }, "peerDependencies": { - "@react-native/jest-preset": "0.87.0-main", "@types/react": "^19.1.1", "react": "^19.2.3" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@react-native/jest-preset": { - "optional": true } }, "dependencies": { From b6a535afee1e099db00e62b904e3611f41227544 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 8 Jul 2026 06:28:17 -0700 Subject: [PATCH 016/834] Remove react-native/rn-get-polyfills subpath (#57482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57482 Another cleanup to our `package.json#exports` map ahead of the Strict API rollout. This has been long-migrated to `react-native/js-polyfills` in Metro configs. https://www.internalfb.com/code/fbsource/[960845236d89]/xplat/js/react-native-github/packages/metro-config/src/index.flow.js?lines=66 Changelog: [General][Breaking] - `react-native/rn-get-polyfills` is removed — please use `react-native/js-polyfills` (package) Reviewed By: rubennorte Differential Revision: D111035710 fbshipit-source-id: d36acd7583f76f78c69a07ba7134ce8145d578f9 --- packages/react-native/package.json | 3 --- packages/react-native/rn-get-polyfills.js | 13 ------------- 2 files changed, 16 deletions(-) delete mode 100644 packages/react-native/rn-get-polyfills.js diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 429675458c65..0d0fb4ae30df 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -52,7 +52,6 @@ "types": null, "default": "./src/asset-registry.js" }, - "./rn-get-polyfills": "./rn-get-polyfills.js", "./src/fb_internal/*": "./src/fb_internal/*", "./package.json": "./package.json" }, @@ -88,7 +87,6 @@ "ReactApple", "ReactCommon", "README.md", - "rn-get-polyfills.js", "scripts/replace-rncore-version.js", "scripts/bundle.js", "scripts/cocoapods", @@ -147,7 +145,6 @@ "@react-native/codegen": "0.87.0-main", "@react-native/community-cli-plugin": "0.87.0-main", "@react-native/gradle-plugin": "0.87.0-main", - "@react-native/js-polyfills": "0.87.0-main", "@react-native/normalize-colors": "0.87.0-main", "@react-native/virtualized-lists": "0.87.0-main", "anser": "^1.4.9", diff --git a/packages/react-native/rn-get-polyfills.js b/packages/react-native/rn-get-polyfills.js deleted file mode 100644 index bf0d0428de37..000000000000 --- a/packages/react-native/rn-get-polyfills.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -'use strict'; - -module.exports = require('@react-native/js-polyfills'); From ba71ce815e5ed64d9f8823d0169f2672fd79d72c Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Wed, 8 Jul 2026 07:47:23 -0700 Subject: [PATCH 017/834] Add changelog for v0.87.0 (#57485) Summary: echo $BASE_VERSION v0.86.0-rc.3 npx rnx-kit/rn-changelog-generator \ --base $BASE_VERSION \ --compare v0.87.0-rc.0 \ --repo . \ --changelog ./CHANGELOG.md \ --token $GITHUB_TOKEN Add Changelog for 0.87.0 ## Changelog: [Internal] - Add Changelog for 0.87.0 Pull Request resolved: https://github.com/react/react-native/pull/57485 Test Plan: N/A Reviewed By: cortinico Differential Revision: D111040140 Pulled By: zeyap fbshipit-source-id: b7b480b1b9aba4fca8c16eaf0143120f6944f181 --- CHANGELOG.md | 322 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eadd5d08af60..c8e2d20121e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,327 @@ # Changelog +## v0.87.0-rc.0 + +### Breaking + +- Remove deprecated `StatusBar` `backgroundColor` / `translucent` / `networkActivityIndicatorVisible` props and `setBackgroundColor` / `setTranslucent` / `setNetworkActivityIndicatorVisible` methods ([6b45e579d6](https://github.com/react/react-native/commit/6b45e579d6c17e2ff43709812810b4a32ec81a40) by [@zoontek](https://github.com/zoontek)) +- Remove deprecated boolean values support for `ScrollView` `keyboardShouldPersistTaps` ([5bf3e38db2](https://github.com/react/react-native/commit/5bf3e38db24cadfcd994c3da1a051c0bea4e7f02) by [@zoontek](https://github.com/zoontek)) +- Set max font scaling in `LogBox` to avoid layout breaking ([4aef2b0126](https://github.com/react/react-native/commit/4aef2b0126c22ab386c970a84d61a9ef2c4b3ddd) by [@pchalupa](https://github.com/pchalupa)) +- Remove RawPropsKey prefix and suffix ([67381e157f](https://github.com/react/react-native/commit/67381e157faadd9f18db1ff8710d8d46c3c1dae5) by [@javache](https://github.com/javache)) +- Deep imports to `'react-native/src/private/...'` have been restricted, and are no longer visible to TypeScript. These subpaths still exist, but do not have type coverage. ([142b6172a9](https://github.com/react/react-native/commit/142b6172a94ea178ed8d0fd9ffb03a64c4dd9676) by [@huntie](https://github.com/huntie)) +- Compile out RuntimeScheduler_Legacy under RCT_REMOVE_LEGACY_ARCH ([d205267a5f](https://github.com/react/react-native/commit/d205267a5fa96b343ef9860e9b2d209e5bc8832c) by [@christophpurrer](https://github.com/christophpurrer)) +- **Strict TypeScript API**: Remove legacy `NativeMethods` and `NativeMethodsMixin` types ([b724611aec](https://github.com/react/react-native/commit/b724611aec382742ee52b8b2619eadc47bc14bc4) by [@huntie](https://github.com/huntie)) +- The `NativeDialogManagerAndroid` export is removed. ([a793d21a7e](https://github.com/react/react-native/commit/a793d21a7ebdea0e5a065190497a67619bdefa89) by [@huntie](https://github.com/huntie)) +- The `react-native/core-cli-utils` package is no longer published. It remains available in the React Native repo as a reference implementation. ([9c3fddf9cd](https://github.com/react/react-native/commit/9c3fddf9cd03788a57ffca0f195bb92b84fab281) by [@huntie](https://github.com/huntie)) +- Remove support for connecting to the standalone `react-devtools` package via WebSocket. Use React Native DevTools instead. ([f1971caa44](https://github.com/react/react-native/commit/f1971caa447a9d8f2f6185e99ceb45d512940e86) by [@huntie](https://github.com/huntie)) +- Require Node.js >= 22.13.0 ([a0d39e7a9c](https://github.com/react/react-native/commit/a0d39e7a9c26446b6fe4af4ab7be00aa1c68ddca) by [@huntie](https://github.com/huntie)) +- Remove Legacy Hermes from C++ code ([f9476256bd](https://github.com/react/react-native/commit/f9476256bd1b5d56f46f79bb31a32e77e8e23fe6) by [@cipolleschi](https://github.com/cipolleschi)) +- Fix return type of `useColorScheme()` hook (now `ColorSchemeName | null`) ([ef6463c25d](https://github.com/react/react-native/commit/ef6463c25d78990386a15bb26319d1d4d224df14) by [@huntie](https://github.com/huntie)) +- `useColorScheme()` no longer returns `'unspecified'` (this was always the case, but is a breaking type change) ([8ac88f481a](https://github.com/react/react-native/commit/8ac88f481a51cec28adfe985382ac7b3648921e5) by [@huntie](https://github.com/huntie)) + +#### Android specific + +- Adopt AGP v9 ([abb071b135](https://github.com/react/react-native/commit/abb071b135200823b6794210b2696418190b2892) by [@hurali97](https://github.com/hurali97)) + +#### iOS specific + +- Remove depracted legacy architecture protocol APIs ([ad9936faf6](https://github.com/react/react-native/commit/ad9936faf666264bb03db8c6e7499c529fff3391) by [@christophpurrer](https://github.com/christophpurrer)) +- Remove unused legacy architecture RCTAppSetupUtils methods ([40b16dca9f](https://github.com/react/react-native/commit/40b16dca9fa2b553350993f2a687cd6fc7365ec5) by [@christophpurrer](https://github.com/christophpurrer)) +- Remove RCTBridge Functions from RCTReactNativeFactory Header ([75eaff4c03](https://github.com/react/react-native/commit/75eaff4c033c93819164808635f2cdca77cf8a3c) by [@christophpurrer](https://github.com/christophpurrer)) +- Remove unused bridge-based RCTTurboModuleManager initializer ([cb74b82309](https://github.com/react/react-native/commit/cb74b82309dd9f8e232b8e8f7ef94326e2de8e35) by [@christophpurrer](https://github.com/christophpurrer)) +- RNTester / RCTReactNativeFactory remove unused new architecture flags and RCTArchConfiguratorProtocol ([2148f15db5](https://github.com/react/react-native/commit/2148f15db5a64658647748403bca319a599e7a5d) by [@christophpurrer](https://github.com/christophpurrer)) +- Remove 6 empty Objective-C stub headers ([23ce90bd3b](https://github.com/react/react-native/commit/23ce90bd3bdfb3b8268466c40bab31bf7a54a8d7) by [@christophpurrer](https://github.com/christophpurrer)) +- Remove legacy architecture code guarded by RCT_REMOVE_LEGACY_ARCH from RN iOS ([86350ab988](https://github.com/react/react-native/commit/86350ab988472025d60e192c54e115caf6756295) by [@christophpurrer](https://github.com/christophpurrer)) +- Removing further legacy arch symbols such as RCTCxxBridge ([c65281581d](https://github.com/react/react-native/commit/c65281581dd5add76a10cc20945aa4790fb5db8c) by [@javache](https://github.com/javache)) + +### Added + +- Introduce `react-native/asset-utils` package (relocates Android path utils for libraries/frameworks) ([41d52189e2](https://github.com/react/react-native/commit/41d52189e2167c7edc12a719150c2796b33c0ba0) by [@huntie](https://github.com/huntie)) +- Add `IntBuffer` and `DoubleBuffer` entry types to MapBuffer for compact homogeneous int/double arrays ([e7cadaf9f3](https://github.com/react/react-native/commit/e7cadaf9f315d8984bdea1c7fb6da4e302952819) by [@javache](https://github.com/javache)) +- Add support for `textAlign: 'start'` and `textAlign: 'end'`. ([ca29d38537](https://github.com/react/react-native/commit/ca29d385372e4b4832f45d708579029c64d50c62) by [@SJvaca30](https://github.com/SJvaca30)) +- Strict TypeScript API: Component and API doc comment coverage has been significantly improved ([701de4ec3d](https://github.com/react/react-native/commit/701de4ec3d2f893c77db67e3df39ea17f6ff1c52) by [@huntie](https://github.com/huntie)) +- Add `selection` to `TextInputChangeEventData` in TypeScript types ([e745c41a6b](https://github.com/react/react-native/commit/e745c41a6b173f51bb4d2fb6df2c48742a05f310) by [@nsbarsukov](https://github.com/nsbarsukov)) +- **Strict TypeScript API**: Export `Animated.Numeric` ([6206b0b01f](https://github.com/react/react-native/commit/6206b0b01f358eb7fd723e6e99b75c0b1f6d6ea1) by [@huntie](https://github.com/huntie)) +- Export `AccessibilityActionInfo`, `ImageResizeMode`, `EdgeInsetsProp`, and `TextInputBlurEvent` types ([ee36c67a27](https://github.com/react/react-native/commit/ee36c67a277598efa50cf208c04c53bca46c41c1) by [@huntie](https://github.com/huntie)) +- Support native driven AnimatedValue interpolation easing ([57ce6bc585](https://github.com/react/react-native/commit/57ce6bc5857d3ad72de755eb612f8cfd197c22ca) by [@zeyap](https://github.com/zeyap)) +- Remove `useNativeDriver` under featureflag animatedForceNativeDriver ([9cf30b6e4b](https://github.com/react/react-native/commit/9cf30b6e4bcab167e7a3de254ba3ada88ed66374) by [@zeyap](https://github.com/zeyap)) +- Implement the `Page.addScriptToEvaluateOnNewDocument` and `Page.removeScriptToEvaluateOnNewDocument` CDP methods in the modern inspector ([bc20ec88eb](https://github.com/react/react-native/commit/bc20ec88ebb901f31d8f554238e97612aa04181b) by [@GijsWeterings](https://github.com/GijsWeterings)) +- Remove `useNativeDriver` under featureflag animatedForceNativeDriver ([cffe14ff57](https://github.com/react/react-native/commit/cffe14ff57ce0e76601e9efde833700d6ba609af) by [@zeyap](https://github.com/zeyap)) +- Add `Promise.try` + `Promise.withResolvers` typescript types ([b99ddaf606](https://github.com/react/react-native/commit/b99ddaf60661a9b308e9028412fee682e4211d8f) by [@retyui](https://github.com/retyui)) +- Gate shared C++ and Android Java/Kotlin legacy view manager interop behind `RCT_REMOVE_LEGACY_COMPONENT_INTEROP` ([9885fe95ef](https://github.com/react/react-native/commit/9885fe95ef1e7fa08008434620a8c97c3c2b61da) by [@christophpurrer](https://github.com/christophpurrer)) +- Text decorations honor `textDecorationStyle` (`solid`, `double`, `dotted`, `dashed`, `wavy`) ([87184c8fba](https://github.com/react/react-native/commit/87184c8fbac1b869a6dd021f1a9c7cf26dfa462c) by [@quantizor](https://github.com/quantizor)) +- Gate legacy view manager interop behind `RCT_REMOVE_LEGACY_COMPONENT_INTEROP` ([8b959b46ea](https://github.com/react/react-native/commit/8b959b46ea6195ef2d0a77433d7915fc02797b11) by [@christophpurrer](https://github.com/christophpurrer)) +- Add CSS Flexbox §4.5 automatic minimum sizing. Opt in by clearing the new `YGErrataMinSizeUndefinedInsteadOfAuto` errata bit on `YGConfig`. ([b766986387](https://github.com/react/react-native/commit/b766986387b0b08c8bddd5dbad4a458eb9404c61) by [@adityasharat](https://github.com/adityasharat)) +- **Strict TypeScript API**: Add `*Instance` ref types for all built-in components ([a3827f32f4](https://github.com/react/react-native/commit/a3827f32f497057c6d23bd184dd8c8fd5b35a523) by [@huntie](https://github.com/huntie)) +- ## Changelog: [GENERAL][ADDED] Gate legacy TurboModule interop behind `RCT_REMOVE_LEGACY_MODULE_INTEROP` ([3278f309e2](https://github.com/react/react-native/commit/3278f309e29fd957dad5f3fa029893013aa4db1c) by [@christophpurrer](https://github.com/christophpurrer)) +- Add CSS Flexbox §4.5 automatic minimum sizing. Opt in by clearing the new `YGErrataMinSizeUndefinedInsteadOfAuto` errata bit on `YGConfig`. Adds `YGNodeSetMinContentWidth/Height` for static contributions and `YGMinContentMeasureFunc` for dynamic ones.' ([7f396d1116](https://github.com/react/react-native/commit/7f396d1116b4d372376439317d8f8d01452bc343) by [@adityasharat](https://github.com/adityasharat)) +- StatusBar: support `barStyle="auto"` to follow the current color scheme ([61c8b040bf](https://github.com/react/react-native/commit/61c8b040bf76bd909965dfb98b2c0bd2aa8e7475) by [@zoontek](https://github.com/zoontek)) +- Add TypeScript support for Error `cause` property ([abcb7821b1](https://github.com/react/react-native/commit/abcb7821b1e43ba5ff0ec92c746e6fbc4b2a10f3) by [@gimi-anders](https://github.com/gimi-anders)) +- Add `Systrace.trace` helper that wraps a function with begin/end events using try/finally ([e6c7a269d2](https://github.com/react/react-native/commit/e6c7a269d2b31aea770470078ae5ae1887326c7c) by [@rubennorte](https://github.com/rubennorte)) +- Add `ArrayBuffer` support to C++ TurboModules ([226ef2e7c5](https://github.com/react/react-native/commit/226ef2e7c5d1928d5696dc23efc1b8950ba00e37) by [@paradowstack](https://github.com/paradowstack)) +- Add All Animated Props example to the rn-tester Animation Backend section ([e2e655385c](https://github.com/react/react-native/commit/e2e655385cfbdb08577e388f47e6f38818dc1f53) by Bartlomiej Bloniarz) +- Add Performance Test example to the rn-tester Animation Backend section ([09e55cb7c6](https://github.com/react/react-native/commit/09e55cb7c631bfa9a346c6e97047e0b5e8e13bb1) by Bartlomiej Bloniarz) +- Add `optimizedAnimatedPropUpdates` feature flag ([ed96b22af2](https://github.com/react/react-native/commit/ed96b22af2baea90b8ed418c2f30c1b0990c1845) by Bartlomiej Bloniarz) + +#### Android specific + +- Added an entry point that allows changing whether the scrollable React Native containers should delay pressed state in children views ([91b2537f8a](https://github.com/react/react-native/commit/91b2537f8ac5b29d022743ab7246d860343c3e81) by [@j-piasecki](https://github.com/j-piasecki)) +- Add a dedicated `MapBufferList` type to `MapBuffer` for ordered lists of nested `MapBuffer`s ([6a957746f6](https://github.com/react/react-native/commit/6a957746f6a2fc280bd6b30724d4fe10b4841841) by [@javache](https://github.com/javache)) +- Request `ACCESS_LOCAL_NETWORK` so the dev server stays reachable on Android 17 (SDK 37)[Android] [Added] - Add `ACCESS_LOCAL_NETWORK` to `PermissionsAndroid` ([2bc2c8d7e6](https://github.com/react/react-native/commit/2bc2c8d7e6cf2ebfd338268cd6b999974fb727e4) by [@alanjhughes](https://github.com/alanjhughes)) +- Update `compileSdk` and `buildTools` to 37 ([3e8dfb1e64](https://github.com/react/react-native/commit/3e8dfb1e64e7c044e3078b6c7ab147a652abe162) by [@alanjhughes](https://github.com/alanjhughes)) +- Add `ReactContext.getRuntimeExecutor()` ([dc9043e940](https://github.com/react/react-native/commit/dc9043e940c0c31a3f906e1d539d02bef54667f7) by [@javache](https://github.com/javache)) +- Use software snapshot capture unless featureflag ([ba204faa75](https://github.com/react/react-native/commit/ba204faa75ab89150396f7ad69e755187ec47a58) by [@zeyap](https://github.com/zeyap)) + +#### iOS specific + +- Add `enableIOSCompressedTextFrameAdjustment` feature flag for `Text` rendering adjustments. ([b933d45276](https://github.com/react/react-native/commit/b933d45276ed82a8c504b6c856b7dd046f95aeb6) by [@sbuggay](https://github.com/sbuggay)) +- Add ArrayBuffer support to ObjC TurboModules ([54b88d9dea](https://github.com/react/react-native/commit/54b88d9dea5ddc9740327ae426dc3ab8e66f7e75) by [@paradowstack](https://github.com/paradowstack)) +- Export `RCTDidInitializeModuleNotificationModuleKey` constant for `RCTDidInitializeModuleNotification` userInfo ([217ee52d4f](https://github.com/react/react-native/commit/217ee52d4f7a13af64a55ab4ff21fc7d70c23dd8) by Eapen George) +- Added comment to clarify why i18nManager.isRTL may not return the expected value ([e4a5ed3c6d](https://github.com/react/react-native/commit/e4a5ed3c6d6ad4f00652ab8202dda65f6fe40b92) by [@scarlac](https://github.com/scarlac)) +- `textDecorationStyle: 'dotted'` and `'dashed'` for `` render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely ([9d037509bf](https://github.com/react/react-native/commit/9d037509bf90acf14bcfb6accd35ed184d91ea84) by [@quantizor](https://github.com/quantizor)) + +### Changed + +- Make the `window` global non-writable and non-configurable, and the `navigator` global non-writable ([347f8d081e](https://github.com/react/react-native/commit/347f8d081eee8792ae23d87ae04be1cfc7b3fdcb) by [@rubennorte](https://github.com/rubennorte)) +- Metro to 0.86.0 ([2b0107e0aa](https://github.com/react/react-native/commit/2b0107e0aaee7d035dbad2cece6134cb2454bb37) by [@robhogan](https://github.com/robhogan)) +- Change default transform profile to 'hermes-stable' ([a2cd37f828](https://github.com/react/react-native/commit/a2cd37f828ad9338eeacd69a64d90c5c512035be) by [@retyui](https://github.com/retyui)) +- **React Native DevTools**: Expose new options in the app menu ([24e370096e](https://github.com/react/react-native/commit/24e370096e4ee402d4b25dd8a7c7a6778bab4a93) by [@huntie](https://github.com/huntie)) +- Replace `abort-controller` with fork version from react-native ([6f3375a140](https://github.com/react/react-native/commit/6f3375a140b10cffe9bed3dd72a017ece97bbbba) by [@retyui](https://github.com/retyui)) +- Fix missing VERSION_NATIVE_FB in commit artifacts ([c6f29a2173](https://github.com/react/react-native/commit/c6f29a21733377799cf8f39ec5351de941a9e58b) by [@javache](https://github.com/javache)) +- **React Native DevTools**: Add macOS 26/27 app icon ([fa371d156d](https://github.com/react/react-native/commit/fa371d156db2d796a18c44624d28c397ef5837a7) by [@huntie](https://github.com/huntie)) +- Enable Animated flush-queue debouncing (`animatedShouldDebounceQueueFlush`) by default ([5c197fb303](https://github.com/react/react-native/commit/5c197fb303ed0d975482757fefb7ed38349601b6) by [@zeyap](https://github.com/zeyap)) +- Make `EventTarget` methods enumerable by spec ([25c25b3c7c](https://github.com/react/react-native/commit/25c25b3c7cc81c33c1c36fb371d3f94d00ff412e) by [@retyui](https://github.com/retyui)) +- Speed up VirtualizedList render-mask creation for large lists by avoiding the old backward sticky-header scan when sticky headers are missing or sparse. ([fe53279889](https://github.com/react/react-native/commit/fe53279889ba1e89c99d5b888d36a4294a8abb8c) by [@tarikfp](https://github.com/tarikfp)) +- Flip cxxNativeAnimatedEnabled featureflag default to true ([fc7dc741d2](https://github.com/react/react-native/commit/fc7dc741d2b9dcdc36660d100a1b4ba420f8b725) by [@zeyap](https://github.com/zeyap)) +- Make `flattenStyle` avoid extra intermediate objects when flattening nested style arrays. ([d26f7b338d](https://github.com/react/react-native/commit/d26f7b338d340042a15706e5bb4febfe11cdd81f) by [@tarikfp](https://github.com/tarikfp)) +- Remove `experimental_` prefix from `backgroundImage` ([58688bf176](https://github.com/react/react-native/commit/58688bf17623fa54e8c62397ecb0abd285c01a94) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway)) +- Mark parent dirty when child is freed ([4adca58f4c](https://github.com/react/react-native/commit/4adca58f4c52fabe28b6ee899d34b1aa05bd2ea3) by [@harsha-cpp](https://github.com/harsha-cpp)) +- Expose `IntersectionObserverEntry` as a global ([4deb32a507](https://github.com/react/react-native/commit/4deb32a507f15b447f264b27fb1d45dd3d3dfc44) by [@rubennorte](https://github.com/rubennorte)) +- The default value of the `useTurboModules` ReactNativeFeatureFlags flag is now `true` ([db4c6ee3b6](https://github.com/react/react-native/commit/db4c6ee3b6898605794aedcd33e6015e2058e997) by [@mdvacca](https://github.com/mdvacca)) +- **Strict TypeScript API**: Additional component props types are now `interface` declarations, enabling module augmentation by libraries like Uniwind (preserve compatibility) ([2574863642](https://github.com/react/react-native/commit/2574863642862d507f74355fad40966a1370bf03) by [@huntie](https://github.com/huntie)) +- **Strict TypeScript API**: Select component props types are now `interface` declarations, enabling module augmentation by libraries like NativeWind and Expo (preserve compatibility) ([db89600b56](https://github.com/react/react-native/commit/db89600b562dda28e9d41028b8b6e63ca94b86a0) by [@huntie](https://github.com/huntie)) +- Clarify Android AppState background API documentation. ([b09fce6db5](https://github.com/react/react-native/commit/b09fce6db5ce7d025546d84b91ecf201a86a494f) by [@federicobartoli](https://github.com/federicobartoli)) +- Simplified build JS Hermes infrastructure for the Release ([d49aac6b65](https://github.com/react/react-native/commit/d49aac6b652d5b19bcd044c77905cac0c917fd85) by [@cipolleschi](https://github.com/cipolleschi)) +- Bump Hermes V1 to 250829098.0.13 ([0175449606](https://github.com/react/react-native/commit/01754496066fb501a450fcb599a9d2929ac37c16) by [@robhogan](https://github.com/robhogan)) + +#### Android specific + +- Set minCompileSdk to 34, libraries will have to specify a compileSdk of >= 34 in order to work with React Native ([29e5f954c7](https://github.com/react/react-native/commit/29e5f954c70a57d2d0feaa052b698fb95915a0a0) by [@cortinico](https://github.com/cortinico)) +- Gradle to 9.4.1 ([c0ee408c16](https://github.com/react/react-native/commit/c0ee408c163cd4e2e27dad7cbcdd59d97cf3d5a5) by [@leotm](https://github.com/leotm)) +- Convert `ReactHorizontalScrollView` from Java to Kotlin ([5c680995bd](https://github.com/react/react-native/commit/5c680995bd416e2bb0f3ef892bd580f5c4527b18) by [@cortinico](https://github.com/cortinico)) +- Convert `ReactScrollView` and `ReactNestedScrollView` from Java to Kotlin ([9d65e6c497](https://github.com/react/react-native/commit/9d65e6c497686ab518618dcfb33758110ed41d6e) by [@cortinico](https://github.com/cortinico)) +- Corrected nullability of `ViewManager#measure` ([63683f091c](https://github.com/react/react-native/commit/63683f091c7a2f01311ce05e73f7d7d9185113ee) by [@javache](https://github.com/javache)) +- Migrate `ReactShadowNode` interface from Java to Kotlin ([f07c7cceee](https://github.com/react/react-native/commit/f07c7cceeed4148b9d1cfca6b1fb35d236ac4e87) by [@cortinico](https://github.com/cortinico)) +- Migrate `UIManagerModule` from Java to Kotlin ([21dfc2a855](https://github.com/react/react-native/commit/21dfc2a85578e32c08dc9ea4a0cd87e7cfc0835e) by [@cortinico](https://github.com/cortinico)) +- Migrate `UIViewOperationQueue` from Java to Kotlin ([7a8344c037](https://github.com/react/react-native/commit/7a8344c037d361aafb3e180086ab96d44ede63c1) by [@cortinico](https://github.com/cortinico)) +- Migrate `BridgeReactContext` from Java to Kotlin ([6720760faf](https://github.com/react/react-native/commit/6720760fafc59cfd5d312fd58d2ee2389a168019) by [@cortinico](https://github.com/cortinico)) +- Migrate `ReactNativeHost` from Java to Kotlin (no behavioral changes) ([7eb11331b6](https://github.com/react/react-native/commit/7eb11331b670b83fa7b7a7ae194313848d72d0e6) by [@cortinico](https://github.com/cortinico)) +- Bumped min Kotlin version to 2.0+ ([ec1e1ae054](https://github.com/react/react-native/commit/ec1e1ae054f598df73b8795040fae920719a4643) by [@cortinico](https://github.com/cortinico)) +- Bump Kotlin to 2.2.0 ([cb04956cde](https://github.com/react/react-native/commit/cb04956cdef16792e38cde443e03ba420c8efb5d) by [@cortinico](https://github.com/cortinico)) + +#### iOS specific + +- `RCTUnsafeExecuteOnMainQueueSync` and bridgeless sync runtime-thread calls now always use the coordinator implementation that pumps UI tasks while waiting for JS, eliminating a class of deadlocks. The previous opt-in flag has been removed. ([eaf770433a](https://github.com/react/react-native/commit/eaf770433a5e6d88e150d5da701460551f0f5603) by [@javache](https://github.com/javache)) +- Native modules listed in `unstableModulesRequiringMainQueueSetup` are now always initialized eagerly on the main queue during React Native init; the previous `enableEagerMainQueueModulesOnIOS` opt-in flag has been removed. ([36bec56b0b](https://github.com/react/react-native/commit/36bec56b0b6b2c385c1add41abcde347b0b34fb0) by [@javache](https://github.com/javache)) +- Drain React-revision merges from a `BeforeWaiting` main run loop observer instead of dispatching each merge as a separate main-queue block ([d80377cd60](https://github.com/react/react-native/commit/d80377cd60df90ed8ebabdf15df67edd0dd1fc8f) by [@j-piasecki](https://github.com/j-piasecki)) + +### Deprecated + +- Deprecate `react-native/assets-registry`. Use `AssetRegistry` from `react-native` and `react-native/asset-utils` instead. ([715eeaad69](https://github.com/react/react-native/commit/715eeaad69ad4b69dc5694fb5fba6dbde922ca06) by [@huntie](https://github.com/huntie)) +- Deprecate `ImageBackground`, use a `View` with an absolutely positioned `Image` instead ([2361189716](https://github.com/react/react-native/commit/2361189716afbcced6ee2cdb0fd860cf23460850) by [@zoontek](https://github.com/zoontek)) +- Fix CQS signal modernize-deprecated-headers in xplat/yoga/yoga [B] [A] ([61db78db0b](https://github.com/react/react-native/commit/61db78db0b938052c0c4d15d3e145739258b2282) by generatedunixname1587093422349604) +- Mark undocumented `UTFSequence` module as deprecated ([549e4e2d8d](https://github.com/react/react-native/commit/549e4e2d8d678cde73caa1ee0fb893c168e06294) by [@retyui](https://github.com/retyui)) +- Mark undocumented `UTFSequence` module as deprecated ([fd49bf2e2c](https://github.com/react/react-native/commit/fd49bf2e2cfa555de36edeb7fe34e4fad9eca946) by [@retyui](https://github.com/retyui)) +- The `NativeMethods` interface is deprecated. Use `HostInstance` instead. ([19f5691e39](https://github.com/react/react-native/commit/19f5691e39bd6d5a8775e77697db9a88418f6a70) by [@huntie](https://github.com/huntie)) +- `Appearance.setColorScheme('unspecified')` is deprecated, use `'auto'` instead. ([91702e50fe](https://github.com/react/react-native/commit/91702e50fe077ae73ab152da18719d6df98cb71f) by [@huntie](https://github.com/huntie)) + +#### Android specific + +- Deprecate `DrawerLayoutAndroid`, use `react-native-drawer-layout` instead ([5c5e6cd8ce](https://github.com/react/react-native/commit/5c5e6cd8ce0bfb9c69ac39a1823872fc8eb8c40e) by [@zoontek](https://github.com/zoontek)) +- DefaultReactActivityDelegate's constructor taking new arch flags are deprecated ([935f8e5b7d](https://github.com/react/react-native/commit/935f8e5b7db7c197ad31d60dee008db22b43df5a) by [@javache](https://github.com/javache)) +- Deprecate `UIBlock` interface and `UIManagerModule.addUIBlock`/`prependUIBlock` methods. Use `UIManagerListener` or View Commands instead. ([29a76918a9](https://github.com/react/react-native/commit/29a76918a988d4120dda5597575bf5be477a7635) by [@cortinico](https://github.com/cortinico)) + +#### iOS specific + + + +### Removed + +- Remove deprecated `Modal` `animated` prop ([fd10410d51](https://github.com/react/react-native/commit/fd10410d512423933ff12f4264e975226aa4b351) by [@zoontek](https://github.com/zoontek)) +- **Strict TypeScript API**: Remove deprecated `PublicScrollViewInstance` and `PublicModalInstance` types. Use `ScrollViewInstance` and `ModalInstance` instead. ([74fdfd1eab](https://github.com/react/react-native/commit/74fdfd1eab0812171fc60800b87ca22b3069e721) by [@huntie](https://github.com/huntie)) +- Removed RCTDefaultCxxLogFunction ([2b6f605b3c](https://github.com/react/react-native/commit/2b6f605b3c237361d030748b39074bde2beed59a) by [@javache](https://github.com/javache)) +- Remove deprecated `InteractionManager` (use `requestIdleCallback` instead) ([ed893f7d98](https://github.com/react/react-native/commit/ed893f7d98053eb47f6ea757b5a917de6c37a58f) by [@rubennorte](https://github.com/rubennorte)) +- Remove unused `enableVirtualViewDebugFeatures` feature flag and the associated `FlingItemOverlay` / `FlingDebugItemOverlay` debug surfaces. ([083fd99ba4](https://github.com/react/react-native/commit/083fd99ba4f603b1cb70231136614a96a3f00c59) by [@javache](https://github.com/javache)) +- Remove the `useTurboModules` feature flag. TurboModules are now always enabled. ([b4a715ca1f](https://github.com/react/react-native/commit/b4a715ca1fe054c835918c971fd5cc930f7e5846) by [@mdvacca](https://github.com/mdvacca)) +- Remove the experimental native View prop transformation feature flag. ([2c2cd9ef54](https://github.com/react/react-native/commit/2c2cd9ef54e119bf6527c584b47e1b789080851f) by [@sammy-SC](https://github.com/sammy-SC)) +- Removed the unused `disableMaintainVisibleContentPosition` feature flag from ReactNativeFeatureFlags ([661ce06e02](https://github.com/react/react-native/commit/661ce06e02c80f48d3dca9a4992afc9ea9c3e063) by generatedunixname1608173377072046) +- Remove unused `useLISAlgorithmInDifferentiator` feature flag and LIS algorithm code from the Differentiator ([9f4513c751](https://github.com/react/react-native/commit/9f4513c751621b01dbb0a4379ce7c1d2d4884eeb) by [@javache](https://github.com/javache)) + +#### Android specific + +- Deprecate `logMarkerBridgeless` and `logTaggedMarkerBridgeless` in favor of `logMarker` and `logTaggedMarker` ([319610d328](https://github.com/react/react-native/commit/319610d3286f4d003e23e51edf2f109e817f41b9) by [@javache](https://github.com/javache)) +- Remove legacy architecture stub `UIImplementation`. This class was already non-functional (all methods were empty stubs). ([cf28e6b73c](https://github.com/react/react-native/commit/cf28e6b73cb8bd4a5b5d03be85a1f9aa24d2623b) by [@cortinico](https://github.com/cortinico)) +- Remove `DisplayMetricsHolder.getWindowDisplayMetrics`, `setWindowDisplayMetrics`, and `getDisplayMetricsWritableMap` ([82536f230f](https://github.com/react/react-native/commit/82536f230fc14d9433fc006224e3b3da24d5838f) by [@zoontek](https://github.com/zoontek)) +- Remove legacy architecture stub `UIImplementation`. This class was already non-functional (all methods were empty stubs). ([0825f21171](https://github.com/react/react-native/commit/0825f2117163364e31c61becb974c7ecb2e789d4) by [@cortinico](https://github.com/cortinico)) +- Remove hermesV1Enabled and simplify the code ([0a5c8c6b4b](https://github.com/react/react-native/commit/0a5c8c6b4bbf90dc9d727f6fe5d0f3d9dd7668f8) by [@cipolleschi](https://github.com/cipolleschi)) +- Remove unused `DevServerHelper.websocketProxyURL` property (legacy remote JS debugger) ([9cb2e63c87](https://github.com/react/react-native/commit/9cb2e63c87f06aa88ffc1c8c070361ccea54905f) by [@huntie](https://github.com/huntie)) + +#### iOS specific + +- Deprecate `TimingModule`; will be removed in a future React Native release ([b0a6386995](https://github.com/react/react-native/commit/b0a63869955d5949ada7289a20b5b6cd29195721) by [@christophpurrer](https://github.com/christophpurrer)) +- Legacy arch removal: RCTCxxMethod and DispatchMessageQueueThread ([3b764745c4](https://github.com/react/react-native/commit/3b764745c43024316915d3b19a2be884ad33b026) by [@javache](https://github.com/javache)) +- Remove the RCT_HERMES_V1_ENABLED from Cocoapods ([940ee8ae0d](https://github.com/react/react-native/commit/940ee8ae0d2a87b01e8e67b11115a1b314806163) by [@cipolleschi](https://github.com/cipolleschi)) + +### Fixed + +- - [General][Breaking] - `react-native/Libraries/Image/AssetRegistry` is removed. Please use the `AssetRegistry` API (apps/library code) and/or the `react-native/asset-registry` entrypoint (Metro/build configs). ([6cfde8f296](https://github.com/react/react-native/commit/6cfde8f2961389e68e29fd64321a9185cdc93fe6) by [@huntie](https://github.com/huntie)) +- **Strict TypeScript API**: Add missing `textAlignVertical` prop on `` ([e04ff69ab3](https://github.com/react/react-native/commit/e04ff69ab37add0661b3e0a66f0c05917e0f2b8b) by [@huntie](https://github.com/huntie)) +- Fixed potential revision drop during merge ([c65845cde2](https://github.com/react/react-native/commit/c65845cde259807eb4892d0e37b80ca65be8c191) by [@j-piasecki](https://github.com/j-piasecki)) +- Preserve doc comments on root API symbols in the generated TypeScript types ([642273788b](https://github.com/react/react-native/commit/642273788b9f6493666240768a582e0ca88c340f) by [@huntie](https://github.com/huntie)) +- Avoid `memcpy(_, nullptr, 0)` UB in `MapBufferBuilder::build` for empty / scalar-only MapBuffers ([34ccf4f331](https://github.com/react/react-native/commit/34ccf4f331343ed21259808cb20c4b210912aff3) by [@javache](https://github.com/javache)) +- Fix a surface-stop race in the C++ Animated shared backend that could permanently leak per-surface animated state ([d9d2502b61](https://github.com/react/react-native/commit/d9d2502b612cba72279b036757bf02e0c7dfa67a) by Bartlomiej Bloniarz) +- Make `AbortSignal.any()` process its input signals in multiple passes to match the DOM specification ([1136e41bd8](https://github.com/react/react-native/commit/1136e41bd8911f0018d410f332b1253367290406) by [@rubennorte](https://github.com/rubennorte)) +- Fix `Blob.slice()` for negative start offsets and inverted ranges ([b29dc966a4](https://github.com/react/react-native/commit/b29dc966a42fe3482afcadd28fa09db3310546d7) by [@durvesh1992](https://github.com/durvesh1992)) +- Set `FileReader` `readyState` to `LOADING` during a read so `abort()` correctly emits `abort`/`loadend` ([c00813c6ee](https://github.com/react/react-native/commit/c00813c6eeee93ee98645747beed2605928f2330) by [@durvesh1992](https://github.com/durvesh1992)) +- Fix a race in the C++ Animated render-callback lifecycle that could leave stale callbacks running on every frame ([e442c496b7](https://github.com/react/react-native/commit/e442c496b7dc6109167c3a365174aa70db4b243e) by Bartlomiej Bloniarz) +- Several view, text, scrollview, and accessibility props that the iterator-setter path silently dropped now propagate correctly through `setProp`: `automaticallyAdjustKeyboardInsets`, `dynamicTypeRamp`, `writingDirection`, `experimental_accessibilityOrder`, `transformOrigin`, and the full `borderCurves` cascaded set. ([15b1f5529a](https://github.com/react/react-native/commit/15b1f5529a45be6a1957de0f85b51c0f43ab91c6) by [@javache](https://github.com/javache)) +- Add eventCount to TextInputKeyPressEventData type ([886faee2d1](https://github.com/react/react-native/commit/886faee2d140338dba1c3788ec3ac5b4f231157b) by [@pchalupa](https://github.com/pchalupa)) +- Include a node's padding and border in its automatic minimum size when it has a measure function ([91886f7c88](https://github.com/react/react-native/commit/91886f7c8847dd91fe5fd1390bdfa462f0e465c7) by [@adityasharat](https://github.com/adityasharat)) +- Removed wrong changelog entry in 0.83.2 ([ef38ba4050](https://github.com/react/react-native/commit/ef38ba40507aca1c50778a3f94a51c4ee6ea191f) by [@chrfalch](https://github.com/chrfalch)) +- Crash: YogaLayoutableShadowNode.cpp: function layout: assertion failed (YGNodeGetOwner(childYogaNode) == &yogaNode_) https://github.com/react/react-native/issues/52349 ([6fa330693f](https://github.com/react/react-native/commit/6fa330693fba313a2fe1121545c1efd558b60983) by [@5ZYSZ3K](https://github.com/5ZYSZ3K)) +- Base64-encode binary (arraybuffer/blob) response bodies in the C++ NetworkingModule so they are not corrupted when delivered to JS ([1127e54d53](https://github.com/react/react-native/commit/1127e54d539a0bc998ecc43104c7573190a87de1) by [@sathoshik](https://github.com/sathoshik)) +- Fix asyncArrayBufferBorrowNativeBackedTest unconditional skip on Hermes ([9d54391814](https://github.com/react/react-native/commit/9d54391814a27032f4bc1a5e90bee147514be846) by [@christophpurrer](https://github.com/christophpurrer)) +- Add missing pointer event handler types (`onPointerOver`, `onPointerOut`, `onGotPointerCapture`, `onLostPointerCapture`, and their `*Capture` variants) to the TypeScript types ([20931fe975](https://github.com/react/react-native/commit/20931fe9755b62f6487d18325e78468e234ea80f) by [@ahmdshrif](https://github.com/ahmdshrif)) +- Fix text measurements being incorrectly reused across pixel density changes ([d53c7b52a6](https://github.com/react/react-native/commit/d53c7b52a6d21d78b822af91e817cd8b7d6ee3c8) by [@jehartzog](https://github.com/jehartzog)) +- Update ImageSource.d.ts see reference URL to reflect current repository structure ([6eb07a813d](https://github.com/react/react-native/commit/6eb07a813d2e37daf690dbef1fa0f0a3ae3b884a) by [@ergenekonyigit](https://github.com/ergenekonyigit)) +- **Strict TypeScript API**: Add missing `pointerEvents` prop to `Text` component ([a2e042f76a](https://github.com/react/react-native/commit/a2e042f76a9aac30359deb0b06ae7f7db9f2be47) by [@huntie](https://github.com/huntie)) +- Sync JS-side `Animated.Value` with the post-animation value before invoking `Animated.timing(...).start({finished})` callbacks so reads from inside the callback (or from React re-renders it triggers) observe the post-animation value rather than the pre-animation value. Gated behind a new JS-only feature flag, `animatedShouldSyncValueBeforeStartCallback`, defaulting to `true` (set to `false` to opt out). ([ee6958a9f4](https://github.com/react/react-native/commit/ee6958a9f44234ab08bc06aad599cdc34fa5b368) by [@fabriziocucci](https://github.com/fabriziocucci)) +- Expose Modal native ref prop in TypeScript declarations ([7cc8c76e83](https://github.com/react/react-native/commit/7cc8c76e838ca3fb9a036ca5af873c48b0762f99) by [@ya-nsh](https://github.com/ya-nsh)) +- Fixed a regression that causes crashes on reload for `ReactCxxPlatform`'s `ReactHost` ([ac81869f05](https://github.com/react/react-native/commit/ac81869f05d74bccc79d78e738619cfc403db767) by [@etodanik](https://github.com/etodanik)) +- Suppress `React.Fragment` style warning when used as a child of `TouchableHighlight` ([d7b5314bd8](https://github.com/react/react-native/commit/d7b5314bd853679f753379d2233035922bbc8977) by [@qflen](https://github.com/qflen)) +- React Native DevTools will no longer briefly flash in the system dock/taskbar when starting Metro ([6aa7e35ec3](https://github.com/react/react-native/commit/6aa7e35ec35b1b08a5354a89a7e9cee2ee9b6ece) by [@huntie](https://github.com/huntie)) +- Parse Image srcSet density descriptors consistently ([e4cf0a1cc3](https://github.com/react/react-native/commit/e4cf0a1cc3d629916ff1331a48fd7eff65474f1b) by [@ya-nsh](https://github.com/ya-nsh)) +- Align TypeScript accessibility role definitions with supported React Native accessibility roles ([75e1f06b66](https://github.com/react/react-native/commit/75e1f06b66d3fb5addaaff1be8c7fba0a6552a8b) by [@ya-nsh](https://github.com/ya-nsh)) +- Fix hover out timeout stored in wrong variable in Pressability ([74b1a4d026](https://github.com/react/react-native/commit/74b1a4d026482e09103d44c5da81fb802202f0f9) by [@w3di](https://github.com/w3di)) +- Fix missing format specifier in renderApplication invariant ([bc1a31fb16](https://github.com/react/react-native/commit/bc1a31fb16f0cf1ca92331499c6f2fa4e347dfbf) by [@w3di](https://github.com/w3di)) +- **Strict TypeScript API**: Update `getNativeScrollRef` return type across ScrollView, FlatList, and SectionList ([535b844680](https://github.com/react/react-native/commit/535b844680fdd05add3262f73c40c5f50f7bf329) by [@huntie](https://github.com/huntie)) +- ## Changelog:[GENERAL] [FIXED] - Change FlatList.getNativeScrollRef return type definition to allow accessing the underlying HostInstance. ([5162816e03](https://github.com/react/react-native/commit/5162816e035f29ee1743cd00a3f93522b5c1abfe) by [@janpe](https://github.com/janpe)) +- Re-enabled VirtualizedList "retains batch render region when an item is appended" tes ([c0bf1549c2](https://github.com/react/react-native/commit/c0bf1549c2bb5dc3a90ee7da293c7cf964ced119) by [@chicio](https://github.com/chicio)) +- Fix missing and incorrect types in `AccessibilityInfo` TypeScript definitions ([4255f9bd89](https://github.com/react/react-native/commit/4255f9bd899a4730298d7ec808def1e062bd81f2) by [@huntie](https://github.com/huntie)) +- Add null check on JNI return value in `JCxxInspectorPackagerConnectionDelegateImpl::connectWebSocket` to prevent null dereference ([fa05b8c88b](https://github.com/react/react-native/commit/fa05b8c88b86f953d1f9b812699e24ea593d8587) by [@shubhamksavita](https://github.com/shubhamksavita)) + +#### Android specific + +- Prevent descenders from being clipped when Android text lineHeight matches fontSize ([bb3c121073](https://github.com/react/react-native/commit/bb3c121073a84cd14553bbee036f325eeb294478) by [@TorinAsakura](https://github.com/TorinAsakura)) +- Fix ClassCastException crash on Android 9 and below when an IME submit action races the unmount of a TextInput ([97aa7ad272](https://github.com/react/react-native/commit/97aa7ad2723fb8267f1737d4f7389dbbb9820517) by [@shahidrogers](https://github.com/shahidrogers)) +- Fix Dev Menu Settings crash in debugOptimized builds ([2fb4f13fbf](https://github.com/react/react-native/commit/2fb4f13fbff992c5bd53ad4571be046d2f30f10e) by [@Phecda](https://github.com/Phecda)) +- Use explicit `ReactInstanceManager.mHasStartedDestroyingLock` instead of using `ReactInstanceManager.mHasStartedDestroying` ([f500f4239c](https://github.com/react/react-native/commit/f500f4239c8d029242223f73179f46e2d3c81abd) by [@Yqwed](https://github.com/Yqwed)) +- Account for Android Bold text font weight adjustment when measuring Text. ([d833014948](https://github.com/react/react-native/commit/d8330149481c9227a6acf05ff8c1a1bea3a5af54) by [@TorinAsakura](https://github.com/TorinAsakura)) +- Fix incorrect color stop positions in gradients when a positioned stop is immediately followed by an unpositioned stop ([cb63782554](https://github.com/react/react-native/commit/cb637825540a14f2bdde13fa753f4935105f4713) by [@nduaarte](https://github.com/nduaarte)) +- Avoid crash in SurfaceMountingManager when sendAccessibilityEvent or setJSResponder is called for a missing or deleted view state. ([8eb4240d36](https://github.com/react/react-native/commit/8eb4240d3661e00920af513ff0e864593c1978a0) by [@ManasGuptaSprinklr](https://github.com/ManasGuptaSprinklr)) +- Prevent `IllegalArgumentException` crash in `statusBarShow`/`statusBarHide` when the window decorView is detached ([a54b04e94b](https://github.com/react/react-native/commit/a54b04e94b174fd4b91256bddd3944ef8dfdc8c5) by [@alanleedev](https://github.com/alanleedev)) +- Fix crash in Scheduler::animationTick when uiManager_ is null. ([e73592ba51](https://github.com/react/react-native/commit/e73592ba51e00b5789dfb3d445c8d19834f9f66e) by [@shubhamksavita](https://github.com/shubhamksavita)) +- Prevent "Mapped property node does not exist" crash in `PropsAnimatedNode.updateView` when a mapped node is removed during an in-flight native animation ([53369ed321](https://github.com/react/react-native/commit/53369ed3216d973b52fd825001a53434137da4a6) by [@shashank-bhatotia](https://github.com/shashank-bhatotia)) +- Correct logical border radius mapping for `borderEndStartRadius` and `borderStartEndRadius`. ([ad2cf4d1d7](https://github.com/react/react-native/commit/ad2cf4d1d795f0bfeac836156b51828c5c97cb4a) by [@nikhilpakhloo](https://github.com/nikhilpakhloo)) +- Fix `TextInput` placeholder staying on multiple lines after `multiline` is toggled from `true` back to `false` ([df6de4f758](https://github.com/react/react-native/commit/df6de4f758308e30a0df8e08ad41fec78219ca6d) by [@Abbondanzo](https://github.com/Abbondanzo)) +- Register pointer capture event handlers in the Android base view config ([74382cacd8](https://github.com/react/react-native/commit/74382cacd870115fd7549dff1cf0ef2e54075769) by [@yaminyassin](https://github.com/yaminyassin)) +- Prevent React Native containers from delaying native touches ([d8f71837f8](https://github.com/react/react-native/commit/d8f71837f8db1b9bdd3be7c7df0d5b5c476e4bac) by [@j-piasecki](https://github.com/j-piasecki)) +- Fix crash in SkewMatrixHelper ([9e3a0df61e](https://github.com/react/react-native/commit/9e3a0df61e22a17b09d1e0865436d5f0da569a7e) by [@javache](https://github.com/javache)) +- Fix text decoration line thickness regression on Android ([9f20cde724](https://github.com/react/react-native/commit/9f20cde7241184447a84c52150dd27a2406b1048) by [@cortinico](https://github.com/cortinico)) +- Fix text decoration color not matching foreground color when `textDecorationColor` is not set ([2da0244c16](https://github.com/react/react-native/commit/2da0244c16310aeff253219f7e9d5001e5e5d00b) by [@cortinico](https://github.com/cortinico)) +- SkewX / skewY transforms now render correctly on Android Q+. ([62903bc3e2](https://github.com/react/react-native/commit/62903bc3e2da9028105c5f0e6090889a700a4fef) by [@qflen](https://github.com/qflen)) +- Fix deadlock between the UI and JS threads when native animations start while a synchronous VirtualView mode-change event is dispatched ([b96b626297](https://github.com/react/react-native/commit/b96b626297f951187ab2146ee0ef80292b6ba5c3) by [@coado](https://github.com/coado)) +- Prevent `IllegalArgumentException` crash in `StatusBarModule` when activity is destroyed before the UI thread runnable executes ([732b848a8e](https://github.com/react/react-native/commit/732b848a8e94480fb448da0c93b21eb49413e186) by [@alanleedev](https://github.com/alanleedev)) +- Fix `Image.getSize()` failing for local drawable resource URIs including VectorDrawables ([aea8785d66](https://github.com/react/react-native/commit/aea8785d661b6269aedb75f472319dc48519563d) by [@Abbondanzo](https://github.com/Abbondanzo)) +- Fix NullPointerException in `ReactProgressBarViewManager.measure()` when invoked with null `localData` or `state` (for example, from the upcoming CSS Flexbox §4.5 auto-min-size probe in Yoga). ([ef683f7010](https://github.com/react/react-native/commit/ef683f7010beaad08b5c3130f8ca5ec497072b9c) by [@adityasharat](https://github.com/adityasharat)) +- Reduced memory usage during JS bundle downloads by eliminating intermediate buffer copies ([57eb56fbf5](https://github.com/react/react-native/commit/57eb56fbf5658170026b82bba04358e58237eb70) by [@DorianMazur](https://github.com/DorianMazur)) +- Preserve secure TextInput password character reveal timing when JS state echoes the same text. ([08731165f5](https://github.com/react/react-native/commit/08731165f583e6dae344ea80ae4f9051aac0ff48) by [@sorinc03](https://github.com/sorinc03)) +- Issue when clearing accessibilityLabelledBy ([63dec777d9](https://github.com/react/react-native/commit/63dec777d9cf0f2a32acbadf4c6ed5aca0b736a4) by [@rozele](https://github.com/rozele)) +- Fix pure C++ turbo modules not working without `includesGeneratedCode: true` ([c789880517](https://github.com/react/react-native/commit/c7898805178dd7881c745185a5915ce2c4cf3c1d) by [@satya164](https://github.com/satya164)) +- Fix commit branching dropping updates when `enableAccumulatedUpdatesInRawPropsAndroid` is not enabled. ([bcadedaba4](https://github.com/react/react-native/commit/bcadedaba45ad71405d7dfd6696534dc62655c3e) by [@j-piasecki](https://github.com/j-piasecki)) +- Show request body preview for FormData and file uploads in DevTools Network tab ([48fe6df3d2](https://github.com/react/react-native/commit/48fe6df3d2b2d72138bda212354443c22c60302f) by [@HarshitMadhav](https://github.com/HarshitMadhav)) +- Catch IllegalArgumentException in ScrollView.onTouchEvent to prevent crashes from a known Android framework multi-touch bug ([d672c96445](https://github.com/react/react-native/commit/d672c96445eff16da06baa95b63c44fd13be47ab) by [@tomekzaw](https://github.com/tomekzaw)) +- Source props in image headers in Android ([eeb17badf6](https://github.com/react/react-native/commit/eeb17badf6d87166000d50de76dea5b19ea73968) by [@humaidk2](https://github.com/humaidk2)) +- Guard missing autolinked JNI directories in generated CMake during native clean/model configuration. ([948835d944](https://github.com/react/react-native/commit/948835d9448ce7e06ef8d3cfbfaf177b396e1ab3) by [@Phecda](https://github.com/Phecda)) +- JSModule method without args are correctly dispatched ([949b8049df](https://github.com/react/react-native/commit/949b8049dfeb4f5cf449284f9d37093641757a1a) by [@javache](https://github.com/javache)) +- Screen reader behavior for accessibilityState expanded ([fafcfcd5f6](https://github.com/react/react-native/commit/fafcfcd5f6eedf96c7fedc9ba6f04f680f2ae01d) by [@rozele](https://github.com/rozele)) + +#### iOS specific + +- Prevent Alert and Modal from rendering in the top-left corner ([859bdb89e5](https://github.com/react/react-native/commit/859bdb89e592f6879c1140eb900ae5baeb352bb9) by [@zoontek](https://github.com/zoontek)) +- Fix a data race in `RCTImageLoader` loader and decoder lazy initialization that could crash with `EXC_BAD_ACCESS` ([7ed17c9d09](https://github.com/react/react-native/commit/7ed17c9d09e7d45bcaca8c0c7c4a495b58dcc69d) by Miklós Fazekas) +- Fix React-RCTAnimatedModuleProvider build by adding the missing Yoga dependency and a missing space between compiler flags ([08ef7b18d2](https://github.com/react/react-native/commit/08ef7b18d270914c59561873532b5d6d06c4d97b) by [@zoontek](https://github.com/zoontek)) +- Add an explicit wake up call to the main loop hen scheduling a React revision merge ([0cdb59f1b4](https://github.com/react/react-native/commit/0cdb59f1b45b6c15ed9e96cc59927a143bcf5d4c) by [@j-piasecki](https://github.com/j-piasecki)) +- Add missing `RCT_REMOVE_LEGACY_COMPONENT_INTEROP` guard to `LegacyViewManagerInteropComponentDescriptor` ([510cc0c5ec](https://github.com/react/react-native/commit/510cc0c5eccc0d921d9fcdeeb4d21ce9d57b6400) by [@christophpurrer](https://github.com/christophpurrer)) +- Always return true in RCTTurboModuleEnabled ([aee4a2586e](https://github.com/react/react-native/commit/aee4a2586ede308a85d2a0c5abe2e941defb86fd) by [@christophpurrer](https://github.com/christophpurrer)) +- Deprecate RCTTurboModuleEnabled() and RCTEnableTurboModule() ([feede1422b](https://github.com/react/react-native/commit/feede1422b3acce4c2de095113466e34d84b3486) by [@christophpurrer](https://github.com/christophpurrer)) +- Allow iOS apps with the dev menu enabled (`RCT_DEV_MENU`) to connect to Metro via "Change Bundle Location" ([94380cb4ad](https://github.com/react/react-native/commit/94380cb4ad18eba4164f44bd9a2c9afcef82db43) by [@fmacinator](https://github.com/fmacinator)) +- Expose the bridgeless performance logger via `RCTBridgeProxy` and post `RCTJavaScriptDidLoadNotification`, so native startup-perf consumers work in bridgeless ([9568de5a2d](https://github.com/react/react-native/commit/9568de5a2dd1277f7cb3051d394475e5cd1130e3) by [@fkgozali](https://github.com/fkgozali)) +- Fix codegen script phase error logging in `script_phases.sh` ([f14207f9bd](https://github.com/react/react-native/commit/f14207f9bd83c42b0841d6157676a0477bc0b3b3) by [@fallintoplace](https://github.com/fallintoplace)) +- Fixed percentage-based border radius ([b4966128af](https://github.com/react/react-native/commit/b4966128af2ecb7370373e7639b36e0b345cbfb1) by [@j-piasecki](https://github.com/j-piasecki)) +- Remove unsafe window manipulation in [RCTLogBoxView](vscode-file://vscode-app/c:/Users/agloryvimalabai/AppData/Local/Programs/Microsoft%20VS%20Code/0958016b2a/resources/app/out/vs/code/electron-browser/workbench/workbench.html) dealloc to prevent crash with SceneDelegate ([4f825d3469](https://github.com/react/react-native/commit/4f825d346924f71d80c98f4b7ba260f55ac06f52) by [@aswinandro](https://github.com/aswinandro)) +- Fix TypeError crash in ResponderTouchHistoryStore when changedTouches is undefined ([2c6cb27f88](https://github.com/react/react-native/commit/2c6cb27f88f418b4fdecaf592484ac14b67dcca2) by generatedunixname1608173377072046) + +### Security + +- Fix security vulnerabilities in `xmldom/xmldom`, `fast-xml-parser`, `yaml`, `fast-uri`, and `addressable` transitive dependencies ([284035b21d](https://github.com/react/react-native/commit/284035b21d94a0b2096b3aa3f39470747d191a57) by [@cortinico](https://github.com/cortinico)) + +#### Android specific + + + +#### iOS specific + + + +### Unknown + +- Release 0.87.0-rc.0 ([2d913c7292](https://github.com/react/react-native/commit/2d913c7292088a7bc4482a6febb2ee4903d6f32f) by [@react-native-bot](https://github.com/react-native-bot)) +- Release 0.87.0-rc.0 ([d67f27978c](https://github.com/react/react-native/commit/d67f27978c2a1b80afc2447449e4ca8375ea6885) by [@react-native-bot](https://github.com/react-native-bot)) +- Release 0.87.0-rc.0 ([f21a3bef02](https://github.com/react/react-native/commit/f21a3bef0241da0b741d2186e8416a3f4eae6efc) by [@react-native-bot](https://github.com/react-native-bot)) +- Xplat/js/react-native-github/packages/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp ([ce057d6554](https://github.com/react/react-native/commit/ce057d6554b5ed44070336fa2353d7d6624eab92) by generatedunixname1563563004708334) +- Daily `arc lint --take KTFMT` ([f9ba09d60e](https://github.com/react/react-native/commit/f9ba09d60ecb6362b695bb30d387a72a447dc790) by generatedunixname1430061942044674) +- Turn on `experimental.instance_t_objkit_fix` across fbsource roots ([71fee907fb](https://github.com/react/react-native/commit/71fee907fb9f75ecba210a9041cde7393c98697b) by [@SamChou19815](https://github.com/SamChou19815)) +- Xplat/js/react-native-github/packages/react-native/ReactCommon/react/renderer/animated/drivers/DecayAnimationDriver.cpp ([69b58f308e](https://github.com/react/react-native/commit/69b58f308e4399c8b56feb9e283be2da8676890a) by generatedunixname1563563004708334) +- Transform readonly in xplatjs ([5fce2de800](https://github.com/react/react-native/commit/5fce2de80028e972951b2544be2082c951febb64) by [@marcoww6](https://github.com/marcoww6)) +- Fix a few readonly 10/n ([09fc0432d1](https://github.com/react/react-native/commit/09fc0432d1eb799c3c1c2ae2ef0dc3acb2944e35) by [@marcoww6](https://github.com/marcoww6)) +- Codemod `in/out` ([9f558a4d6a](https://github.com/react/react-native/commit/9f558a4d6a546f73babacb4de991116e19727a86) by [@marcoww6](https://github.com/marcoww6)) +- Add license header to ProGuard files ([b3c0f3ce30](https://github.com/react/react-native/commit/b3c0f3ce301369c7e7f9ea8f7442940229946d61) by [@helfper](https://github.com/helfper)) +- Fix use-after-free data race in EventEmitter.cpp (T259167206) ([5dea3b5e6c](https://github.com/react/react-native/commit/5dea3b5e6c01743a331105a031ab33e016e64b3a) by generatedunixname1383054420177565) + +#### Android Unknown + +- Translation auto-update for batch 3/64 on master ([9299204523](https://github.com/react/react-native/commit/9299204523bc5c4151c2d8972dd4669f81ecc751) by Intl Scheduler) +- Translation auto-update for batch 3/64 on master ([10ec4ad01a](https://github.com/react/react-native/commit/10ec4ad01a10e2e5bd156eee18b5b56082d975de) by Intl Scheduler) +- Translation auto-update for batch 2/64 on master ([a902d7402c](https://github.com/react/react-native/commit/a902d7402c241b0260121c0e8be72c691387cc1c) by Intl Scheduler) +- Make CustomEventNamesResolver a fun interface for SAM conversion ([df4cadc126](https://github.com/react/react-native/commit/df4cadc126690ff09ea653b2d02874b892e897f2) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaWrap enum to Kotlin ([6e262624fc](https://github.com/react/react-native/commit/6e262624fcb3ccea694f47b9b3474aabdeaed29f) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaUnit enum to Kotlin ([97cf21dd41](https://github.com/react/react-native/commit/97cf21dd41c7c2c111b9839e71d2cfc54239fd39) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaPositionType enum to Kotlin ([d181829b9c](https://github.com/react/react-native/commit/d181829b9ce9fbed9655dbded41e54baae5ef3d7) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaOverflow enum to Kotlin ([14c1d164d5](https://github.com/react/react-native/commit/14c1d164d53de189a97868e0fa3ff8e8e9e66f14) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaNodeType enum to Kotlin ([bb137aa028](https://github.com/react/react-native/commit/bb137aa028990e7f6f8f4b1f82541ba758df8fac) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaMeasureMode enum to Kotlin ([b7801f29c5](https://github.com/react/react-native/commit/b7801f29c50b5c71ab0e516e23d652b30f82c656) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaLogLevel enum to Kotlin ([5d5350b1e4](https://github.com/react/react-native/commit/5d5350b1e48336bc669aa972ebf316df7f9809df) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaJustify enum to Kotlin ([084a77631f](https://github.com/react/react-native/commit/084a77631f432eab1d007c59b91a07aef1cf75c8) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaGutter enum to Kotlin ([26862a0251](https://github.com/react/react-native/commit/26862a0251b2acdfbade84da46753318a62c700d) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaGridTrackType enum to Kotlin ([f1b488f5da](https://github.com/react/react-native/commit/f1b488f5dae2727472bedc9b65d901dbf20f20d5) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaFlexDirection enum to Kotlin ([984621b212](https://github.com/react/react-native/commit/984621b212f803bc1db8abec877f2be6c1c45f54) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaExperimentalFeature enum to Kotlin ([70ebd43a1a](https://github.com/react/react-native/commit/70ebd43a1a6c03e0fdaa7fea4e4d9bd1cc34eb41) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaErrata enum to Kotlin ([d695a5cff4](https://github.com/react/react-native/commit/d695a5cff4a76674d60d835ce518c0e31709cdb1) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaEdge enum to Kotlin ([2be96e8d92](https://github.com/react/react-native/commit/2be96e8d920ba09570931a09909dd18c6714ea83) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaDisplay enum to Kotlin ([9580a54273](https://github.com/react/react-native/commit/9580a54273c8a4f726f88368a7adb34eaf535cae) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaDimension enum to Kotlin ([2d2b9924e3](https://github.com/react/react-native/commit/2d2b9924e3a415f5aae311b71db323bf762a2bb3) by [@cortinico](https://github.com/cortinico)) +- Migrate YogaBoxSizing enum to Kotlin ([fe154568e5](https://github.com/react/react-native/commit/fe154568e55b21e87f4efe999d57cd403dd1b6b4) by [@cortinico](https://github.com/cortinico)) + +#### iOS Unknown + + + +#### Failed to parse + + + + ## v0.86.0 ### Added From c0806193534cf3f264472f59446b7fe8a59da409 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 8 Jul 2026 08:15:01 -0700 Subject: [PATCH 018/834] Fix skipped post-publish jobs in release mode (#57479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: During the 0.87.0-rc.0 release, the npm packages published successfully (`publish_react_native` ✓) but every release-only downstream job was **skipped**: `post_publish`, `generate_changelog`, `bump_podfile_lock`, and `create_draft_release`. As a result the community template was not published, rn-diff-purge was not triggered, npm/Maven verification did not run, the changelog was not generated, `Podfile.lock` was not bumped, and no draft GitHub release was created. ### Root cause These four jobs gate on a bare condition: ```yaml if: needs.determine_mode.outputs.mode == 'release' ``` Because that expression contains no status-check function, GitHub Actions implicitly ANDs a `success()` gate. `success()` evaluates to false when there is a **skipped job in the dependency graph**. In release mode, `build_android` (a dependency of `publish_react_native`) is always skipped, which poisons the implicit `success()` for these downstream jobs — so they skip even after a fully successful publish. `publish_react_native` already works around exactly this with `always()` + explicit `.result == 'success'` checks (see its existing `if:` and comment). This PR applies the same, proven pattern to the four downstream jobs. ### Fix Replace each bare `if:` with `always()` plus explicit result checks so the jobs run whenever the release publish actually succeeds, and only in release mode: ```yaml if: | always() && needs.determine_mode.result == 'success' && needs.publish_react_native.result == 'success' && needs.determine_mode.outputs.mode == 'release' ``` (`create_draft_release` checks `generate_changelog` and `set_hermes_version` instead, matching its `needs`.) ## Changelog: [Internal] - ### Notes - This bug is structural — it affects **every** release, not just rc.0, since `build_android` is always skipped in release mode. - A companion PR backports this fix to `0.87-stable`. Pull Request resolved: https://github.com/react/react-native/pull/57479 Test Plan: - `if` expressions unchanged in intent: run only for a successful release publish, skip for nightly/bumped-packages. - YAML validated locally. - Verify on the next release that `post_publish`, `generate_changelog`, `bump_podfile_lock`, and `create_draft_release` all run after `publish_react_native` succeeds. Reviewed By: cortinico Differential Revision: D111035339 Pulled By: cipolleschi fbshipit-source-id: ce26ef974909af85087dcba263430e5e3ec7b990 --- .github/workflows/publish-npm.yml | 35 +++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 2eb06590225b..fad0e0b3a47d 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -201,7 +201,15 @@ jobs: post_publish: runs-on: ubuntu-latest needs: [determine_mode, publish_react_native] - if: needs.determine_mode.outputs.mode == 'release' + # Use always() so this job still runs in release mode even though some + # upstream jobs (e.g. build_android) are skipped and would otherwise + # poison the implicit success() gate. The explicit result checks below + # handle the real gating: only run for a successful release publish. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.publish_react_native.result == 'success' && + needs.determine_mode.outputs.mode == 'release' env: REACT_NATIVE_BOT_GITHUB_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} steps: @@ -256,19 +264,38 @@ jobs: # ─── Release-only: changelog, podfile bump, draft release ──────── generate_changelog: needs: [determine_mode, publish_react_native] - if: needs.determine_mode.outputs.mode == 'release' + # always() + explicit result checks: run for a successful release publish + # even when skipped upstream jobs would trip the implicit success() gate. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.publish_react_native.result == 'success' && + needs.determine_mode.outputs.mode == 'release' uses: ./.github/workflows/generate-changelog.yml secrets: inherit bump_podfile_lock: needs: [determine_mode, publish_react_native] - if: needs.determine_mode.outputs.mode == 'release' + # always() + explicit result checks: run for a successful release publish + # even when skipped upstream jobs would trip the implicit success() gate. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.publish_react_native.result == 'success' && + needs.determine_mode.outputs.mode == 'release' uses: ./.github/workflows/bump-podfile-lock.yml secrets: inherit create_draft_release: needs: [determine_mode, generate_changelog, set_hermes_version] - if: needs.determine_mode.outputs.mode == 'release' + # always() + explicit result checks: run for a successful release even when + # skipped upstream jobs would trip the implicit success() gate. + if: | + always() && + needs.determine_mode.result == 'success' && + needs.generate_changelog.result == 'success' && + needs.set_hermes_version.result == 'success' && + needs.determine_mode.outputs.mode == 'release' uses: ./.github/workflows/create-draft-release.yml secrets: inherit with: From a69badd4682f06fb164717b78eb8a972cce9df35 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Wed, 8 Jul 2026 08:27:48 -0700 Subject: [PATCH 019/834] Add missing LogBox method declarations to LogBox.d.ts (#57472) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57472 `LogBox.d.ts` declared only `ignoreLogs`, `ignoreAllLogs`, `install`, and `uninstall`, but the runtime `LogBox` object also exposes `isInstalled()` and `clearAllLogs()`. Add these two signatures so TypeScript consumers can call them without casting. Changelog: [General][Added] - Add `isInstalled()` and `clearAllLogs()` to the `LogBox` TypeScript declarations Reviewed By: christophpurrer Differential Revision: D110930249 fbshipit-source-id: a7b739266ebf02297797b47ff6931652a359d1cf --- packages/react-native/Libraries/LogBox/LogBox.d.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/react-native/Libraries/LogBox/LogBox.d.ts b/packages/react-native/Libraries/LogBox/LogBox.d.ts index 527250281537..994bff44dc53 100644 --- a/packages/react-native/Libraries/LogBox/LogBox.d.ts +++ b/packages/react-native/Libraries/LogBox/LogBox.d.ts @@ -22,6 +22,17 @@ export interface LogBoxStatic { install(): void; uninstall(): void; + + /** + * Whether LogBox is currently installed. + */ + isInstalled(): boolean; + + /** + * Clear all logs and dismiss the LogBox surface. Invoked by the Fast Refresh + * pipeline on each applied update. + */ + clearAllLogs(): void; } export const LogBox: LogBoxStatic; From 0df0be24efc16e1608e12c21f8220041b50d9a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Wed, 8 Jul 2026 08:48:48 -0700 Subject: [PATCH 020/834] Fix markdown formatting in react-native-fantom docs README (#57486) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57486 `prettier --list-different` (run via `yarn format-check`) flagged the react-native-fantom `__docs__/README.md` as unformatted. Re-wrap the two affected prose lines to prettier's print width so the docs pass the formatting check. No content changes; formatting only. Changelog: [Internal] ___ Differential Revision: D111041556 fbshipit-source-id: cab15f502e941d908503e0873fe8cba12b48686f --- private/react-native-fantom/__docs__/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/private/react-native-fantom/__docs__/README.md b/private/react-native-fantom/__docs__/README.md index d5997c130e8d..dbfb007c8c1b 100644 --- a/private/react-native-fantom/__docs__/README.md +++ b/private/react-native-fantom/__docs__/README.md @@ -187,12 +187,12 @@ hi underlying native (Fabric/TurboModules) code, so tests stay close to real usage and are more resilient to internal refactors. Observe results through the public surface too — e.g. assert the rendered output - (`root.getRenderedOutput(...)`) or values delivered to public listeners - rather than reading private state. + (`root.getRenderedOutput(...)`) or values delivered to public listeners rather + than reading private state. - When Fantom doesn't support something (a native module, a capability, a way to observe a result, etc.), it's fine to reach into internals to work around - that limitation. Prefer a short comment explaining why the internal access is - necessary. + that limitation. Prefer a short comment explaining why the internal access + is necessary. - Place test files in `__tests__` directories alongside the code being tested. - Benchmark tests use the `-benchmark-itest.js` suffix. - Use `Fantom.runTask()` to render and run synchronous operations; it ensures From d2ac190411877e7a1bc94ffac346c5fd35b65a7c Mon Sep 17 00:00:00 2001 From: Phecda Date: Wed, 8 Jul 2026 08:51:45 -0700 Subject: [PATCH 021/834] Persist Change Bundle Location host setting (#57425) Summary: Persist `Change Bundle Location` updates to the same `debug_http_host` preference used by the Android Dev Settings screen. Previously, `Change Bundle Location` only updated the in-memory `PackagerConnectionSettings` host override. After killing and restarting the app process, the host was read again from `SharedPreferences`, so the change was lost. ## Changelog: [ANDROID] [FIXED] - Persist Change Bundle Location debug server host changes Pull Request resolved: https://github.com/react/react-native/pull/57425 Test Plan: Verified with RNTester `debugOptimized` on Android: 1. Installed and launched RNTester on a physical Android device. 2. Opened the React Native Dev Menu. 3. Changed the bundle location using `Change Bundle Location`. 4. Killed and restarted the app process. 5. Confirmed the updated host is still used after restart. Reviewed By: cortinico Differential Revision: D110990504 Pulled By: Abbondanzo fbshipit-source-id: c5fa7ed011872df7f0f14a8a45a0a7a0e4d8658d --- .../react/packagerconnection/PackagerConnectionSettings.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt index 72fda134ea40..24880b2b59a1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt @@ -21,7 +21,7 @@ public open class PackagerConnectionSettings(private val appContext: Context) { public val packageName: String = appContext.packageName init { - resetDebugServerHost() + _cachedOrOverrideHost = null } public open var debugServerHost: String @@ -50,13 +50,16 @@ public open class PackagerConnectionSettings(private val appContext: Context) { } set(host) { if (host.isEmpty()) { + preferences.edit().remove(PREFS_DEBUG_SERVER_HOST_KEY).apply() _cachedOrOverrideHost = null } else { + preferences.edit().putString(PREFS_DEBUG_SERVER_HOST_KEY, host).apply() _cachedOrOverrideHost = host } } public open fun resetDebugServerHost() { + preferences.edit().remove(PREFS_DEBUG_SERVER_HOST_KEY).apply() _cachedOrOverrideHost = null } From bfa679f0ea5431c92894d236b8e414f2feb70280 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 8 Jul 2026 09:33:30 -0700 Subject: [PATCH 022/834] Expose 'react-native/setup-env' to replace InitializeCore (#57475) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57475 NOTE: Resubmission of D110890810. Replace `'react-native/Libraries/Core/InitializeCore'` with an explicit `'react-native/setup-env'` secondary export. This was previously a special-case path excluded from our ESLint warnings. It is now formalised. **Changes** - Add new `src/setup-env.js` entry point and `package.json` mapping. - Replace references in `packages/metro-config/` and `packages/community-cli-plugin/`. - Update `warn-on-deep-imports` ESLint rule. Changelog: [General][Added] - Add `'react-native/setup-env'` entry point. This replaces the previous side-effectful `'react-native/Libraries/Core/InitializeCore'`. [General][Deprecated] - Deprecate `'react-native/Libraries/Core/InitializeCore'`. Use `'react-native/setup-env'` instead. Reviewed By: rubennorte Differential Revision: D111022546 fbshipit-source-id: c9081997468b55b02c8a89544c7cb2026d625ad3 --- .flowconfig | 1 + jest.config.js | 5 +++ .../src/utils/loadMetroConfig.js | 13 +++--- .../__tests__/no-deep-imports-test.js | 30 ++++++++++++- .../no-deep-imports.js | 44 ++++++++++++++----- packages/eslint-plugin-react-native/utils.js | 7 +++ packages/jest-preset/jest-preset.js | 5 +++ packages/jest-preset/jest/setup.js | 1 + packages/metro-config/src/index.flow.js | 2 +- .../plugin-warn-on-deep-imports-test.js | 14 ------ .../src/plugin-warn-on-deep-imports.js | 17 ++----- .../Libraries/Core/InitializeCore.js | 1 + packages/react-native/package.json | 1 + packages/react-native/src/asset-registry.js | 2 +- packages/react-native/src/setup-env.js | 22 ++++++++++ .../IntegrationTests/IntegrationTestsApp.js | 3 +- 16 files changed, 118 insertions(+), 50 deletions(-) create mode 100644 packages/react-native/src/setup-env.js diff --git a/.flowconfig b/.flowconfig index 4d01ae0c0e40..915f8b340ad8 100644 --- a/.flowconfig +++ b/.flowconfig @@ -56,6 +56,7 @@ experimental.multi_platform.extensions=.android munge_underscores=true module.name_mapper='^react-native$' -> '/packages/react-native/index.js' +module.name_mapper='^react-native/setup-env$' -> '/packages/react-native/src/setup-env.js' module.name_mapper='^react-native/\(.*\)$' -> '/packages/react-native/\1' module.name_mapper='^@react-native/dev-middleware$' -> '/packages/dev-middleware' module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|heif\)$' -> '/packages/react-native/Libraries/Image/RelativeImageStub' diff --git a/jest.config.js b/jest.config.js index 240abcbfeae2..aa6c08fb0ad2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,11 @@ module.exports = { '.*': './jest/preprocessor.js', }, resolver: './packages/jest-preset/jest/resolver.js', + moduleNameMapper: { + // `resolver.js` strips `exports`, so alias this subpath to its `src/` impl. + '^react-native/setup-env$': + '/packages/react-native/src/setup-env.js', + }, setupFiles: ['./packages/jest-preset/jest/local-setup.js'], fakeTimers: { enableGlobally: true, diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index 43d9e4126af7..a41b028fa29a 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -60,16 +60,17 @@ function getCommunityCliDefaultConfig( return { resolver, serializer: { - // We can include multiple copies of InitializeCore here because metro will + // We can include multiple copies of setup-env here because Metro will // only add ones that are already part of the bundle getModulesRunBeforeMainModule: () => [ - require.resolve( - path.join(ctx.reactNativePath, 'Libraries/Core/InitializeCore'), - {paths: [ctx.root]}, - ), + // NOTE: ctx.reactNativePath is an absolute path, therefore we need to + // reference setup-env.js here by exact path specifier. + require.resolve(path.join(ctx.reactNativePath, 'src/setup-env.js'), { + paths: [ctx.root], + }), ...outOfTreePlatforms.map(platform => require.resolve( - `${ctx.platforms[platform].npmPackageName}/Libraries/Core/InitializeCore`, + `${ctx.platforms[platform].npmPackageName}/setup-env`, {paths: [ctx.root]}, ), ), diff --git a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js index d28b33f45835..bf14e917f7bf 100644 --- a/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js +++ b/packages/eslint-plugin-react-native/__tests__/no-deep-imports-test.js @@ -29,10 +29,10 @@ eslintTester.run('../no-deep-imports', rule, { "import Foo from 'react-native-foo';", "import Foo from 'react-native-foo/Foo';", "import Foo from 'react/native/Foo';", - "import 'react-native/Libraries/Core/InitializeCore';", - "require('react-native/Libraries/Core/InitializeCore');", "import Foo from 'react-native/src/fb_internal/Foo'", "require('react-native/src/fb_internal/Foo')", + "import 'react-native/setup-env';", + "require('react-native/setup-env');", ], invalid: [ { @@ -125,5 +125,31 @@ eslintTester.run('../no-deep-imports', rule, { ], output: null, }, + { + code: "import 'react-native/Libraries/Core/InitializeCore';", + errors: [ + { + messageId: 'useReplacementSource', + data: { + importPath: 'react-native/Libraries/Core/InitializeCore', + replacementSource: 'react-native/setup-env', + }, + }, + ], + output: "import 'react-native/setup-env';", + }, + { + code: "require('react-native/Libraries/Core/InitializeCore');", + errors: [ + { + messageId: 'useReplacementSource', + data: { + importPath: 'react-native/Libraries/Core/InitializeCore', + replacementSource: 'react-native/setup-env', + }, + }, + ], + output: "require('react-native/setup-env');", + }, ], }); diff --git a/packages/eslint-plugin-react-native/no-deep-imports.js b/packages/eslint-plugin-react-native/no-deep-imports.js index 6446c70ba6ac..e84a1f24d45b 100644 --- a/packages/eslint-plugin-react-native/no-deep-imports.js +++ b/packages/eslint-plugin-react-native/no-deep-imports.js @@ -21,6 +21,8 @@ module.exports = { messages: { deepImport: "'{{importPath}}' React Native deep imports are deprecated. Please use the top level import instead.", + useReplacementSource: + "'{{importPath}}' is deprecated. Please import '{{replacementSource}}' instead.", }, schema: [], fixable: 'code', @@ -31,12 +33,14 @@ module.exports = { ImportDeclaration(node) { if ( !isDeepReactNativeImport(node.source) || - isInitializeCoreImport(node.source) || isSecondaryEntryPoint(node.source) || isFbInternalImport(node.source) ) { return; } + if (reportReplacementSource(node.source)) { + return; + } if (isDefaultImport(node)) { const reactNativeSource = node.source.value.slice( 'react-native/'.length, @@ -88,13 +92,16 @@ module.exports = { CallExpression(node) { if ( !isDeepRequire(node) || - isInitializeCoreImport(node.arguments[0]) || isSecondaryEntryPoint(node.arguments[0]) || isFbInternalImport(node.arguments[0]) ) { return; } + if (reportReplacementSource(node.arguments[0])) { + return; + } + const parent = node.parent; const importPath = node.arguments[0].value; @@ -123,6 +130,26 @@ module.exports = { }, }; + function reportReplacementSource(source) { + const reactNativeSource = source.value.slice('react-native/'.length); + const mapping = publicAPIMapping[reactNativeSource]; + if (!mapping || !mapping.replacementSource) { + return false; + } + context.report({ + node: source, + messageId: 'useReplacementSource', + data: { + importPath: source.value, + replacementSource: mapping.replacementSource, + }, + fix(fixer) { + return fixer.replaceText(source, `'${mapping.replacementSource}'`); + }, + }); + return true; + } + function getStandardReport(source) { return { node: source, @@ -167,20 +194,15 @@ module.exports = { return parts.length > 1 && parts[0] === 'react-native'; } - function isInitializeCoreImport(source) { - if (source.type !== 'Literal' || typeof source.value !== 'string') { - return false; - } - - return source.value === 'react-native/Libraries/Core/InitializeCore'; - } - function isSecondaryEntryPoint(source) { if (source.type !== 'Literal' || typeof source.value !== 'string') { return false; } - return source.value === 'react-native/asset-registry'; + return ( + source.value === 'react-native/asset-registry' || + source.value === 'react-native/setup-env' + ); } function isFbInternalImport(source) { diff --git a/packages/eslint-plugin-react-native/utils.js b/packages/eslint-plugin-react-native/utils.js index 98d1e90ca0b0..728cc26d6ab3 100644 --- a/packages/eslint-plugin-react-native/utils.js +++ b/packages/eslint-plugin-react-native/utils.js @@ -37,6 +37,13 @@ const publicAPIMapping = { default: 'experimental_LayoutConformance', types: ['LayoutConformanceProps'], }, + 'Libraries/Core/InitializeCore': { + // `InitializeCore` has no public named export; the deep import must be + // swapped for the `react-native/setup-env` entry point entirely. + default: null, + types: null, + replacementSource: 'react-native/setup-env', + }, 'Libraries/Lists/FlatList': { default: 'FlatList', types: ['FlatListProps'], diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index 5632cc7fe5c2..bfe131904a21 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -18,6 +18,11 @@ module.exports = { platforms: ['android', 'ios', 'native'], }, moduleNameMapper: { + // `setup-env` is a secondary entry point exposed via the package's + // `exports`, but `./jest/resolver.js` strips `exports` and the generic + // mapper below resolves subpaths as literal directory paths. Alias it + // explicitly so it resolves to its `src/` implementation. + '^react-native/setup-env$': `${path.dirname(require.resolve('react-native'))}/src/setup-env.js`, '^react-native($|/.*)': `${path.dirname(require.resolve('react-native'))}/$1`, }, resolver: require.resolve('./jest/resolver.js'), diff --git a/packages/jest-preset/jest/setup.js b/packages/jest-preset/jest/setup.js index 29f86698a129..483f14cad8f3 100644 --- a/packages/jest-preset/jest/setup.js +++ b/packages/jest-preset/jest/setup.js @@ -133,6 +133,7 @@ mock( 'm#react-native/Libraries/Core/InitializeCore', 'm#./mocks/InitializeCore', ); +mock('m#react-native/setup-env', 'm#./mocks/InitializeCore'); mock('m#react-native/Libraries/Core/NativeExceptionsManager'); mock('m#react-native/Libraries/Image/Image', 'm#./mocks/Image'); mock( diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index 03a9aed5ace4..dcac214b3978 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -61,7 +61,7 @@ export function getDefaultConfig(projectRoot: string): ConfigT { serializer: { // Note: This option is overridden in cli-plugin-metro (getOverrideConfig) getModulesRunBeforeMainModule: () => [ - require.resolve('react-native/Libraries/Core/InitializeCore'), + require.resolve('react-native/setup-env'), ], getPolyfills: () => require('@react-native/js-polyfills')(), isThirdPartyModule({path: modulePath}: Readonly<{path: string, ...}>) { diff --git a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js index 820fc9c59eae..66f247d7112a 100644 --- a/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js +++ b/packages/react-native-babel-preset/src/__tests__/plugin-warn-on-deep-imports-test.js @@ -82,17 +82,3 @@ test('import from other package', () => { `"import { foo } from 'react-native-foo';"`, ); }); - -test('import react-native/Libraries/Core/InitializeCore', () => { - const code = ` - import 'react-native/Libraries/Core/InitializeCore'; - require('react-native/Libraries/Core/InitializeCore'); - export * from 'react-native/Libraries/Core/InitializeCore'; - `; - - expect(transform(code, [rnDeepImportsWarningPlugin])).toMatchInlineSnapshot(` - "import 'react-native/Libraries/Core/InitializeCore'; - require('react-native/Libraries/Core/InitializeCore'); - export * from 'react-native/Libraries/Core/InitializeCore';" - `); -}); diff --git a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js index 98f317586fa4..5e98ed59ad74 100644 --- a/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js +++ b/packages/react-native-babel-preset/src/plugin-warn-on-deep-imports.js @@ -38,10 +38,6 @@ function isDeepReactNativeImport(source) { return parts.length > 1 && parts[0] === 'react-native'; } -function isInitializeCoreImport(source) { - return source === 'react-native/Libraries/Core/InitializeCore'; -} - function withLocation(node, loc) { if (!node.loc) { return {...node, loc}; @@ -55,7 +51,7 @@ module.exports = ({types: t}) => ({ ImportDeclaration(path, state) { const source = path.node.source.value; - if (isDeepReactNativeImport(source) && !isInitializeCoreImport(source)) { + if (isDeepReactNativeImport(source)) { const loc = path.node.loc; state.import.push({source, loc}); } @@ -71,10 +67,7 @@ module.exports = ({types: t}) => ({ ) { const source = args[0].node.type === 'StringLiteral' ? args[0].node.value : ''; - if ( - isDeepReactNativeImport(source) && - !isInitializeCoreImport(source) - ) { + if (isDeepReactNativeImport(source)) { const loc = path.node.loc; state.require.push({source, loc}); } @@ -83,11 +76,7 @@ module.exports = ({types: t}) => ({ ExportNamedDeclaration(path, state) { const source = path.node.source; - if ( - source && - isDeepReactNativeImport(source.value) && - !isInitializeCoreImport(source) - ) { + if (source && isDeepReactNativeImport(source.value)) { const loc = path.node.loc; state.export.push({source: source.value, loc}); } diff --git a/packages/react-native/Libraries/Core/InitializeCore.js b/packages/react-native/Libraries/Core/InitializeCore.js index c3ba83318376..8846ce4dea50 100644 --- a/packages/react-native/Libraries/Core/InitializeCore.js +++ b/packages/react-native/Libraries/Core/InitializeCore.js @@ -22,6 +22,7 @@ * 1. Require system. * 2. Bridged modules. * + * @deprecated Since 0.87. Use `'react-native/setup-env'` instead. */ 'use strict'; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 0d0fb4ae30df..6a09d814cb59 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -52,6 +52,7 @@ "types": null, "default": "./src/asset-registry.js" }, + "./setup-env": "./src/setup-env.js", "./src/fb_internal/*": "./src/fb_internal/*", "./package.json": "./package.json" }, diff --git a/packages/react-native/src/asset-registry.js b/packages/react-native/src/asset-registry.js index e98f442e7694..6d3a4cdf34b4 100644 --- a/packages/react-native/src/asset-registry.js +++ b/packages/react-native/src/asset-registry.js @@ -11,7 +11,7 @@ 'use strict'; // ---------------------------------------------------------------------------- -// Secondary react-native/asset-registry entry point. +// react-native/asset-registry // // This is an untyped secondary entry point intended to be referenced from // Metro's `transformer.assetRegistryPath` config option. This entry point may diff --git a/packages/react-native/src/setup-env.js b/packages/react-native/src/setup-env.js new file mode 100644 index 000000000000..fbe7f9178b31 --- /dev/null +++ b/packages/react-native/src/setup-env.js @@ -0,0 +1,22 @@ +/** + * 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'; +'use client'; + +// ---------------------------------------------------------------------------- +// react-native/setup-env +// +// Side-effectful module that sets up the core React Native JavaScript +// environment. This includes global timers (`setTimeout` etc), the global +// `console` object, and hooks for printing stack traces with source maps. +// ---------------------------------------------------------------------------- + +require('./private/setup/setUpDefaultReactNativeEnvironment').default(); diff --git a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js index c1f9cf2c760a..1cbc4335348f 100644 --- a/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js +++ b/packages/rn-tester/IntegrationTests/IntegrationTestsApp.js @@ -10,7 +10,8 @@ 'use strict'; -require('react-native/Libraries/Core/InitializeCore'); +require('react-native/setup-env'); + const React = require('react'); const ReactNative = require('react-native'); From 6fbf3062f9085c93bcb35c6a61c76029abbc16a5 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 8 Jul 2026 09:33:30 -0700 Subject: [PATCH 023/834] Remove Touchable (base) from public API (#57476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57476 NOTE: Resubmission of D110488985. **Motivation** The public type for `Touchable` has zero overlap with its runtime value. This is an internal implementation object which is undocumented. https://www.internalfb.com/code/fbsource/[86267213ee84]/xplat/js/react-native-github/packages/react-native/Libraries/Components/Touchable/Touchable.d.ts?lines=18-24 https://www.internalfb.com/code/fbsource/[86267213ee84]/xplat/js/react-native-github/packages/react-native/Libraries/Components/Touchable/Touchable.js?lines=966-986 Replaces D110483989 / https://github.com/react/react-native/pull/57419. **Impact** This is a **breaking change** with few open source consumers. The `onTouchStart` etc prop typings are part of `ViewProps` today, which is a suitable replacement. Across projects I've been testing for the Strict TypeScript API rollout, the only load bearing consumer was Uniwind — where I've sent a PR to address this. - https://github.com/uni-stack/uniwind/pull/592. Changelog: [General][Breaking] - The `Touchable` root export (undocumented) is removed. If you are extending `Touchable` as a type, please use `ViewProps` instead. Reviewed By: rubennorte Differential Revision: D111022549 fbshipit-source-id: bf29ae826d34fb04889b9f56de0f11904fce3f53 --- packages/react-native/ReactNativeApi.d.ts | 51 +------------------ packages/react-native/__typetests__/index.tsx | 17 ------- packages/react-native/index.js | 18 +++++-- packages/react-native/index.js.flow | 2 - 4 files changed, 16 insertions(+), 72 deletions(-) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 214d2651245e..5c0ec3775e04 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<<0ef11b701c83f2414d61f7ec37c78df1>> + * @generated SignedSource<<0d6ccb57cc35f5518f7a83a709927f2f>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -398,16 +398,6 @@ declare const staggerImpl: ( time: number, animations: Array, ) => CompositeAnimation -declare const States: { - ERROR: "ERROR" - NOT_RESPONDER: "NOT_RESPONDER" - RESPONDER_ACTIVE_LONG_PRESS_IN: "RESPONDER_ACTIVE_LONG_PRESS_IN" - RESPONDER_ACTIVE_LONG_PRESS_OUT: "RESPONDER_ACTIVE_LONG_PRESS_OUT" - RESPONDER_ACTIVE_PRESS_IN: "RESPONDER_ACTIVE_PRESS_IN" - RESPONDER_ACTIVE_PRESS_OUT: "RESPONDER_ACTIVE_PRESS_OUT" - RESPONDER_INACTIVE_PRESS_IN: "RESPONDER_INACTIVE_PRESS_IN" - RESPONDER_INACTIVE_PRESS_OUT: "RESPONDER_INACTIVE_PRESS_OUT" -} declare const subtract: typeof $$AnimatedImplementation.subtract declare const subtractImpl: ( a: AnimatedNode_default | number, @@ -449,7 +439,6 @@ declare const ToastAndroid_default: { yOffset: number, ) => void } -declare const Touchable: typeof TouchableImpl_default declare const Touchable_default: ( props: TouchableOpacityProps & { ref?: React.Ref @@ -461,33 +450,6 @@ declare const TouchableHighlight_default: ( ref?: React.Ref }, ) => React.ReactNode -declare const TouchableImpl_default: { - Mixin: typeof TouchableMixinImpl - renderDebugView: ($$PARAM_0$$: { - color: ColorValue - hitSlop?: EdgeInsetsProp - }) => null | React.ReactNode -} -declare const TouchableMixinImpl: { - withoutDefaultFocusAndBlur: {} - componentDidMount: () => void - componentWillUnmount: () => void - touchableGetInitialState: () => { - touchable: { - responderID: GestureResponderEvent["currentTarget"] | undefined - touchState: TouchableState | undefined - } - } - touchableHandleBlur: (e: BlurEvent) => void - touchableHandleFocus: (e: FocusEvent) => void - touchableHandleResponderGrant: (e: GestureResponderEvent) => void - touchableHandleResponderMove: (e: GestureResponderEvent) => void - touchableHandleResponderRelease: (e: GestureResponderEvent) => void - touchableHandleResponderTerminate: (e: GestureResponderEvent) => void - touchableHandleResponderTerminationRequest: () => any - touchableHandleStartShouldSetResponder: () => any - touchableLongPressCancelsPress: () => boolean -} declare const TouchableOpacity: typeof Touchable_default declare const UIManager: typeof UIManager_default declare const UIManager_default: UIManagerJSInterface @@ -5224,7 +5186,6 @@ declare type TimingAnimationConfig = Readonly< } > declare type ToastAndroid = typeof ToastAndroid -declare type Touchable = typeof Touchable declare type TouchableHighlight = typeof TouchableHighlight declare type TouchableHighlightBaseProps = { readonly activeOpacity?: number @@ -5329,15 +5290,6 @@ declare type TouchableOpacityTVProps = { readonly nextFocusRight?: number readonly nextFocusUp?: number } -declare type TouchableState = - | typeof States.ERROR - | typeof States.NOT_RESPONDER - | typeof States.RESPONDER_ACTIVE_LONG_PRESS_IN - | typeof States.RESPONDER_ACTIVE_LONG_PRESS_OUT - | typeof States.RESPONDER_ACTIVE_PRESS_IN - | typeof States.RESPONDER_ACTIVE_PRESS_OUT - | typeof States.RESPONDER_INACTIVE_PRESS_IN - | typeof States.RESPONDER_INACTIVE_PRESS_OUT declare function TouchableWithoutFeedback( props: TouchableWithoutFeedbackProps, ): React.ReactNode @@ -6019,7 +5971,6 @@ export { TextProps, // 58466ea1 TextStyle, // b62b8399 ToastAndroid, // 88a8969a - Touchable, // c15da0a2 TouchableHighlight, // 20ba0199 TouchableHighlightInstance, // b510c0eb TouchableHighlightProps, // 337a9164 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 1df236c3bc71..54ea8b7694ba 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -124,7 +124,6 @@ import { // @ts-ignore SectionListData, ToastAndroid, - Touchable, LayoutAnimation, processColor, experimental_LayoutConformance as LayoutConformance, @@ -487,22 +486,6 @@ class Welcome extends React.Component< export default Welcome; -// TouchableTest -function TouchableTest() { - function basicUsage() { - return Touchable.renderDebugView({ - color: 'mediumspringgreen', - hitSlop: {bottom: 5, top: 5}, - }); - } - - function defaultHitSlop() { - return Touchable.renderDebugView({ - color: 'red', - }); - } -} - export class TouchableHighlightTest extends React.Component { buttonRef = React.createRef>(); diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 201ee115755d..0fb660e9aaf7 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -149,9 +149,6 @@ module.exports = { get TextInput() { return require('./Libraries/Components/TextInput/TextInput').default; }, - get Touchable() { - return require('./Libraries/Components/Touchable/Touchable').default; - }, get TouchableHighlight() { return require('./Libraries/Components/Touchable/TouchableHighlight') .default; @@ -412,6 +409,21 @@ module.exports = { // #endregion } as ReactNativePublicAPI; +// `Touchable` has been removed from the public API types, but remains +// re-exported at runtime here because of a hanging `react-native-svg` call +// site (fbsource). +// TODO(huntie): Remove this re-export once `react-native-svg` is updated. +/* $FlowFixMe[prop-missing] This is intentional: `Touchable` is a value-only + * re-export that is absent from the public API types. */ +/* $FlowFixMe[invalid-export] This is intentional: `Touchable` is a value-only + * re-export that is absent from the public API types. */ +Object.defineProperty(module.exports, 'Touchable', { + configurable: true, + get() { + return require('./Libraries/Components/Touchable/Touchable').default; + }, +}); + if (__DEV__) { /* $FlowFixMe[prop-missing] This is intentional: Flow will error when * attempting to access InteractionManager. */ diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 9869ed8c5907..51e4a54e0552 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -175,8 +175,6 @@ export type { } from './Libraries/Components/TextInput/TextInput'; export {default as TextInput} from './Libraries/Components/TextInput/TextInput'; -export {default as Touchable} from './Libraries/Components/Touchable/Touchable'; - export type { TouchableHighlightInstance, TouchableHighlightProps, From c1652651c09506b8dda0b9515b5f0e5829220f0d Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 8 Jul 2026 09:33:30 -0700 Subject: [PATCH 024/834] Remove src/* exports mapping (#57484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57484 Follows up https://github.com/react/react-native/pull/57277, going further to remove this entry point mapping entirely — D110911864 further supports that these import paths were never used externally (and in the first case, `src/private/` has always been an explicit name). **One caveat** `rn-tester` includes some runtime and Fantom test references to `'react-native/src/private/...'`. - This happens to continue working because we've not enabled `"exports"` resolution under Flow. - These can be classified as repo-local — motivation is simplifying/tightening the root `package.json` for open source. Changelog: [Internal] - (Covered by https://github.com/react/react-native/pull/57277) Reviewed By: rubennorte Differential Revision: D111034634 fbshipit-source-id: 4c80223dcf634201df9c77f8bde96dfb0c6f64f9 --- packages/react-native/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 6a09d814cb59..bf6a02391a3d 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -44,10 +44,6 @@ "default": "./Libraries/*.js" }, "./scripts/*": "./scripts/*", - "./src/*": { - "types": null, - "default": "./src/*.js" - }, "./asset-registry": { "types": null, "default": "./src/asset-registry.js" From 24e4b2cae070a27adb5948355ae4ffdbc52de950 Mon Sep 17 00:00:00 2001 From: Intl Scheduler Date: Wed, 8 Jul 2026 20:04:12 -0700 Subject: [PATCH 025/834] translation auto-update for batch 3/64 on master Summary: Chronos Job Instance ID: 1125908308852000 Sandcastle Job Instance ID: 13510801664355067 Processed xml files: android_res/com/meta/metaai/aistudio/immersivethread/view/strings/res/values/strings.xml android_res/com/instagram/unifiedvideo/strings/res/values/strings.xml android_res/com/instagram/shopping/clips/strings/res/values/strings.xml android_res/com/instagram/fanclub/settings/strings/res/values/strings.xml android_res/com/instagram/fanclub/promovideo/strings/res/values/strings.xml android_res/com/instagram/discovery/ui/strings/res/values/strings.xml android_res/com/instagram/direct/share/strings/res/values/strings.xml android_res/com/instagram/creation/music/strings/res/values/strings.xml android_res/com/instagram/creation/location/strings/res/values/strings.xml android_res/com/instagram/creation/metadata/strings/res/values/strings.xml android_res/com/instagram/wellbeing/timespent/strings/res/values/strings.xml android_res/com/instagram/rtc/strings/res/values/strings.xml android_res/com/instagram/video/videocall/strings/res/values/strings.xml android_res/com/instagram/rtc/rooms/res/values/strings.xml android_res/com/instagram/rtc/areffects/strings/res/values/strings.xml android_res/com/instagram/rtc/cowatch/strings/res/values/strings.xml android_res/com/instagram/genai/res/values/strings.xml android_res/com/instagram/wellbeing/fundraiser/feedcreation/strings/res/values/strings.xml android_res/com/instagram/creation/cta/sellproductrow/strings/res/values/strings.xml android_res/com/instagram/commentprompts/strings/res/values/strings.xml android_res/com/instagram/commentpoll/commentpoll/strings/res/values/strings.xml android_res/com/instagram/clips/viewer/navigationbar/strings/res/values/strings.xml android_res/com/instagram/clips/sharesheet/res/values/strings.xml android_res/com/instagram/people/strings/res/values/strings.xml android_res/com/instagram/clips/sharesheet/common/strings/res/values/strings.xml android_res/com/instagram/clips/remix/strings/res/values/strings.xml android_res/com/instagram/clips/intentawaread/strings/res/values/strings.xml android_res/com/instagram/clips/edit/music/strings/res/values/strings.xml android_res/com/instagram/clips/audio/soundsync/view/player/strings/res/values/strings.xml android_res/com/instagram/direct/messagethread/voicestorysticker/res/values/strings.xml android_res/com/instagram/clips/ui/watermark/strings/res/values/strings.xml android_res/com/instagram/creation/cta/ctaselector/strings/res/values/strings.xml ../xplat/js/react-native-github/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml ../xplat/js/react-native-github/packages/react-native/ReactAndroid/src/main/res/systeminfo/values/strings.xml android_res/com/bloks/foa/cds/bottomsheet/strings/res/values/strings.xml android_res/com/instagram/barcelona/common/strings/res/values/strings.xml android_res/com/instagram/barcelona/feed/post/ui/res/values/strings.xml android_res/com/instagram/creation/photo/edit/photo/tint/strings/res/values/strings.xml android_res/com/instagram/creation/capture/quickcapture/sundial/sam3effects/strings/res/values/strings.xml android_res/com/instagram/creation/capture/quickcapture/commentreply/strings/res/values/strings.xml android_res/com/instagram/basel/workflows/text/strings/res/values/strings.xml android_res/com/instagram/basel/effects/strings/res/values/strings.xml android_res/java/com/meta/vault/strings/res/values/strings.xml android_res/com/meta/foa/products/accountswitcher/src/main/shared/res/values/strings.xml libraries/foa/ui/cds/widgets-litho/navbar/res/values/strings.xml libraries/foa/ui/cds/widgets-litho/brandasset/res/values/strings.xml libraries/foa/ui/cds/widgets-litho/textinput/res/values/strings.xml libraries/foa/ui/cds/widgets-litho/searchfield/res/values/strings.xml android_res/com/meta/foa/products/bidirectional/src/main/strings/res/values/strings.xml android_res/com/instagram/profile/strings/res/values/strings.xml android_res/com/instagram/interestpicker/res/values/strings.xml android_res/com/instagram/igds/megaphone/strings/res/values/strings.xml android_res/com/instagram/editprofilepictureui/strings/res/values/strings.xml android_res/com/instagram/direct/profile/strings/res/values/strings.xml android_res/com/instagram/direct/inbox/ui/strings/res/values/strings.xml android_res/com/instagram/clips/profile/tab/strings/res/values/strings.xml android_res/com/instagram/barcelonaig/appswitcher/strings/res/values/strings.xml android_res/com/instagram/arlink/strings/res/values/strings.xml android_res/com/instagram/archive/api/strings/res/values/strings.xml android_res/com/instagram/presence/strings/res/values/strings.xml android_res/com/instagram/bloks/debug/strings/res/values/strings.xml android_res/com/instagram/bloks/strings/res/values/strings.xml android_res/com/instagram/mainfeed/netego/strings/res/values/strings.xml android_res/com/instagram/release/buildinfo/strings/res/values/strings.xml android_res/com/facebook/ps_apps_downloader/res/values/strings.xml android_res/com/facebook/eventdebuggeroverlay/res/values/strings.xml android_res/com/instagram/whoptions/strings/res/values/strings.xml android_res/com/facebook/browser/lite/res/values/strings.xml android_res/com/facebookpay/expresscheckout/strings/res/values/strings.xml android_res/com/facebook/browser/lite/extensions/autofill/base/strings/res/values/strings.xml libraries/maps/facebook-maps/res/values/strings.xml android_res/com/instagram/maps/strings/res/values/strings.xml android_res/com/instagram/igtv/model/strings/res/values/strings.xml android_res/com/instagram/music/search/config/strings/res/values/strings.xml android_res/com/facebook/messaging/accountlogin/strings/res/values/strings.xml android_res/com/instagram/mainfeed/netego/res/values/strings.xml android_res/com/instagram/guides/strings/res/values/strings.xml android_res/com/instagram/permissions/strings/res/values/strings.xml android_res/com/instagram/sponsored/common/strings/res/values/strings.xml android_res/com/instagram/shopping/cart/strings/res/values/strings.xml android_res/com/fbpay/auth/res/values/strings.xml android_res/com/fbpay/hub/res/values/strings.xml android_res/com/fbpay/hub/form/res/values/strings.xml android_res/com/facebookpay/form/strings/res/values/strings.xml android_res/com/facebookpay/modularpttgeneration/res/values/strings.xml libraries/mft/cardscanner/camera/res/values/strings.xml android_res/com/fbpay/w3c/autofill/strings/res/values/strings.xml android_res/com/facebook/payments/exception/res/values/strings.xml android_res/com/facebookpay/offsite/inappwebviewer/adsdisclosurefooter/res/values/strings.xml android_res/com/facebook/browser/lite/extensions/whatsapp/strings/res/values/strings.xml android_res/com/instagram/inappbrowser/strings/res/values/strings.xml android_res/com/facebook/browser/lite/views/instagram/res/values/strings.xml android_res/com/facebook/browser/lite/chrome/instagram/res/values/strings.xml android_res/com/instagram/leadads/strings/res/values/strings.xml android_res/com/instagram/canvas/strings/res/values/strings.xml android_res/com/meta/compose/material/bottomsheet/strings/res/values/strings.xml android_res/com/facebook/browser/lite/extensions/igincentivepromo/res/values/strings.xml android_res/com/facebook/browser/lite/extensions/igcallextension/strings/res/values/strings.xml android_res/com/facebook/browser/lite/extensions/safebrowsing/views/instagram/res/values/strings.xml android_res/com/facebook/browser/lite/extensions/safebrowsing/e2ee/res/values/strings.xml allow-large-files ignore-conflict-markers opt-out-review drop-conflicts Differential Revision: D111177821 fbshipit-source-id: a4cb2ef27fede37b9caf4daa194640aa952b1e40 --- .../src/main/res/views/uimanager/values-zu/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml index e99ad5cf9483..e588156675d6 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml @@ -4,6 +4,7 @@ Imenyu + Ibha Lokuskrola Ithebhu kuvaliwe From 7d2ef7815d92db9bb04460212794ef5da51de0ac Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Thu, 9 Jul 2026 02:52:03 -0700 Subject: [PATCH 026/834] Fix HelloWorld Jest preset after react-native/jest-preset removal (#57491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: https://github.com/react/react-native/issues/57481 removed the `react-native/jest-preset.js` redirect module (and the `react-native/jest-preset` public export), but the in-repo `private/helloworld` app still referenced `preset: 'react-native'` in its Jest config. This breaks `yarn test` in the `test_ios_helloworld` CI job (which cascade-cancels the rest of the iOS matrix and turns the e2e retry `report` jobs red): ``` ● Validation Error: Module react-native should have "jest-preset.js" or "jest-preset.json" file at the root. ``` This migrates HelloWorld to the standalone `react-native/jest-preset` package — the documented migration path (see `packages/jest-preset/README.md`). ## Changes - `private/helloworld/jest.config.js`: `preset: 'react-native'` → `preset: 'react-native/jest-preset'` - `private/helloworld/package.json`: add `react-native/jest-preset` devDependency (`0.87.0-main`, matching the sibling `react-native/*` packages) ## Changelog: [Internal] [Fixed] - Fix HelloWorld Jest preset after `react-native/jest-preset` removal Pull Request resolved: https://github.com/react/react-native/pull/57491 Test Plan: - `cd private/helloworld && yarn test` resolves the preset and runs (previously failed with the validation error above). - CI `test_ios_helloworld` job. Reviewed By: cortinico Differential Revision: D111215532 Pulled By: cipolleschi fbshipit-source-id: 2d60de619060e384b4a5b662a6f588ea3e368433 --- private/helloworld/jest.config.js | 2 +- private/helloworld/package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/private/helloworld/jest.config.js b/private/helloworld/jest.config.js index 26ef92b73cee..3b10bdf375da 100644 --- a/private/helloworld/jest.config.js +++ b/private/helloworld/jest.config.js @@ -9,5 +9,5 @@ */ module.exports = { - preset: 'react-native', + preset: '@react-native/jest-preset', }; diff --git a/private/helloworld/package.json b/private/helloworld/package.json index 63cdc897f1ee..d75b1f9e56ae 100644 --- a/private/helloworld/package.json +++ b/private/helloworld/package.json @@ -22,6 +22,7 @@ "@react-native/babel-preset": "0.87.0-main", "@react-native/core-cli-utils": "*", "@react-native/eslint-config": "0.87.0-main", + "@react-native/jest-preset": "0.87.0-main", "@react-native/metro-config": "0.87.0-main", "@react-native/typescript-config": "0.87.0-main", "@types/jest": "^29.5.14", From eb987ef5504d85ff862b5a679056157888c56960 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Thu, 9 Jul 2026 04:54:28 -0700 Subject: [PATCH 027/834] Keep react-native/setup-env reachable in the module graph (#57492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: https://github.com/react/react-native/issues/57475 pointed Metro's `getModulesRunBeforeMainModule` at `react-native/setup-env` (`src/setup-env.js`) instead of `InitializeCore`. But Metro's serializer only emits a run-before-main require for modules **already in the bundle graph** — see `getAppendScripts`: ```js const paths = [...options.runBeforeMainModule, entryPoint]; for (const path of paths) { if (modules.some(module => module.path === path)) { /* emit run stmt */ } } ``` Nothing imports `src/setup-env.js` at runtime, so it was silently dropped. The RN environment setup (`setUpDefaultReactNativeEnvironment`: `HMRClient`, timers, batched bridge, `AppRegistry` setup, …) then never ran before the app's main module, and the app **redboxed on launch**: ``` Error: Failed to call into JavaScript module method HMRClient.setup(). Module has not been registered as callable. Registered callable JavaScript modules (n = 1): AppRegistry. ``` This broke the **template E2E tests** on both iOS and Android — the app renders a redbox, so Maestro's `assertVisible: 'Welcome to React Native'` fails (the `test (…)` jobs are green only because the Maestro step runs with `continue-on-error: true`; the real failure surfaces via the retry chain and the `retry_2 / report` job). ## Fix Have the deprecated `InitializeCore` delegate to `'react-native/setup-env'` via a side-effectful `require`. `InitializeCore` is a guaranteed graph entry (renderer → `ReactNativePrivateInitializeCore` → `InitializeCore`), so this pulls `src/setup-env.js` into the graph, and `getModulesRunBeforeMainModule` runs it before the main module again. Behavior is unchanged: `setup-env` and `InitializeCore` both call the idempotent `setUpDefaultReactNativeEnvironment()`. This keeps https://github.com/react/react-native/issues/57475's move to `setup-env` intact rather than reverting it. ## Changelog: [General][Fixed] - Fix app failing to initialize (`HMRClient.setup()` redbox) because the environment setup module was dropped from the bundle Pull Request resolved: https://github.com/react/react-native/pull/57492 Test Plan: - Template E2E (`test_e2e_ios_templateapp` / `test_e2e_android_templateapp`) should pass: app launches and shows "Welcome to React Native" instead of the redbox. cc huntie (https://github.com/react/react-native/issues/57475) Reviewed By: cipolleschi Differential Revision: D111226610 Pulled By: huntie fbshipit-source-id: b44e82a248c1f2333c2ea85001c9f243b54619ad --- packages/react-native/Libraries/Core/InitializeCore.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/react-native/Libraries/Core/InitializeCore.js b/packages/react-native/Libraries/Core/InitializeCore.js index 8846ce4dea50..a53aedfe8991 100644 --- a/packages/react-native/Libraries/Core/InitializeCore.js +++ b/packages/react-native/Libraries/Core/InitializeCore.js @@ -27,4 +27,11 @@ 'use strict'; -require('../../src/private/setup/setUpDefaultReactNativeEnvironment').default(); +// NOTE: This delegates to the `'react-native/setup-env'` entry point (rather +// than calling `setUpDefaultReactNativeEnvironment` directly) so that +// `src/setup-env.js` is pulled into the module graph. Metro's +// `getModulesRunBeforeMainModule` only runs modules that are already part of +// the bundle, and `InitializeCore` is a guaranteed graph entry (via +// `ReactNativePrivateInitializeCore`). This keeps `'react-native/setup-env'` +// reachable so it runs before the main module. +require('../../src/setup-env'); From 918fb15bfe5ff434d036c91be957de62cf8f8449 Mon Sep 17 00:00:00 2001 From: jensdev Date: Thu, 9 Jul 2026 06:11:41 -0700 Subject: [PATCH 028/834] Fix custom font weight rendering heaviest face on iOS Fabric (#57483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: On the New Architecture (Fabric), a custom font referenced by its PostScript / full font name (e.g. `fontFamily: "Foo-Medium"`) combined with an explicit non-regular `fontWeight` renders at the heaviest available face (bold/black) instead of the requested weight. This is an iOS-only regression introduced in 0.83; 0.82 was correct. Android is unaffected. **Root cause.** In `RCTFontUtils.mm`, `RCTFontWithFontProperties()` handles the case where the given `fontFamily` is actually a font name rather than a family name (`fontNames.count == 0`) and resolves the effective weight with: ```objc fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font); ``` `fontWeight` is a `UIFontWeight` (a `double`: Regular = 0.0, Medium = 0.23, Bold = 0.4, Black = 0.62). The Objective-C "Elvis" operator `A ?: B` evaluates to **`A` itself** when `A` is truthy — and here `A` is the *comparison* `(fontWeight != 0.0)`, a `BOOL`. So whenever a weight was set (e.g. 0.23), `fontWeight` was reassigned to `1.0` (heavier than Black), and the subsequent "closest weight in the family" search always picked the heaviest face. This was introduced by the automated implicit-bool-conversion sweep in https://github.com/react/react-native/issues/53591 (D81571883), which rewrote the original correct line `fontWeight ?: RCTGetFontWeight(font)` into `(fontWeight != 0.0) ?: …`. Making the truthiness check explicit is fine as a *condition*, but with `?:` the left-hand side is also the *returned value*, so the numeric weight got replaced by the boolean. **Fix.** ```objc fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font); ``` For a `double`, `(A != 0.0) ? A : B` is exactly equivalent to the original `A ?: B`, so this restores the 0.81/0.82 behavior while keeping the explicit `!= 0.0` form used elsewhere in the file. The no-weight case is unchanged: `RCTResolveFontProperties` fills an unspecified weight with `UIFontWeightRegular` (0.0), so the weight is still inferred from the font name via `RCTGetFontWeight(font)` in that case. The legacy (Paper) path in `React/Views/RCTFont.mm` uses a different construct and is not affected — consistent with his only reproducing on the New Architecture. ## Changelog: [iOS] [Fixed] - Custom fonts with an explicit fontWeight no longer render at the heaviest weight on the New Architecture Pull Request resolved: https://github.com/react/react-native/pull/57483 Test Plan: Fixes https://github.com/react/react-native/issues/54934. New Architecture, iOS, with a custom font bundled and referenced by its PostScript name, e.g. ``: | Version | Result | | --- | --- | | 0.82 | renders at the requested medium weight ✅ | | 0.83 / `main` (before fix) | renders bold/black ❌ | | removing `fontWeight` on 0.83 | renders correctly ✅ (confirms the weight branch is the culprit) | After this change the text renders at the requested weight on both the old and new architecture. Reviewed By: cortinico Differential Revision: D111217210 Pulled By: javache fbshipit-source-id: 76858e1b83f8f1032cb599aec6c4b0d49a6a7427 --- .../ios/react/renderer/textlayoutmanager/RCTFontUtils.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm index 9b04cadc22f5..b2b27626edc4 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm @@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize]; if (font != nullptr) { fontNames = [UIFont fontNamesForFamilyName:font.familyName]; - fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font); + fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font); } else { // Failback to system font. font = RCTDefaultFontWithFontProperties(fontProperties); From d70113ff6e57012970dce52421ee6fa8d049641b Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 9 Jul 2026 08:51:58 -0700 Subject: [PATCH 029/834] Flow: Fix `@babel/types` libdef for `getBindingIdentifiers` (#57488) Summary: X-link: https://github.com/react/metro/pull/1770 Pull Request resolved: https://github.com/react/react-native/pull/57488 The generated Flow libdef for `babel/types` declared `duplicates` as a required parameter on `getBindingIdentifiers` and `getOuterBindingIdentifiers`. It is actually optional, both at runtime (only read as a truthy flag, defaulting to the non-duplicate map) and in the upstream TS types: https://github.com/babel/babel/blob/7.x/packages/babel-types/src/retrievers/getBindingIdentifiers.ts#L17-L36 This prevented legitimate single-argument use later in this stack. Separating it for export to RN + Metro. Changelog: [Internal] Reviewed By: GijsWeterings Differential Revision: D111056985 fbshipit-source-id: f3f07617e2fb010b08e83d7d012800a884f5bd15 --- flow-typed/npm/babel-types_v7.x.x.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flow-typed/npm/babel-types_v7.x.x.js b/flow-typed/npm/babel-types_v7.x.x.js index 00aaa15964bf..c6b6cd7e64e3 100644 --- a/flow-typed/npm/babel-types_v7.x.x.js +++ b/flow-typed/npm/babel-types_v7.x.x.js @@ -3848,8 +3848,8 @@ declare module "@babel/types" { declare export function prependToMemberExpression(member: BabelNodeMemberExpression, prepend: BabelNodeExpression): BabelNodeMemberExpression declare export function removeProperties(n: T, opts: ?{}): void; declare export function removePropertiesDeep(n: T, opts: ?{}): T; - declare export function getBindingIdentifiers(node: BabelNode, duplicates: boolean, outerOnly?: boolean): { [key: string]: BabelNodeIdentifier | Array } - declare export function getOuterBindingIdentifiers(node: Node, duplicates: boolean): { [key: string]: BabelNodeIdentifier | Array } + declare export function getBindingIdentifiers(node: BabelNode, duplicates?: boolean, outerOnly?: boolean): { [key: string]: BabelNodeIdentifier | Array } + declare export function getOuterBindingIdentifiers(node: Node, duplicates?: boolean): { [key: string]: BabelNodeIdentifier | Array } declare export type TraversalAncestors = Array<{ node: BabelNode, key: string, From 5976273f1c0a7fc968528fdaeb895b289a5a06db Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Thu, 9 Jul 2026 09:33:00 -0700 Subject: [PATCH 030/834] Move shadow-node ref update thread tag to Fabric install (#57497) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57497 The runtime shadow-node reference update thread tag was being set on every `RuntimeExecutor` tick in `ReactInstance`. Because the underlying flag is `thread_local` and only meaningful on the JS runtime thread, it's enough to set it once during Fabric installation. Move the `ShadowNode::setUseRuntimeShadowNodeReferenceUpdateOnThread` call into `UIManagerBinding::createAndInstallIfNeeded`, which runs on the JS thread and is guarded to execute once per runtime. This lets the `runtime` module drop its direct dependency on `renderer/core`, reducing coupling between the Bridgeless runtime host and the Fabric renderer. Behavior is unchanged: the write was already unconditional; the read at `updateMountedFlag` is still gated by the existing feature flag. Changelog: [Internal] Reviewed By: lenaic, zeyap Differential Revision: D111059967 fbshipit-source-id: e38a16f75ba972c214cd92637caf0441ee6a734e --- .../react/renderer/uimanager/UIManagerBinding.cpp | 7 +++++++ .../ReactCommon/react/runtime/ReactInstance.cpp | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index c79fc75df5b4..ed1c62d97394 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +36,12 @@ void UIManagerBinding::createAndInstallIfNeeded( auto object = jsi::Object::createFromHostObject(runtime, uiManagerBinding); runtime.global().setProperty( runtime, uiManagerModuleName, std::move(object)); + + // Tag this thread (the JS runtime thread) as the one allowed to propagate + // runtime shadow-node reference updates during Fabric commit. The flag is + // thread_local, so it must be set on the JS thread; installing the binding + // is guaranteed to run there and to happen before any JS-driven commit. + ShadowNode::setUseRuntimeShadowNodeReferenceUpdateOnThread(true); } } diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp index 4d12c90edfb3..e6b8f129d5f4 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp @@ -18,7 +18,6 @@ #include #include #include -#include #include #include #include @@ -98,7 +97,6 @@ ReactInstance::ReactInstance( jsi::Runtime& jsiRuntime = runtime->getRuntime(); TraceSection s("ReactInstance::_runtimeExecutor[Callback]"); try { - ShadowNode::setUseRuntimeShadowNodeReferenceUpdateOnThread(true); callback(jsiRuntime); } catch (jsi::JSError& originalError) { jsErrorHandler->handleError(jsiRuntime, originalError, true); From 0a60ec9deab31bcefa7ca9947276222f078b0c36 Mon Sep 17 00:00:00 2001 From: MayankSharma-2812 Date: Thu, 9 Jul 2026 16:01:37 -0700 Subject: [PATCH 031/834] fix: decimal values in transformOrigin string parser (#57487) Summary: Fixes incorrect parsing of decimal percentage and pixel values in the string form of `transformOrigin`. The current tokenizer only matches integer numeric values followed by `%` or `px`. As a result, decimal pixel values are silently corrupted. For example: ```js processTransformOrigin('12.5px 7.5px'); ``` currently returns: ```js [5, 5, 0] ``` instead of: ```js [12.5, 7.5, 0] ``` Decimal percentage values are also incorrectly tokenized. For example, `50.5% 30.2%` is split into partial matches instead of preserving the decimal values. This updates the numeric token pattern from `\d+(?:%|px)` to `\d*\.?\d+(?:%|px)`, supporting decimal values such as `12.5px`, `50.5%`, and leading-dot decimal values such as `.5%`. Regression coverage is added for decimal percentages, decimal pixels, and leading-dot decimal values. ## Changelog: [GENERAL] [FIXED] - Fix `transformOrigin` string parsing for decimal percentage and pixel values. Pull Request resolved: https://github.com/react/react-native/pull/57487 Test Plan: Added regression coverage in `processTransformOrigin-itest.js` for: * decimal percentages: `50.5% 30.2%` * decimal pixels: `12.5px 7.5px` * leading-dot decimals: `.5% .5px` A focused local execution against `processTransformOrigin` confirmed: ```text Before fix: "12.5px 7.5px" -> [5, 5, 0] After fix: "12.5px 7.5px" -> [12.5, 7.5, 0] ``` Also attempted the targeted Fantom integration test with: ```text node node_modules/jest/bin/jest.js --config private/react-native-fantom/config/jest.config.js packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-itest.js ``` The Fantom test could not complete locally because Metro failed to resolve a generated runtime setup path containing Windows path separators. Reviewed By: lenaic Differential Revision: D111240577 Pulled By: javache fbshipit-source-id: 425aeb598d1a454d9245f747441bd8040acf3288 --- .../__tests__/processTransformOrigin-itest.js | 9 ++++++ .../StyleSheet/processTransformOrigin.js | 3 +- .../css/tests/CSSTransformOriginTest.cpp | 30 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-itest.js b/packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-itest.js index 976060faab26..81a882f4175a 100644 --- a/packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-itest.js +++ b/packages/react-native/Libraries/StyleSheet/__tests__/processTransformOrigin-itest.js @@ -113,5 +113,14 @@ describe('processTransformOrigin', () => { processTransformOrigin('top 30% center'); }).toThrow('Could not parse transform-origin: top 30% center'); }); + it('should handle decimal percentage and pixel values', () => { + expect(processTransformOrigin('50.5% 30.2%')).toEqual([ + '50.5%', + '30.2%', + 0, + ]); + expect(processTransformOrigin('12.5px 7.5px')).toEqual([12.5, 7.5, 0]); + expect(processTransformOrigin('.5% .5px')).toEqual(['.5%', 0.5, 0]); + }); }); }); diff --git a/packages/react-native/Libraries/StyleSheet/processTransformOrigin.js b/packages/react-native/Libraries/StyleSheet/processTransformOrigin.js index 75e4acacbd60..96cd040d8142 100644 --- a/packages/react-native/Libraries/StyleSheet/processTransformOrigin.js +++ b/packages/react-native/Libraries/StyleSheet/processTransformOrigin.js @@ -11,7 +11,8 @@ import invariant from 'invariant'; // Pre-compiled regex pattern for performance - avoids regex compilation on each call -const TRANSFORM_ORIGIN_REGEX = /(top|bottom|left|right|center|\d+(?:%|px)|0)/gi; +const TRANSFORM_ORIGIN_REGEX = + /(top|bottom|left|right|center|\d*\.?\d+(?:%|px)|0)/gi; const INDEX_X = 0; const INDEX_Y = 1; diff --git a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTransformOriginTest.cpp b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTransformOriginTest.cpp index fbfbeac8bc06..3df2920f069e 100644 --- a/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTransformOriginTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/css/tests/CSSTransformOriginTest.cpp @@ -425,4 +425,34 @@ TEST(CSSTransformOrigin, percentage_percentage) { EXPECT_EQ(origin.z, CSSLength{}); } +TEST(CSSTransformOrigin, decimal_length_length) { + auto val = parseCSSProperty("12.5px 7.5px"); + EXPECT_TRUE(std::holds_alternative(val)); + auto& origin = std::get(val); + + EXPECT_TRUE(std::holds_alternative(origin.x)); + EXPECT_EQ(std::get(origin.x).value, 12.5f); + EXPECT_EQ(std::get(origin.x).unit, CSSLengthUnit::Px); + + EXPECT_TRUE(std::holds_alternative(origin.y)); + EXPECT_EQ(std::get(origin.y).value, 7.5f); + EXPECT_EQ(std::get(origin.y).unit, CSSLengthUnit::Px); + + EXPECT_EQ(origin.z, CSSLength{}); +} + +TEST(CSSTransformOrigin, decimal_percentage_percentage) { + auto val = parseCSSProperty("50.5% 30.2%"); + EXPECT_TRUE(std::holds_alternative(val)); + auto& origin = std::get(val); + + EXPECT_TRUE(std::holds_alternative(origin.x)); + EXPECT_EQ(std::get(origin.x).value, 50.5f); + + EXPECT_TRUE(std::holds_alternative(origin.y)); + EXPECT_EQ(std::get(origin.y).value, 30.2f); + + EXPECT_EQ(origin.z, CSSLength{}); +} + } // namespace facebook::react From 102fde7b6bf699dac9769b5336d9bbde2e228109 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Thu, 9 Jul 2026 19:57:24 -0700 Subject: [PATCH 032/834] Deprecate InputAccessoryView (#57402) Summary: Deprecates `InputAccessoryView` as part of the effort to keep core lean. It is iOS only, and `react-native-keyboard-controller` [`KeyboardStickyView`](https://kirillzyusko.github.io/react-native-keyboard-controller/docs/api/components/keyboard-sticky-view) is a better, cross-platform alternative. This adds a `deprecated` JSDoc tag to the component and its props (Flow and TypeScript) and a one-time runtime warning when the component is imported. No behavior changes. ## Changelog: [ANDROID] [DEPRECATED] - Deprecate `InputAccessoryView` Pull Request resolved: https://github.com/react/react-native/pull/57402 Test Plan: - `deprecated` shows a strikethrough on `InputAccessoryView` in editors. - Importing/rendering `InputAccessoryView` logs the deprecation warning once. Reviewed By: cortinico Differential Revision: D110333743 Pulled By: Abbondanzo fbshipit-source-id: 3e59afc0c3016619e9c4950c6f2b240bef146fad --- .../Components/TextInput/InputAccessoryView.d.ts | 7 +++++++ .../Components/TextInput/InputAccessoryView.js | 10 ++++++++-- packages/react-native/index.js | 4 ++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.d.ts b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.d.ts index d64911fcaf4a..e5a9a711e223 100644 --- a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.d.ts +++ b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.d.ts @@ -17,9 +17,16 @@ import {ViewStyle} from '../../StyleSheet/StyleSheetTypes'; * * To use this component wrap your custom toolbar with the InputAccessoryView component, and set a nativeID. Then, pass * that nativeID as the inputAccessoryViewID of whatever TextInput you desire. + * + * InputAccessoryView is deprecated and will be removed in a future release. + * @deprecated */ export class InputAccessoryView extends React.Component {} +/** + * InputAccessoryView is deprecated and will be removed in a future release. + * @deprecated + */ export interface InputAccessoryViewProps { backgroundColor?: ColorValue | undefined; diff --git a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js index 4b2e7d961c4c..e00db0a1fa35 100644 --- a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js +++ b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js @@ -18,7 +18,11 @@ import useWindowDimensions from '../../Utilities/useWindowDimensions'; import RCTInputAccessoryViewNativeComponent from './RCTInputAccessoryViewNativeComponent'; import * as React from 'react'; -/** @build-types emit-as-interface Expo compatibility */ +/** + * InputAccessoryView is deprecated and will be removed in a future release. + * @deprecated + * @build-types emit-as-interface Expo compatibility + */ export type InputAccessoryViewProps = Readonly<{ readonly children: React.Node, /** @@ -36,8 +40,10 @@ export type InputAccessoryViewProps = Readonly<{ * * This component can also be used to create sticky text inputs (text inputs which are anchored to the top of the keyboard). To do this, wrap a `TextInput` with `InputAccessoryView` and don't set a `nativeID`. * - * @see https://reactnative.dev/docs/inputaccessoryview + * InputAccessoryView is deprecated and will be removed in a future release. + * * @platform ios + * @deprecated */ const InputAccessoryView: React.ComponentType = ( props: InputAccessoryViewProps, diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 0fb660e9aaf7..80fb9723e4f9 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -77,6 +77,10 @@ module.exports = { return require('./Libraries/Image/ImageBackground').default; }, get InputAccessoryView() { + warnOnce( + 'input-accessory-view-deprecated', + 'InputAccessoryView is deprecated and will be removed in a future release.', + ); return require('./Libraries/Components/TextInput/InputAccessoryView') .default; }, From 9160010d1f35266ddc18004f7fb945958aa16be6 Mon Sep 17 00:00:00 2001 From: Adam Ivancza Date: Fri, 10 Jul 2026 02:46:41 -0700 Subject: [PATCH 033/834] Fix ScrollView recycled content inset (#57494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Fixes https://github.com/react/react-native/issues/57314. When a Fabric `ScrollView` is recycled on iOS, `prepareForRecycle` resets native scroll state before the view is reused. `contentInset` was being reset to `UIEdgeInsetsZero`, which prevents stale native insets from leaking between recycled views, but also drops a valid non-zero `contentInset` from the current props. If the recycled view is mounted again with the same `contentInset` prop value, `updateProps` may not reapply it because the props did not change. This can leave the native `UIScrollView` with `contentInset = 0` even though React props still specify a non-zero inset. This change keeps the stale-state protection, but resets `contentInset` back to the value from the current props instead of always zeroing it. ## Changelog: [IOS] [FIXED] - Preserve Fabric ScrollView contentInset when recycling iOS native views Pull Request resolved: https://github.com/react/react-native/pull/57494 Test Plan: Verified with the repro from https://github.com/react/react-native/issues/57314: 1. Cloned and installed `gimi-anders/rn-contentinset-recycle-repro` 2. Ran the app with the existing implementation and reproduced the issue: - Step 0: first ScrollView reports `native contentInset.top = 200` - Step 1: first ScrollView is unmounted - Step 2: recycled ScrollView reports `native contentInset.top = 0` 3. Applied this change to the repro app’s local React Native source and rebuilt with React Native Core built from source. 4. Re-ran the repro: - Step 2 now reports `native contentInset.top = 200` Also verified that the previous stale-inset protection is preserved: `prepareForRecycle` still overwrites the recycled native `UIScrollView.contentInset`; it now overwrites it with the current props value instead of always using `UIEdgeInsetsZero`. | Before | After | | --- | --- | | https://github.com/user-attachments/assets/e69ce800-23ab-4406-8700-f57511af85d8 | https://github.com/user-attachments/assets/35b93cff-0804-455c-a85c-2c0b05d35417 | Reviewed By: cipolleschi Differential Revision: D111262993 Pulled By: javache fbshipit-source-id: a98729d105792c51a7dec6eee8510cfd086e372e --- .../ComponentViews/ScrollView/RCTScrollViewComponentView.mm | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 548987ff291d..45c69b13f4b9 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -696,8 +696,7 @@ - (void)prepareForRecycle // Invalidate cached content size so that updateState: recalculates the // container frame after zoomScale reset (which may have mutated it in RTL). _contentSize = CGSizeZero; - // Reset contentInset to prevent stale insets leaking into recycled scroll views. - _scrollView.contentInset = UIEdgeInsetsZero; + _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset); // We set the default behavior to "never" so that iOS // doesn't do weird things to UIScrollView insets automatically // and keeps it as an opt-in behavior. From b7edeb71269aae339d656b0c9b03dc5e2b23f265 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Fri, 10 Jul 2026 06:00:50 -0700 Subject: [PATCH 034/834] Expose react-private-interface subpath (#57495) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57495 See [**RFC0894: Removing deep imports from react-native**](https://github.com/react-native-community/discussions-and-proposals/pull/894) Exposes a `'react-native/react-private-interface'` subpath export on the main package, to replace `Libraries/ReactPrivate/ReactNativePrivateInterface.js`. **Impact: Internal** This is a private contract between `react` and `react-native`. - `ReactNativePrivateInterface` is deprecated and we'll migrate React call sites in a future version before cleanup. **Naming** Translate directly to `react-private-interface` ("private interface for React"). There's alternatives here but the explicitness and continuity is helpful, I believe. Changelog: [Internal] Reviewed By: robhogan Differential Revision: D111231527 fbshipit-source-id: 9caa17d2762fe290f21f80894e018e29f95fba46 --- .flowconfig | 1 + .../ReactNativePrivateInterface.js | 121 +-------------- .../ReactNativePrivateInterface.js.flow | 36 +---- packages/react-native/package.json | 4 + .../src/react-private-interface.js | 145 ++++++++++++++++++ .../src/react-private-interface.js.flow | 48 ++++++ 6 files changed, 208 insertions(+), 147 deletions(-) create mode 100644 packages/react-native/src/react-private-interface.js create mode 100644 packages/react-native/src/react-private-interface.js.flow diff --git a/.flowconfig b/.flowconfig index 915f8b340ad8..832667b526f1 100644 --- a/.flowconfig +++ b/.flowconfig @@ -56,6 +56,7 @@ experimental.multi_platform.extensions=.android munge_underscores=true module.name_mapper='^react-native$' -> '/packages/react-native/index.js' +module.name_mapper='^react-native/react-private-interface$' -> '/packages/react-native/src/react-private-interface.js' module.name_mapper='^react-native/setup-env$' -> '/packages/react-native/src/setup-env.js' module.name_mapper='^react-native/\(.*\)$' -> '/packages/react-native/\1' module.name_mapper='^@react-native/dev-middleware$' -> '/packages/dev-middleware' diff --git a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js index 190d559a7117..2cf1eca02e95 100644 --- a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js +++ b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js @@ -8,124 +8,13 @@ * @format */ -import typeof dispatchNativeEvent from '../../src/private/renderer/events/dispatchNativeEvent'; -import typeof CustomEvent from '../../src/private/webapis/dom/events/CustomEvent'; -import typeof BatchedBridge from '../BatchedBridge/BatchedBridge'; -import typeof legacySendAccessibilityEvent from '../Components/AccessibilityInfo/legacySendAccessibilityEvent'; -import typeof TextInputState from '../Components/TextInput/TextInputState'; -import typeof ExceptionsManager from '../Core/ExceptionsManager'; -import typeof RawEventEmitter from '../Core/RawEventEmitter'; -import typeof ReactFiberErrorDialog from '../Core/ReactFiberErrorDialog'; -import typeof RCTEventEmitter from '../EventEmitter/RCTEventEmitter'; -import typeof { - createPublicInstance, - createPublicRootInstance, - createPublicTextInstance, - getInternalInstanceHandleFromPublicInstance, - getNativeTagFromPublicInstance, - getNodeFromPublicInstance, -} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; -import typeof { - create as createAttributePayload, - diff as diffAttributePayloads, -} from '../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; -import typeof UIManager from '../ReactNative/UIManager'; -import typeof * as ReactNativeViewConfigRegistry from '../Renderer/shims/ReactNativeViewConfigRegistry'; -import typeof flattenStyle from '../StyleSheet/flattenStyle'; -import type {DangerouslyImpreciseStyleProp} from '../StyleSheet/StyleSheet'; -import typeof deepFreezeAndThrowOnMutationInDev from '../Utilities/deepFreezeAndThrowOnMutationInDev'; -import typeof deepDiffer from '../Utilities/differ/deepDiffer'; -import typeof Platform from '../Utilities/Platform'; +import typeof {createPublicTextInstance} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; export type {PublicRootInstance} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; export type PublicTextInstance = ReturnType; -// flowlint unsafe-getters-setters:off +/** + * @deprecated Since 0.88. Use 'react-native/react-private-interface' instead. + */ // eslint-disable-next-line @react-native/monorepo/no-commonjs-exports -module.exports = { - get BatchedBridge(): BatchedBridge { - return require('../BatchedBridge/BatchedBridge').default; - }, - get ExceptionsManager(): ExceptionsManager { - return require('../Core/ExceptionsManager').default; - }, - get Platform(): Platform { - return require('../Utilities/Platform').default; - }, - get RCTEventEmitter(): RCTEventEmitter { - return require('../EventEmitter/RCTEventEmitter').default; - }, - get ReactNativeViewConfigRegistry(): ReactNativeViewConfigRegistry { - return require('../Renderer/shims/ReactNativeViewConfigRegistry'); - }, - get TextInputState(): TextInputState { - return require('../Components/TextInput/TextInputState').default; - }, - get UIManager(): UIManager { - return require('../ReactNative/UIManager').default; - }, - // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` - get deepDiffer(): deepDiffer { - return require('../Utilities/differ/deepDiffer').default; - }, - get deepFreezeAndThrowOnMutationInDev(): deepFreezeAndThrowOnMutationInDev< - {...} | Array, - > { - return require('../Utilities/deepFreezeAndThrowOnMutationInDev').default; - }, - // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` - get flattenStyle(): flattenStyle { - // $FlowFixMe[underconstrained-implicit-instantiation] - // $FlowFixMe[incompatible-type] - return require('../StyleSheet/flattenStyle').default; - }, - get ReactFiberErrorDialog(): ReactFiberErrorDialog { - return require('../Core/ReactFiberErrorDialog').default; - }, - get legacySendAccessibilityEvent(): legacySendAccessibilityEvent { - return require('../Components/AccessibilityInfo/legacySendAccessibilityEvent') - .default; - }, - get RawEventEmitter(): RawEventEmitter { - return require('../Core/RawEventEmitter').default; - }, - get CustomEvent(): CustomEvent { - return require('../../src/private/webapis/dom/events/CustomEvent').default; - }, - get createAttributePayload(): createAttributePayload { - return require('../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') - .create; - }, - get diffAttributePayloads(): diffAttributePayloads { - return require('../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') - .diff; - }, - get createPublicRootInstance(): createPublicRootInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .createPublicRootInstance; - }, - get createPublicInstance(): createPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .createPublicInstance; - }, - get createPublicTextInstance(): createPublicTextInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .createPublicTextInstance; - }, - get getNativeTagFromPublicInstance(): getNativeTagFromPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .getNativeTagFromPublicInstance; - }, - get getNodeFromPublicInstance(): getNodeFromPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .getNodeFromPublicInstance; - }, - get getInternalInstanceHandleFromPublicInstance(): getInternalInstanceHandleFromPublicInstance { - return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') - .getInternalInstanceHandleFromPublicInstance; - }, - get dispatchNativeEvent(): dispatchNativeEvent { - return require('../../src/private/renderer/events/dispatchNativeEvent') - .default; - }, -}; +module.exports = require('../../src/react-private-interface'); diff --git a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow index da6972e835df..c9b1e0e77c8b 100644 --- a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow +++ b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js.flow @@ -8,34 +8,8 @@ * @format */ -import typeof {createPublicTextInstance as createPublicTextInstanceT} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; - -export type {PublicRootInstance} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; -export type PublicTextInstance = ReturnType; - -export {default as BatchedBridge} from '../BatchedBridge/BatchedBridge'; -export {default as ExceptionsManager} from '../Core/ExceptionsManager'; -export {default as Platform} from '../Utilities/Platform'; -export {default as RCTEventEmitter} from '../EventEmitter/RCTEventEmitter'; -export * as ReactNativeViewConfigRegistry from '../Renderer/shims/ReactNativeViewConfigRegistry'; -export {default as TextInputState} from '../Components/TextInput/TextInputState'; -export {default as UIManager} from '../ReactNative/UIManager'; -export {default as deepDiffer} from '../Utilities/differ/deepDiffer'; -export {default as deepFreezeAndThrowOnMutationInDev} from '../Utilities/deepFreezeAndThrowOnMutationInDev'; -export {default as flattenStyle} from '../StyleSheet/flattenStyle'; -export {default as ReactFiberErrorDialog} from '../Core/ReactFiberErrorDialog'; -export {default as legacySendAccessibilityEvent} from '../Components/AccessibilityInfo/legacySendAccessibilityEvent'; -export {default as RawEventEmitter} from '../Core/RawEventEmitter'; -export {default as CustomEvent} from '../../src/private/webapis/dom/events/CustomEvent'; -export { - create as createAttributePayload, - diff as diffAttributePayloads, -} from '../ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; -export { - createPublicRootInstance, - createPublicInstance, - createPublicTextInstance, - getNativeTagFromPublicInstance, - getNodeFromPublicInstance, - getInternalInstanceHandleFromPublicInstance, -} from '../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +/** + * @deprecated Since 0.88. Use 'react-native/react-private-interface' instead. + */ +export type * from '../../src/react-private-interface'; +export * from '../../src/react-private-interface'; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index bf6a02391a3d..9b98b4fc0fe4 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -48,6 +48,10 @@ "types": null, "default": "./src/asset-registry.js" }, + "./react-private-interface": { + "types": null, + "default": "./src/react-private-interface.js" + }, "./setup-env": "./src/setup-env.js", "./src/fb_internal/*": "./src/fb_internal/*", "./package.json": "./package.json" diff --git a/packages/react-native/src/react-private-interface.js b/packages/react-native/src/react-private-interface.js new file mode 100644 index 000000000000..6e965490ebc9 --- /dev/null +++ b/packages/react-native/src/react-private-interface.js @@ -0,0 +1,145 @@ +/** + * 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'; + +// ---------------------------------------------------------------------------- +// react-native/react-private-interface +// +// This is a private entry point allowing React to require React Native +// internals (previously, Libaries/ReactNativePrivateInterface.js). +// +// These APIs should ONLY be used by first party React internals and are not +// part of our public API. +// +// IMPORTANT: Keep this file in sync with react-private-interface.js.flow. +// ---------------------------------------------------------------------------- + +import typeof BatchedBridge from '../Libraries/BatchedBridge/BatchedBridge'; +import typeof legacySendAccessibilityEvent from '../Libraries/Components/AccessibilityInfo/legacySendAccessibilityEvent'; +import typeof TextInputState from '../Libraries/Components/TextInput/TextInputState'; +import typeof ExceptionsManager from '../Libraries/Core/ExceptionsManager'; +import typeof RawEventEmitter from '../Libraries/Core/RawEventEmitter'; +import typeof ReactFiberErrorDialog from '../Libraries/Core/ReactFiberErrorDialog'; +import typeof RCTEventEmitter from '../Libraries/EventEmitter/RCTEventEmitter'; +import typeof { + createPublicInstance, + createPublicRootInstance, + createPublicTextInstance, + getInternalInstanceHandleFromPublicInstance, + getNativeTagFromPublicInstance, + getNodeFromPublicInstance, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +import typeof { + create as createAttributePayload, + diff as diffAttributePayloads, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; +import typeof UIManager from '../Libraries/ReactNative/UIManager'; +import typeof * as ReactNativeViewConfigRegistry from '../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; +import typeof flattenStyle from '../Libraries/StyleSheet/flattenStyle'; +import type {DangerouslyImpreciseStyleProp} from '../Libraries/StyleSheet/StyleSheet'; +import typeof deepFreezeAndThrowOnMutationInDev from '../Libraries/Utilities/deepFreezeAndThrowOnMutationInDev'; +import typeof deepDiffer from '../Libraries/Utilities/differ/deepDiffer'; +import typeof Platform from '../Libraries/Utilities/Platform'; +import typeof dispatchNativeEvent from './private/renderer/events/dispatchNativeEvent'; +import typeof CustomEvent from './private/webapis/dom/events/CustomEvent'; + +export type {PublicRootInstance} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +export type PublicTextInstance = ReturnType; + +// flowlint unsafe-getters-setters:off +// eslint-disable-next-line @react-native/monorepo/no-commonjs-exports +module.exports = { + get BatchedBridge(): BatchedBridge { + return require('../Libraries/BatchedBridge/BatchedBridge').default; + }, + get ExceptionsManager(): ExceptionsManager { + return require('../Libraries/Core/ExceptionsManager').default; + }, + get Platform(): Platform { + return require('../Libraries/Utilities/Platform').default; + }, + get RCTEventEmitter(): RCTEventEmitter { + return require('../Libraries/EventEmitter/RCTEventEmitter').default; + }, + get ReactNativeViewConfigRegistry(): ReactNativeViewConfigRegistry { + return require('../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'); + }, + get TextInputState(): TextInputState { + return require('../Libraries/Components/TextInput/TextInputState').default; + }, + get UIManager(): UIManager { + return require('../Libraries/ReactNative/UIManager').default; + }, + // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` + get deepDiffer(): deepDiffer { + return require('../Libraries/Utilities/differ/deepDiffer').default; + }, + get deepFreezeAndThrowOnMutationInDev(): deepFreezeAndThrowOnMutationInDev< + {...} | Array, + > { + return require('../Libraries/Utilities/deepFreezeAndThrowOnMutationInDev') + .default; + }, + // TODO: Remove when React has migrated to `createAttributePayload` and `diffAttributePayloads` + get flattenStyle(): flattenStyle { + // $FlowFixMe[underconstrained-implicit-instantiation] + // $FlowFixMe[incompatible-type] + return require('../Libraries/StyleSheet/flattenStyle').default; + }, + get ReactFiberErrorDialog(): ReactFiberErrorDialog { + return require('../Libraries/Core/ReactFiberErrorDialog').default; + }, + get legacySendAccessibilityEvent(): legacySendAccessibilityEvent { + return require('../Libraries/Components/AccessibilityInfo/legacySendAccessibilityEvent') + .default; + }, + get RawEventEmitter(): RawEventEmitter { + return require('../Libraries/Core/RawEventEmitter').default; + }, + get CustomEvent(): CustomEvent { + return require('./private/webapis/dom/events/CustomEvent').default; + }, + get createAttributePayload(): createAttributePayload { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') + .create; + }, + get diffAttributePayloads(): diffAttributePayloads { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload') + .diff; + }, + get createPublicRootInstance(): createPublicRootInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .createPublicRootInstance; + }, + get createPublicInstance(): createPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .createPublicInstance; + }, + get createPublicTextInstance(): createPublicTextInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .createPublicTextInstance; + }, + get getNativeTagFromPublicInstance(): getNativeTagFromPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .getNativeTagFromPublicInstance; + }, + get getNodeFromPublicInstance(): getNodeFromPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .getNodeFromPublicInstance; + }, + get getInternalInstanceHandleFromPublicInstance(): getInternalInstanceHandleFromPublicInstance { + return require('../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') + .getInternalInstanceHandleFromPublicInstance; + }, + get dispatchNativeEvent(): dispatchNativeEvent { + return require('./private/renderer/events/dispatchNativeEvent').default; + }, +}; diff --git a/packages/react-native/src/react-private-interface.js.flow b/packages/react-native/src/react-private-interface.js.flow new file mode 100644 index 000000000000..b30331d119f8 --- /dev/null +++ b/packages/react-native/src/react-private-interface.js.flow @@ -0,0 +1,48 @@ +/** + * 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 + */ + +// ---------------------------------------------------------------------------- +// Types entry point for react-native/react-private-interface +// +// IMPORTANT: Keep this file in sync with react-private-interface.js. +// ---------------------------------------------------------------------------- + +import typeof {createPublicTextInstance as createPublicTextInstanceT} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; + +export type {PublicRootInstance} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +export type PublicTextInstance = ReturnType; + +export {default as BatchedBridge} from '../Libraries/BatchedBridge/BatchedBridge'; +export {default as ExceptionsManager} from '../Libraries/Core/ExceptionsManager'; +export {default as Platform} from '../Libraries/Utilities/Platform'; +export {default as RCTEventEmitter} from '../Libraries/EventEmitter/RCTEventEmitter'; +export * as ReactNativeViewConfigRegistry from '../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; +export {default as TextInputState} from '../Libraries/Components/TextInput/TextInputState'; +export {default as UIManager} from '../Libraries/ReactNative/UIManager'; +export {default as deepDiffer} from '../Libraries/Utilities/differ/deepDiffer'; +export {default as deepFreezeAndThrowOnMutationInDev} from '../Libraries/Utilities/deepFreezeAndThrowOnMutationInDev'; +export {default as flattenStyle} from '../Libraries/StyleSheet/flattenStyle'; +export {default as ReactFiberErrorDialog} from '../Libraries/Core/ReactFiberErrorDialog'; +export {default as legacySendAccessibilityEvent} from '../Libraries/Components/AccessibilityInfo/legacySendAccessibilityEvent'; +export {default as RawEventEmitter} from '../Libraries/Core/RawEventEmitter'; +export {default as CustomEvent} from './private/webapis/dom/events/CustomEvent'; +export { + create as createAttributePayload, + diff as diffAttributePayloads, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; +export { + createPublicRootInstance, + createPublicInstance, + createPublicTextInstance, + getNativeTagFromPublicInstance, + getNodeFromPublicInstance, + getInternalInstanceHandleFromPublicInstance, +} from '../Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance'; +export {default as dispatchNativeEvent} from './private/renderer/events/dispatchNativeEvent'; From 4b7fa49c27a2d280ec08ca7326b34d9f242a9c41 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Fri, 10 Jul 2026 06:00:50 -0700 Subject: [PATCH 035/834] Add unstable-internals entry point (#57489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57489 See [**RFC0894: Removing deep imports from react-native**](https://github.com/react-native-community/discussions-and-proposals/pull/894) Adds a `'react-native/unstable-internals-do-not-use'` entry point that acts as an explicit, opt-in escape hatch for frameworks and libraries that depend on specific React Native internals. Follows (and supersedes) https://github.com/react-native-community/discussions-and-proposals/pull/985. Resolves T270727304. **Motivation** - Maintains a known list of which React Native internals are still in use. - Decouples removal of the Strict API opt-out from the longer-term decisions about these APIs. - Lets us relocate supporting source files freely, since consumers reference this subpath instead of deep internal paths. **Notes** - Reuses the runtime **object-with-getter pattern** (same as `index.js`), to avoid any native module side effects caused by loading adjacent file exports. - e.g. in Expo, [`hmrUtils.js`](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/async-require/hmrUtils.native.ts?fbclid=IwY2xjawS7GjNwZG9mA2V4dG4DYWVtAjExAGJyaWQRMTZ1ckEwU05oWUw2bDBVN21zcnRjBmFwcF9pZAEwAAEen3UuX4Ig-RD3n1ULZIHIHq21Mfk_ci9b2SxquY7UkngDs6qhnRt5YQlb8Os_aem_ylJy40egSOl1qhZ9bA9HTA) is designed to be run early in RN startup — preserve the lazy require characteristic and do not break this. - TypeScript defs on this API are minimal, with complex internal input/handle types reduced to `unknown`. As with D110602194, these are intended to be feature-locked. **Exposed internals and known consumers** NOTE: The exports the community needs have reduced slightly since the spreadsheet in RFC0895 and would almost all align with `devsupport`. However, we're explicitly leaving a decision to formalise until later, and we may need to add in extra APIs quickly based on RC feedback. | Export | Source module | Known consumers | |---|---|---| | `AppContainer` | `Libraries/ReactNative/AppContainer` | react-native-screens ([DebugContainer.tsx](https://github.com/software-mansion/react-native-screens/blob/6f38d76129d40cc8145d06b98f885b508fa7bf2d/src/components/DebugContainer.tsx#L5)) | | `AssetSourceResolver` | `Libraries/Image/AssetSourceResolver` | Re.pack ([IncludeModules.ts](https://github.com/callstack/repack/blob/2498b3a426203daa4b81c7e6f7a88e86d88e77b9/packages/repack/src/modules/IncludeModules.ts#L9)) | | `customDirectEventTypes` | `Libraries/Renderer/shims/ReactNativeViewConfigRegistry` | react-native-gesture-handler ([customDirectEventTypes.ts](https://github.com/software-mansion/react-native-gesture-handler/blob/ee147abf364bef48a02d3cf437572b4dddd30bec/packages/react-native-gesture-handler/src/handlers/customDirectEventTypes.ts#L2)) | | `DevLoadingView` | `Libraries/Utilities/DevLoadingView` | Expo ([hmrUtils.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/async-require/hmrUtils.native.ts)), Re.pack ([WebpackHMRClient.ts](https://github.com/callstack/repack/blob/2498b3a426203daa4b81c7e6f7a88e86d88e77b9/packages/repack/src/modules/WebpackHMRClient.ts)) | | `getDevServer` | `Libraries/Core/Devtools/getDevServer` | Expo ([getDevServer.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/async-require/getDevServer.native.ts), [hmrUtils.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/async-require/hmrUtils.native.ts), [base.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/dom/base.ts), [getDevServer.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/metro-runtime/src/getDevServer.native.ts), [devServerEndpoints.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/log-box/src/utils/devServerEndpoints.ts), [getConnectionInfo.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/devtools/src/getConnectionInfo.native.ts), [index.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo-router/src/getDevServer/index.native.ts)) | | `HMRClient` | `Libraries/Utilities/HMRClient` | Expo ([metroServerLogs.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/metro-runtime/src/metroServerLogs.native.ts)) | | `NativeExceptionsManager` | `Libraries/Core/NativeExceptionsManager` | Expo ([hmrUtils.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/async-require/hmrUtils.native.ts)), Re.pack ([WebpackHMRClient.ts](https://github.com/callstack/repack/blob/2498b3a426203daa4b81c7e6f7a88e86d88e77b9/packages/repack/src/modules/WebpackHMRClient.ts)) | | `NativeRedBox` | `Libraries/NativeModules/specs/NativeRedBox` | Expo ([hmrUtils.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/async-require/hmrUtils.native.ts)), Re.pack ([WebpackHMRClient.ts](https://github.com/callstack/repack/blob/2498b3a426203daa4b81c7e6f7a88e86d88e77b9/packages/repack/src/modules/WebpackHMRClient.ts)) | | `NativeSourceCode` | `Libraries/NativeModules/specs/NativeSourceCode` | Expo ([getBundleUrl.native.ts](https://github.com/expo/expo/blob/2d07e3a17f4be2284bbf579d8825fe32f762ce1a/packages/expo/src/utils/getBundleUrl.native.ts)) | | `PressabilityDebugView` | `Libraries/Pressability/PressabilityDebug` | react-native-gesture-handler ([PressabilityDebugView.tsx](https://github.com/software-mansion/react-native-gesture-handler/blob/ee147abf364bef48a02d3cf437572b4dddd30bec/packages/react-native-gesture-handler/src/handlers/PressabilityDebugView.tsx#L2)) | **Alternative names** - `unstable-frameworks` - `unstable-devsupport` - `frameworks-private-interface` (mirroring `ReactPrivateInterface.js`) — this is likely a future stable shape candidate. Changelog: [Internal] Reviewed By: rubennorte Differential Revision: D110911864 fbshipit-source-id: 4cb4758e6621a626916cc6543f9a1f8462f1d5b0 --- packages/react-native/package.json | 5 + .../src/unstable-internals-do-not-use.d.ts | 214 ++++++++++++++++++ .../src/unstable-internals-do-not-use.js | 76 +++++++ 3 files changed, 295 insertions(+) create mode 100644 packages/react-native/src/unstable-internals-do-not-use.d.ts create mode 100644 packages/react-native/src/unstable-internals-do-not-use.js diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 9b98b4fc0fe4..08a2580f8587 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -53,6 +53,11 @@ "default": "./src/react-private-interface.js" }, "./setup-env": "./src/setup-env.js", + "./unstable-internals-do-not-use": { + "react-native-unstable-internals": "./src/unstable-internals-do-not-use.d.ts", + "types": null, + "default": "./src/unstable-internals-do-not-use.js" + }, "./src/fb_internal/*": "./src/fb_internal/*", "./package.json": "./package.json" }, diff --git a/packages/react-native/src/unstable-internals-do-not-use.d.ts b/packages/react-native/src/unstable-internals-do-not-use.d.ts new file mode 100644 index 000000000000..6c1f3bf54c40 --- /dev/null +++ b/packages/react-native/src/unstable-internals-do-not-use.d.ts @@ -0,0 +1,214 @@ +/** + * 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 + */ + +// ---------------------------------------------------------------------------- +// Types entry point for react-native/unstable-internals-do-not-use. +// +// IMPORTANT: Keep this file in sync with unstable-internals-do-not-use.js. +// ---------------------------------------------------------------------------- + +import type * as React from 'react'; + +// #region AppContainer + +interface AppContainerProps { + children?: React.ReactNode | undefined; + rootTag: number; + initialProps?: object | undefined; + WrapperComponent?: React.ComponentType | null | undefined; + rootViewStyle?: unknown | undefined; + internal_excludeLogBox?: boolean | undefined; + internal_excludeInspector?: boolean | undefined; +} + +/** Root component that wraps and mounts a React Native app tree. */ +export const AppContainer: React.ComponentType; + +// #endregion +// #region AssetSourceResolver + +interface ResolvedAssetSource { + readonly __packager_asset: boolean; + readonly width: number | null | undefined; + readonly height: number | null | undefined; + readonly uri: string; + readonly scale: number; +} + +/** Resolves a packager asset descriptor to a loadable source for the current platform. */ +export class AssetSourceResolver { + serverUrl: string | null | undefined; + jsbundleUrl: string | null | undefined; + asset: unknown; + constructor( + serverUrl: string | null | undefined, + jsbundleUrl: string | null | undefined, + asset: unknown, + ); + isLoadedFromServer(): boolean; + isLoadedFromFileSystem(): boolean; + defaultAsset(): ResolvedAssetSource; + getAssetUsingResolver(resolver: 'android' | 'generic'): ResolvedAssetSource; + assetServerURL(): ResolvedAssetSource; + scaledAssetPath(): ResolvedAssetSource; + scaledAssetURLNearBundle(): ResolvedAssetSource; + resourceIdentifierWithoutScale(): ResolvedAssetSource; + drawableFolderInBundle(): ResolvedAssetSource; + fromSource(source: string): ResolvedAssetSource; + static pickScale(scales: number[], deviceScale?: number): number; +} + +// #endregion +// #region customDirectEventTypes + +/** Registry mapping custom direct (non-bubbling) event names to their registration names. */ +export const customDirectEventTypes: { + [eventName: string]: Readonly<{ + registrationName: string; + }>; +}; + +// #endregion +// #region DevLoadingView + +/** Dev-only overlay banner showing bundle load, refresh, and error status. */ +export const DevLoadingView: { + showMessage( + message: string, + type: 'load' | 'refresh' | 'error', + options?: {dismissButton?: boolean | undefined}, + ): void; + hide(): void; +}; + +// #endregion +// #region getDevServer + +interface DevServerInfo { + url: string; + fullBundleUrl: string | null; + bundleLoadedFromServer: boolean; +} + +/** Returns information about the running dev server. */ +export function getDevServer(): DevServerInfo; + +// #endregion +// #region HMRClient + +/** Client that receives Fast Refresh updates and applies them at runtime. */ +export class HMRClient { + enable(): void; + disable(): void; + registerBundle(requestUrl: string): void; + log( + level: + | 'trace' + | 'info' + | 'warn' + | 'error' + | 'log' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'debug', + data: ReadonlyArray, + ): void; + setup( + platform: string, + bundleEntry: string, + host: string, + port: number | string, + isEnabled: boolean, + scheme?: string, + ): void; +} + +// #endregion +// #region NativeExceptionsManager + +interface StackFrame { + column: number | null; + file: string | null; + lineNumber: number | null; + methodName: string; + collapse?: boolean | undefined; +} + +interface ExceptionData { + message: string; + originalMessage: string | null; + name: string | null; + componentStack: string | null; + stack: StackFrame[]; + id: number; + isFatal: boolean; + extraData?: object | undefined; +} + +/** Reports JS exceptions to native and manages RedBox. */ +export const NativeExceptionsManager: { + reportFatalException( + message: string, + stack: StackFrame[], + exceptionId: number, + ): void; + reportSoftException( + message: string, + stack: StackFrame[], + exceptionId: number, + ): void; + dismissRedbox(): void; + reportException(data: ExceptionData): void; +}; + +// #endregion +// #region NativeRedBox + +interface NativeRedBoxSpec { + setExtraData(extraData: object, forIdentifier: string): void; + dismiss(): void; +} + +/** Native module for the RedBox error overlay; null when unavailable. */ +export const NativeRedBox: NativeRedBoxSpec | null; + +// #endregion +// #region NativeSourceCode + +interface SourceCodeConstants { + scriptURL: string; +} + +/** Native module exposing source-code constants such as the bundle scriptURL. */ +export const NativeSourceCode: { + getConstants(): SourceCodeConstants; +}; + +// #endregion +// #region PressabilityDebugView + +type Rect = Readonly<{ + bottom?: number | null | undefined; + left?: number | null | undefined; + right?: number | null | undefined; + top?: number | null | undefined; +}>; + +type RectOrSize = Rect | number; + +interface PressabilityDebugViewProps { + color: unknown; + hitSlop: RectOrSize | null | undefined; +} + +/** Debug overlay that visualizes press targets when enabled via the Inspector. */ +export const PressabilityDebugView: React.ComponentType; + +// #endregion diff --git a/packages/react-native/src/unstable-internals-do-not-use.js b/packages/react-native/src/unstable-internals-do-not-use.js new file mode 100644 index 000000000000..20cb19edaec0 --- /dev/null +++ b/packages/react-native/src/unstable-internals-do-not-use.js @@ -0,0 +1,76 @@ +/** + * 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. + * + * @noflow + * @format + */ + +'use strict'; +'use client'; + +// ---------------------------------------------------------------------------- +// react-native/unstable-internals-do-not-use +// +// UNSTABLE WITH NO SEMVER GUARANTEES. +// SHOULD NOT BE DEPENDED ON BY NEW CODE. +// +// This is a secondary entry point for frameworks and libraries that depend on +// specific React Native internals, serving as a compatibility bridge. +// +// Consuming codebases must opt in via tsconfig.json: +// "customConditions": ["react-native-unstable-internals"] +// +// Having this entry point: +// - Maintains a known list of which React Native internals are in use. +// - Enables us to relocate supporting files more freely. +// - Gives us time to decide on the future of these APIs (independent from +// removal of the Strict API opt out). +// +// The long term future of these exports is to formalize/delete them where +// appropriate, and collapse this entry point. +// +// Replaces RFC0985 +// https://github.com/react-native-community/discussions-and-proposals/pull/985 +// where we reviewed internal APIs used in the ecosystem. +// +// IMPORTANT: Keep this file in sync with unstable-internals-do-not-use.d.ts. +// ---------------------------------------------------------------------------- + +// eslint-disable-next-line @react-native/monorepo/no-commonjs-exports +module.exports = { + get AppContainer() { + return require('../Libraries/ReactNative/AppContainer').default; + }, + get AssetSourceResolver() { + return require('../Libraries/Image/AssetSourceResolver').default; + }, + get customDirectEventTypes() { + return require('../Libraries/Renderer/shims/ReactNativeViewConfigRegistry') + .customDirectEventTypes; + }, + get DevLoadingView() { + return require('../Libraries/Utilities/DevLoadingView').default; + }, + get getDevServer() { + return require('../Libraries/Core/Devtools/getDevServer').default; + }, + get HMRClient() { + return require('../Libraries/Utilities/HMRClient').default; + }, + get NativeExceptionsManager() { + return require('../Libraries/Core/NativeExceptionsManager').default; + }, + get NativeRedBox() { + return require('../Libraries/NativeModules/specs/NativeRedBox').default; + }, + get NativeSourceCode() { + return require('../Libraries/NativeModules/specs/NativeSourceCode').default; + }, + get PressabilityDebugView() { + return require('../Libraries/Pressability/PressabilityDebug') + .PressabilityDebugView; + }, +}; From 4237f1a90d69a15299decc1d0044a0947c932b0d Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Fri, 10 Jul 2026 06:08:44 -0700 Subject: [PATCH 036/834] Simplify react-native/setup-env require in community-cli-plugin (#57498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57498 **Motivation** While sanity checking https://github.com/react/react-native/pull/57492, I double checked why we use `path.join(ctx.reactNativePath, ...)` here when adjacent OOT platforms resolve by package name. It turns out this complexity is historic and redundant: - `reactNativePath` doesn't customize the `react-native` package name — it only resolves the `node_modules` directory. [[1]](https://github.com/react-native-community/cli/blame/main/packages/cli-tools/src/findPackageDependencyDir.ts#L81) - We now (since ~2 years ago) resolve using `{paths: [ctx.root]}`, making the manual `path.join` redundant. **This diff** Drops the `reactNativePath` option and resolves `react-native/setup-env` directly by package name. Purely a refactor. No need to pick to 0.87. Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D111225671 fbshipit-source-id: ff0a385172228f51cde747266543cc615be7ee38 --- packages/community-cli-plugin/src/utils/loadMetroConfig.js | 6 +----- packages/metro-config/src/index.flow.js | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index a41b028fa29a..9b6a79ee1041 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -14,7 +14,6 @@ import type {MetroConfig} from 'metro'; import {CLIError} from './errors'; import {reactNativePlatformResolver} from './metroPlatformResolver'; import {loadConfig, resolveConfig} from 'metro'; -import path from 'path'; const debug = require('debug')('ReactNative:CommunityCliPlugin'); @@ -25,7 +24,6 @@ export type {Config}; export type ConfigLoadingContext = Readonly<{ root: Config['root'], - reactNativePath: Config['reactNativePath'], platforms: Config['platforms'], ... }>; @@ -63,9 +61,7 @@ function getCommunityCliDefaultConfig( // We can include multiple copies of setup-env here because Metro will // only add ones that are already part of the bundle getModulesRunBeforeMainModule: () => [ - // NOTE: ctx.reactNativePath is an absolute path, therefore we need to - // reference setup-env.js here by exact path specifier. - require.resolve(path.join(ctx.reactNativePath, 'src/setup-env.js'), { + require.resolve('react-native/setup-env', { paths: [ctx.root], }), ...outOfTreePlatforms.map(platform => diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index dcac214b3978..74fc4053c2b7 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -59,7 +59,7 @@ export function getDefaultConfig(projectRoot: string): ConfigT { unstable_conditionNames: ['react-native'], }, serializer: { - // Note: This option is overridden in cli-plugin-metro (getOverrideConfig) + // NOTE: Overridden in community-cli-plugin getModulesRunBeforeMainModule: () => [ require.resolve('react-native/setup-env'), ], From c948b61c051a2d5cfc79925e93eb49904db972c8 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Fri, 10 Jul 2026 09:28:17 -0700 Subject: [PATCH 037/834] Enable Strict TS API by default (#57490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57490 See [**RFC0894: Removing deep imports from react-native**](https://github.com/react-native-community/discussions-and-proposals/pull/894) This is the **big switch** to enable the Strict TypeScript API (generated types + single index entry point) by default in React Native. **Opt-in → opt-out** After this change, the main `react-native` package resolves its `"types"` entry points only to `types_generated/index.d.ts` — with no other subpaths available. The new `"react-native-legacy-deep-imports"` condition maps to legacy `types/` and `Libraries/*.d.ts` sources. **Impact limitation**: For this stage of rollout, the `"default"` condition continues to resolve to source files. Only TypeScript is affected. **How to opt out** Opposite of today's opt-in, which we will update in [the docs](https://reactnative.dev/docs/strict-typescript-api). Again, the only impact area today is **TypeScript**. ```json5 // tsconfig.json { "extends": "react-native/typescript-config", "compilerOptions": { ... "customConditions": ["react-native-legacy-deep-imports"] } } ``` **Other changes** - Drop `react-native/typescript-config/strict` entry point, update README. - Update `__typetests__`. **Rollout plan** **Target release: 0.87**. This and the contributing stack will be cherry picked for RC1. - We've conducted testing against 100+ real Expo codebases, giving us the confidence that we've reduced breaking changes enough that the vast majority of RN codebases can migrate. - The Strict API includes a number of **intentional breaking changes**, and docs have been kept up to date. - We're shipping a `/migrate-to-strict-api` skill to migrate via agents, see https://github.com/react-native-community/skills/pull/3. **What's improved since 0.80?** Since the initial opt-in launch of the Strict API in 0.80, we've been making continuous improvements over the last year to get our generated types into a widely launchable state. Most notably: - 21+ new/updated root APIs and fixes due to community feedback ([discussion](https://github.com/react-native-community/discussions-and-proposals/discussions/893), [PRs](https://github.com/react/react-native/pulls?q=is%3Apr%20label%3A%22JS%20API%20stabilization%20(1.0)%22%20is%3Aclosed)). - Upstream encapsulation blocker in TypeScript, fixed in 6.0 (https://github.com/react/react-native/issues/53565). - Tailwind/Uniwind compatibility (`interface` types for props). - `*Instance` ref type exports for all built-in components (https://github.com/react-native-community/discussions-and-proposals/pull/1003). - Fixes to previously mistyped, high impact APIs, such as `Appearance`. - New subpath entry points for `asset-registry`, `setup-env`, and others. - Refinements to doc comments/type translation build. **Rollback plan** Revert this diff. IMPORTANT: We'll adopt a policy of **super-eager rollback**, if there are any unsolvable issues during the RC phase. Changelog: [General][Breaking] - React Native's default JavaScript API is now the [Strict TypeScript API](https://reactnative.dev/docs/strict-typescript-api). Use `customConditions: ["react-native-legacy-deep-imports"]` to opt out. Reviewed By: cortinico Differential Revision: D110458670 fbshipit-source-id: 4b0e0b458a5f895f783d6d936e7b11ccff2df076 --- .github/workflows/test-all.yml | 4 ++-- package.json | 4 ++-- .../react-native/__typetests__/tsconfig.json | 7 +++--- .../__typetests__/tsconfig.legacy.json | 10 ++++++++ packages/react-native/package.json | 11 ++++----- packages/react-native/types/tsconfig.json | 17 ------------- packages/typescript-config/README.md | 15 ++++-------- packages/typescript-config/package.json | 3 +-- .../typescript-config/tsconfig.strict.json | 8 ------- packages/virtualized-lists/package.json | 4 ++-- .../js-api/build-types/buildGeneratedTypes.js | 24 ++++--------------- scripts/run-ci-javascript-tests.js | 2 +- 12 files changed, 34 insertions(+), 75 deletions(-) rename scripts/js-api/build-types/templates/tsconfig.test.json => packages/react-native/__typetests__/tsconfig.json (59%) create mode 100644 packages/react-native/__typetests__/tsconfig.legacy.json delete mode 100644 packages/react-native/types/tsconfig.json delete mode 100644 packages/typescript-config/tsconfig.strict.json diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 422a90c712ae..1f49896cbf36 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -471,9 +471,9 @@ jobs: - name: Flow shell: bash run: yarn flow-check - - name: TypeScript + - name: TypeScript (legacy deep imports / manual types) shell: bash - run: yarn test-typescript + run: yarn test-typescript-legacy test_js: runs-on: ubuntu-latest diff --git a/package.json b/package.json index 6c0b4a3cb097..213f870072eb 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "test-release-local-clean": "node ./scripts/release-testing/test-release-local-clean.js", "test-release-local": "node ./scripts/release-testing/test-release-local.js", "test-ios": "./scripts/objc-test.sh test", - "test-typescript": "tsc -p packages/react-native/types/tsconfig.json", - "test-generated-typescript": "tsc -p packages/react-native/types_generated/tsconfig.test.json", + "test-typescript-legacy": "tsc -p packages/react-native/__typetests__/tsconfig.legacy.json", + "test-generated-typescript": "tsc -p packages/react-native/__typetests__/tsconfig.json", "test": "jest", "fantom": "./scripts/fantom.sh", "fantom-cli": "./scripts/fantom-cli.sh", diff --git a/scripts/js-api/build-types/templates/tsconfig.test.json b/packages/react-native/__typetests__/tsconfig.json similarity index 59% rename from scripts/js-api/build-types/templates/tsconfig.test.json rename to packages/react-native/__typetests__/tsconfig.json index 7af1dd074cb8..241c3b969fb2 100644 --- a/scripts/js-api/build-types/templates/tsconfig.test.json +++ b/packages/react-native/__typetests__/tsconfig.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "module": "esnext", "lib": ["es2020"], @@ -9,9 +10,7 @@ "jsx": "react", "noEmit": true, "forceConsistentCasingInFileNames": true, - "paths": {"react-native": ["."]}, - "moduleResolution": "bundler", - "customConditions": ["react-native-strict-api"] + "moduleResolution": "bundler" }, - "include": ["**/*.d.ts", "../__typetests__/**/*"] + "include": ["**/*"] } diff --git a/packages/react-native/__typetests__/tsconfig.legacy.json b/packages/react-native/__typetests__/tsconfig.legacy.json new file mode 100644 index 000000000000..3aea5153dceb --- /dev/null +++ b/packages/react-native/__typetests__/tsconfig.legacy.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + // Legacy variant: Opts into the `"react-native-legacy-deep-imports"` export + // condition so `react-native` and `react-native/Libraries/*` resolve to the + // hand-written types in `types/`. + "extends": "./tsconfig.json", + "compilerOptions": { + "customConditions": ["react-native-legacy-deep-imports"] + } +} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 08a2580f8587..8b761ac0ff60 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -27,20 +27,19 @@ "react-native": "cli.js" }, "main": "./index.js", - "types": "types", "exports": { ".": { - "react-native-strict-api": "./types_generated/index.d.ts", - "types": "./types/index.d.ts", + "react-native-legacy-deep-imports": "./types/index.d.ts", + "types": "./types_generated/index.d.ts", "default": "./index.js" }, "./Libraries/*": { - "react-native-strict-api": null, - "types": "./Libraries/*.d.ts", + "react-native-legacy-deep-imports": "./Libraries/*.d.ts", + "types": null, "default": "./Libraries/*.js" }, "./Libraries/*.js": { - "react-native-strict-api": null, + "types": null, "default": "./Libraries/*.js" }, "./scripts/*": "./scripts/*", diff --git a/packages/react-native/types/tsconfig.json b/packages/react-native/types/tsconfig.json deleted file mode 100644 index bb7659500b10..000000000000 --- a/packages/react-native/types/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "lib": ["es6"], - "strict": false, - "noImplicitAny": true, - "noImplicitThis": true, - "strictFunctionTypes": true, - "strictNullChecks": true, - "types": [], - "jsx": "react", - "noEmit": true, - "forceConsistentCasingInFileNames": true, - "paths": {"react-native": ["."]} - }, - "include": ["**/*.d.ts", "../__typetests__/**/*"] -} diff --git a/packages/typescript-config/README.md b/packages/typescript-config/README.md index d58ee3b56455..bf53f0c39a6a 100644 --- a/packages/typescript-config/README.md +++ b/packages/typescript-config/README.md @@ -9,25 +9,18 @@ This package provides the default `tsconfig.json` used by newly built React Nati This template is customized for specific versions of React Native, and should be updated in sync with the rest of your app. -## Strict TypeScript API +## Opting out of the Strict TypeScript API (default in 0.87) -To opt into the new [strict TypeScript API](https://reactnative.dev/blog/2025/06/12/moving-towards-a-stable-javascript-api#strict-typescript-api-opt-in) you can extend from `@react-native/typescript-config/strict` +To opt out of the new [Strict TypeScript API](https://reactnative.dev/docs/strict-typescript-api) you can extend from `@react-native/typescript-config/strict` -```jsonc -{ - "extends": "@react-native/typescript-config/strict", - // ... -} -``` - -or alternatively add the `customConditions` yourself: +If your app still needs access to deep `'react-native/Libraries/*'` imports (deprecated in 0.80), you can opt out via `customConditions` in your `tsconfig.json`: ```jsonc { "extends": "@react-native/typescript-config", "compilerOptions": { // ... - "customConditions": ["react-native-strict-api", "react-native"] + "customConditions": ["react-native", "react-native-legacy-deep-imports"] } } ``` diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index c840653a8850..98c39c04112b 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -16,7 +16,6 @@ ], "bugs": "https://github.com/react/react-native/issues", "exports": { - ".": "./tsconfig.json", - "./strict": "./tsconfig.strict.json" + ".": "./tsconfig.json" } } diff --git a/packages/typescript-config/tsconfig.strict.json b/packages/typescript-config/tsconfig.strict.json deleted file mode 100644 index c060fa2a4191..000000000000 --- a/packages/typescript-config/tsconfig.strict.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "display": "React Native (Strict)", - "compilerOptions": { - "customConditions": ["react-native-strict-api", "react-native"] - } -} diff --git a/packages/virtualized-lists/package.json b/packages/virtualized-lists/package.json index 8291231025c9..b08b6df9dec6 100644 --- a/packages/virtualized-lists/package.json +++ b/packages/virtualized-lists/package.json @@ -21,8 +21,8 @@ }, "exports": { ".": { - "react-native-strict-api": "./types_generated/index.d.ts", - "types": "./index.d.ts", + "react-native-legacy-deep-imports": "./index.d.ts", + "types": "./types_generated/index.d.ts", "default": "./index.js" }, "./*": { diff --git a/scripts/js-api/build-types/buildGeneratedTypes.js b/scripts/js-api/build-types/buildGeneratedTypes.js index 1408b1625b50..3ef71abc2d13 100644 --- a/scripts/js-api/build-types/buildGeneratedTypes.js +++ b/scripts/js-api/build-types/buildGeneratedTypes.js @@ -55,26 +55,10 @@ async function buildGeneratedTypes(): Promise> { } } - await Promise.all([ - fs.copyFile( - path.join(__dirname, 'templates', 'tsconfig.json'), - path.join( - PACKAGES_DIR, - 'react-native', - TYPES_OUTPUT_DIR, - 'tsconfig.json', - ), - ), - fs.copyFile( - path.join(__dirname, 'templates', 'tsconfig.test.json'), - path.join( - PACKAGES_DIR, - 'react-native', - TYPES_OUTPUT_DIR, - 'tsconfig.test.json', - ), - ), - ]); + await fs.copyFile( + path.join(__dirname, 'templates', 'tsconfig.json'), + path.join(PACKAGES_DIR, 'react-native', TYPES_OUTPUT_DIR, 'tsconfig.json'), + ); if (allErrors.length > 0) { console.error( diff --git a/scripts/run-ci-javascript-tests.js b/scripts/run-ci-javascript-tests.js index 7992b46fbcce..aed03f7f812a 100644 --- a/scripts/run-ci-javascript-tests.js +++ b/scripts/run-ci-javascript-tests.js @@ -81,7 +81,7 @@ try { ); describe('Test: TypeScript tests'); - execAndLog(`${YARN_BINARY} run test-typescript`); + execAndLog(`${YARN_BINARY} run test-typescript-legacy`); } catch (e) { if (e instanceof ExecError) { console.error(e.message); From 0ed6c560da56f208bddd604a4881f745bfb15f44 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Fri, 10 Jul 2026 12:27:14 -0700 Subject: [PATCH 038/834] Consolidate package checks into one Jest test (#57509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57509 Simplify/unify various mechanisms for the monorepo's package invariants. These checks were previously scattered: - `private/monorepo-tests` package (manifest field checks) - `.github/workflow-scripts/lint_files.sh` (`.npmignore` ban) - `react-native/eslint-plugin-monorepo` (manifest field checks — duplicated) This folds everything into `scripts/monorepo-tests/__tests__/check-packages-test.js`. A plain Jest test is the most extensible home for future checks. Changelog: [Internal] Reviewed By: cortinico Differential Revision: D111471313 fbshipit-source-id: ea50abd8148d92e70605dd7f809654348e3c0080 --- .eslintrc.js | 6 -- .github/workflow-scripts/lint_files.sh | 12 --- .github/workflows/test-all.yml | 3 - private/eslint-plugin-monorepo/index.js | 1 - .../__tests__/react-native-manifest-test.js | 92 ------------------- .../rules/react-native-manifest.js | 85 ----------------- .../no-dependencies-in-root-package-test.js | 31 ------- ...pendencies-in-react-native-package-test.js | 33 ------- .../no-dev-dependencies-in-tester-package.js | 33 ------- private/monorepo-tests/package.json | 5 - .../__tests__/check-packages-test.js | 42 +++++++++ 11 files changed, 42 insertions(+), 301 deletions(-) delete mode 100755 .github/workflow-scripts/lint_files.sh delete mode 100644 private/eslint-plugin-monorepo/rules/__tests__/react-native-manifest-test.js delete mode 100644 private/eslint-plugin-monorepo/rules/react-native-manifest.js delete mode 100644 private/monorepo-tests/__tests__/no-dependencies-in-root-package-test.js delete mode 100644 private/monorepo-tests/__tests__/no-dev-dependencies-in-react-native-package-test.js delete mode 100644 private/monorepo-tests/__tests__/no-dev-dependencies-in-tester-package.js delete mode 100644 private/monorepo-tests/package.json create mode 100644 scripts/monorepo-tests/__tests__/check-packages-test.js diff --git a/.eslintrc.js b/.eslintrc.js index 4ef0585b7674..f236d21a44f2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -61,12 +61,6 @@ module.exports = { files: ['package.json'], parser: 'jsonc-eslint-parser', }, - { - files: ['package.json'], - rules: { - '@react-native/monorepo/react-native-manifest': 'error', - }, - }, { files: ['flow-typed/**/*.js', 'packages/react-native/flow/**/*'], rules: { diff --git a/.github/workflow-scripts/lint_files.sh b/.github/workflow-scripts/lint_files.sh deleted file mode 100755 index 9563d7dafb57..000000000000 --- a/.github/workflow-scripts/lint_files.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# 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. - -set -e - -if git ls-files | grep -E '\.npmignore$'; then - echo "Error: Found unexpected .npmignore file(s). Please use package.json#files instead." - exit 1 -fi diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 1f49896cbf36..ef4e0a50029f 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -453,9 +453,6 @@ jobs: uses: ./.github/actions/setup-node - name: Install dependencies uses: ./.github/actions/yarn-install - - name: Lint file structure - shell: bash - run: ./.github/workflow-scripts/lint_files.sh - name: Run shellcheck shell: bash run: ./.github/workflow-scripts/analyze_scripts.sh diff --git a/private/eslint-plugin-monorepo/index.js b/private/eslint-plugin-monorepo/index.js index 6b34791bc03b..7ad4b026eb82 100644 --- a/private/eslint-plugin-monorepo/index.js +++ b/private/eslint-plugin-monorepo/index.js @@ -15,7 +15,6 @@ exports.rules = { 'no-react-named-type-imports': require('./rules/no-react-named-type-imports'), 'no-react-native-imports': require('./rules/no-react-native-imports'), 'no-react-node-imports': require('./rules/no-react-node-imports'), - 'react-native-manifest': require('./rules/react-native-manifest'), 'require-extends-error': require('./rules/require-extends-error'), 'sort-imports': require('./rules/sort-imports'), 'valid-flow-typed-signature': require('./rules/valid-flow-typed-signature'), diff --git a/private/eslint-plugin-monorepo/rules/__tests__/react-native-manifest-test.js b/private/eslint-plugin-monorepo/rules/__tests__/react-native-manifest-test.js deleted file mode 100644 index c0ec67da127b..000000000000 --- a/private/eslint-plugin-monorepo/rules/__tests__/react-native-manifest-test.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @noflow - */ - -'use strict'; - -const rule = require('../react-native-manifest.js'); -const {RuleTester} = require('eslint'); - -const ruleTester = new RuleTester({ - parser: require.resolve('jsonc-eslint-parser'), -}); - -ruleTester.run('react-native-manifest', rule, { - valid: [ - { - code: JSON.stringify({ - name: '@react-native/package-name', - }), - }, - { - code: JSON.stringify({ - name: '@react-native/package-name', - dependencies: { - dependencyA: '1.0.0', - }, - devDependencies: { - dependencyB: '1.0.0', - }, - }), - }, - { - code: JSON.stringify({ - name: '@react-native/monorepo', - devDependencies: { - dependencyB: '1.0.0', - }, - }), - }, - { - code: JSON.stringify({ - name: 'react-native', - dependencies: { - dependencyA: '1.0.0', - }, - }), - }, - ], - invalid: [ - { - code: JSON.stringify({ - name: '@react-native/monorepo', - dependencies: { - dependencyA: '1.0.0', - }, - }), - errors: [ - { - messageId: 'propertyDisallowed', - data: { - property: 'dependencies', - describe: - "Declare 'dependencies' in `packages/react-native/package.json`.", - }, - }, - ], - }, - { - code: JSON.stringify({ - name: 'react-native', - devDependencies: { - dependencyA: '1.0.0', - }, - }), - errors: [ - { - messageId: 'propertyDisallowed', - data: { - property: 'devDependencies', - describe: "Declare 'devDependencies' in `/package.json`.", - }, - }, - ], - }, - ], -}); diff --git a/private/eslint-plugin-monorepo/rules/react-native-manifest.js b/private/eslint-plugin-monorepo/rules/react-native-manifest.js deleted file mode 100644 index ff073946ac17..000000000000 --- a/private/eslint-plugin-monorepo/rules/react-native-manifest.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @format - * @noflow - */ - -'use strict'; - -/** - * React Native's monorepo requires that "devDependencies" be declared at the - * root package.json and "dependencies" in the `react-native` package.json to - * permit the ability to segment dependent workspaces into 1) a development - * workspace root (which depends on the monorepo) and 2) a production workspace - * (which depends on the `react-native` package). - */ -const PACKAGE_CONSTRAINTS = { - '@react-native/monorepo': { - disallowed: [ - { - property: 'dependencies', - describe: - "Declare 'dependencies' in `packages/react-native/package.json`.", - }, - ], - }, - 'react-native': { - disallowed: [ - { - property: 'devDependencies', - describe: "Declare 'devDependencies' in `/package.json`.", - }, - ], - }, -}; - -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'Enforce react-native manifest constraints', - }, - messages: { - propertyDisallowed: - "'{{property}}' is disallowed in this file. {{describe}}", - }, - schema: [], - }, - - create(context) { - // @see https://www.npmjs.com/package/jsonc-eslint-parser - if (!context.parserServices.isJSON) { - return {}; - } - return { - 'JSONExpressionStatement > JSONObjectExpression'(node) { - const propertyNodes = {}; - for (const propertyNode of node.properties) { - propertyNodes[propertyNode.key.value] = propertyNode; - } - - const name = propertyNodes.name?.value?.value; - const constraints = PACKAGE_CONSTRAINTS[name]; - if (constraints == null) { - return; - } - - for (const {property, describe} of constraints.disallowed) { - const propertyNode = propertyNodes[property]; - if (propertyNode == null) { - continue; - } - context.report({ - node: propertyNode, - messageId: 'propertyDisallowed', - data: {property, describe}, - }); - } - }, - }; - }, -}; diff --git a/private/monorepo-tests/__tests__/no-dependencies-in-root-package-test.js b/private/monorepo-tests/__tests__/no-dependencies-in-root-package-test.js deleted file mode 100644 index 9bc646c0dbbe..000000000000 --- a/private/monorepo-tests/__tests__/no-dependencies-in-root-package-test.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import fs from 'fs'; -import path from 'path'; - -const CURRENT_DIR = __dirname; -const PATH_TO_ROOT_PACKAGE_MANIFEST = path.join( - CURRENT_DIR, - '..', - '..', - '..', - 'package.json', -); - -const manifest = JSON.parse( - fs.readFileSync(PATH_TO_ROOT_PACKAGE_MANIFEST).toString(), -); - -describe('@react-native/monorepo root package', () => { - it('expected not to list any dependencies', () => { - expect(manifest).not.toHaveProperty('dependencies'); - }); -}); diff --git a/private/monorepo-tests/__tests__/no-dev-dependencies-in-react-native-package-test.js b/private/monorepo-tests/__tests__/no-dev-dependencies-in-react-native-package-test.js deleted file mode 100644 index 0ce252a19469..000000000000 --- a/private/monorepo-tests/__tests__/no-dev-dependencies-in-react-native-package-test.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import fs from 'fs'; -import path from 'path'; - -const CURRENT_DIR = __dirname; -const PATH_TO_REACT_NATIVE_PACKAGE_MANIFEST = path.join( - CURRENT_DIR, - '..', - '..', - '..', - 'packages', - 'react-native', - 'package.json', -); - -const manifest = JSON.parse( - fs.readFileSync(PATH_TO_REACT_NATIVE_PACKAGE_MANIFEST).toString(), -); - -describe('react-native package', () => { - it('expected not to list any devDependencies', () => { - expect(manifest).not.toHaveProperty('devDependencies'); - }); -}); diff --git a/private/monorepo-tests/__tests__/no-dev-dependencies-in-tester-package.js b/private/monorepo-tests/__tests__/no-dev-dependencies-in-tester-package.js deleted file mode 100644 index 3a3e48430c13..000000000000 --- a/private/monorepo-tests/__tests__/no-dev-dependencies-in-tester-package.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import fs from 'fs'; -import path from 'path'; - -const CURRENT_DIR = __dirname; -const PATH_TO_TESTER_PACKAGE_MANIFEST = path.join( - CURRENT_DIR, - '..', - '..', - '..', - 'packages', - 'rn-tester', - 'package.json', -); - -const manifest = JSON.parse( - fs.readFileSync(PATH_TO_TESTER_PACKAGE_MANIFEST).toString(), -); - -describe('@react-native/tester package', () => { - it('expected not to list any devDependencies', () => { - expect(manifest).not.toHaveProperty('devDependencies'); - }); -}); diff --git a/private/monorepo-tests/package.json b/private/monorepo-tests/package.json deleted file mode 100644 index 10725c529541..000000000000 --- a/private/monorepo-tests/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@react-native/monorepo-tests", - "private": true, - "version": "0.0.0" -} diff --git a/scripts/monorepo-tests/__tests__/check-packages-test.js b/scripts/monorepo-tests/__tests__/check-packages-test.js new file mode 100644 index 000000000000..3fc0611203a1 --- /dev/null +++ b/scripts/monorepo-tests/__tests__/check-packages-test.js @@ -0,0 +1,42 @@ +/** + * 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 {REPO_ROOT} from '../../shared/consts'; +import { + getReactNativePackage, + getWorkspaceRoot, +} from '../../shared/monorepoUtils'; +import {globSync} from 'tinyglobby'; + +describe('package manifests', () => { + test('the workspace root must not declare runtime dependencies', async () => { + const {packageJson} = await getWorkspaceRoot(); + expect(packageJson).not.toHaveProperty('dependencies'); + }); + + test('the react-native package must not declare devDependencies', async () => { + const {packageJson} = await getReactNativePackage(); + expect(packageJson).not.toHaveProperty('devDependencies'); + }); +}); + +describe('package file structure', () => { + test('packages must not contain .npmignore files', () => { + // Publishing must be controlled via the package.json "files" field, which is + // easier to audit and does not silently expand what gets shipped to npm. + const npmignoreFiles = globSync('{packages,private}/**/.npmignore', { + cwd: REPO_ROOT, + dot: true, + ignore: ['**/node_modules/**'], + }); + + expect(npmignoreFiles).toEqual([]); + }); +}); From 042833e6995f81abe8edb8f55aac52693e60332c Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Fri, 10 Jul 2026 12:27:14 -0700 Subject: [PATCH 039/834] Extend check-packages-test to validate additional required fields (#57510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57510 Extend `check-packages-test.js` to catch two more classes of manifest drift: published packages missing required fields, and packages under `private/` missing the `private` flag. **Motivation** Inspired by https://github.com/react-native-community/template/pull/241 — avoid a missing field blocking a future RN package publish. **Other changes** - To satisfy the new `files` requirement, add an explicit `files` allowlist to the four config packages that lacked one. As a side effect, this saves some `__tests__` files from being distributed. Changelog: [Internal] Reviewed By: cortinico Differential Revision: D111471314 fbshipit-source-id: e13082d2282128936e22e3a7580b5e434c3b41b2 --- .../eslint-config-react-native/package.json | 1 + .../eslint-plugin-react-native/package.json | 7 +++ packages/eslint-plugin-specs/package.json | 6 +++ packages/typescript-config/package.json | 3 +- .../__tests__/check-packages-test.js | 50 ++++++++++++++++++- scripts/shared/monorepoUtils.js | 7 ++- 6 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/eslint-config-react-native/package.json b/packages/eslint-config-react-native/package.json index 937b51e14be1..99fc3a8d2e9c 100644 --- a/packages/eslint-config-react-native/package.json +++ b/packages/eslint-config-react-native/package.json @@ -23,6 +23,7 @@ ".": "./index.js", "./flat": "./flat.js" }, + "files": ["README.md", "flat.js", "index.js", "shared.js"], "dependencies": { "@babel/core": "^7.25.2", "@babel/eslint-parser": "^7.25.1", diff --git a/packages/eslint-plugin-react-native/package.json b/packages/eslint-plugin-react-native/package.json index c655ccbd8849..d6900b4ff4c8 100644 --- a/packages/eslint-plugin-react-native/package.json +++ b/packages/eslint-plugin-react-native/package.json @@ -17,6 +17,13 @@ ], "bugs": "https://github.com/react/react-native/issues", "main": "index.js", + "files": [ + "README.md", + "index.js", + "no-deep-imports.js", + "platform-colors.js", + "utils.js" + ], "devDependencies": { "babel-plugin-syntax-hermes-parser": "0.36.1", "hermes-eslint": "0.36.1" diff --git a/packages/eslint-plugin-specs/package.json b/packages/eslint-plugin-specs/package.json index 60ad0fb96725..882071d9c484 100644 --- a/packages/eslint-plugin-specs/package.json +++ b/packages/eslint-plugin-specs/package.json @@ -19,6 +19,12 @@ ], "bugs": "https://github.com/react/react-native/issues", "main": "index.js", + "files": [ + "README.md", + "index.js", + "react-native-modules.js", + "with-babel-register" + ], "scripts": { "prepack": "node prepack.js", "postpack": "node postpack.js" diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 98c39c04112b..8d938d94b0e1 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -17,5 +17,6 @@ "bugs": "https://github.com/react/react-native/issues", "exports": { ".": "./tsconfig.json" - } + }, + "files": ["README.md", "tsconfig.json", "tsconfig.strict.json"] } diff --git a/scripts/monorepo-tests/__tests__/check-packages-test.js b/scripts/monorepo-tests/__tests__/check-packages-test.js index 3fc0611203a1..93dd3ba58a58 100644 --- a/scripts/monorepo-tests/__tests__/check-packages-test.js +++ b/scripts/monorepo-tests/__tests__/check-packages-test.js @@ -8,11 +8,13 @@ * @format */ -import {REPO_ROOT} from '../../shared/consts'; +import {PRIVATE_DIR, REPO_ROOT} from '../../shared/consts'; import { + getPackages, getReactNativePackage, getWorkspaceRoot, } from '../../shared/monorepoUtils'; +import path from 'path'; import {globSync} from 'tinyglobby'; describe('package manifests', () => { @@ -25,6 +27,52 @@ describe('package manifests', () => { const {packageJson} = await getReactNativePackage(); expect(packageJson).not.toHaveProperty('devDependencies'); }); + + test('published packages must declare required fields', async () => { + const packages = await getPackages({includeReactNative: true}); + const violations: Array = []; + + for (const name of Object.keys(packages)) { + const {packageJson} = packages[name]; + + if (!packageJson.version) { + violations.push(`${name}: missing "version"`); + } + if (packageJson.license == null || packageJson.license === '') { + violations.push(`${name}: missing "license"`); + } + // "repository" is required for npm's trusted publishing / provenance (OIDC) + if ( + packageJson.repository?.url == null || + packageJson.repository.url === '' + ) { + violations.push(`${name}: missing "repository.url"`); + } + if ( + packageJson.repository?.directory == null || + packageJson.repository.directory === '' + ) { + violations.push(`${name}: missing "repository.directory"`); + } + if (packageJson.files == null || packageJson.files.length === 0) { + violations.push(`${name}: missing "files"`); + } + } + + expect(violations).toEqual([]); + }); + + test('packages under private/ must set "private": true', async () => { + const packages = await getPackages({ + includeReactNative: true, + includePrivate: true, + }); + const notPrivate = Object.keys(packages) + .filter(name => packages[name].path.startsWith(PRIVATE_DIR + path.sep)) + .filter(name => packages[name].packageJson.private !== true); + + expect(notPrivate).toEqual([]); + }); }); describe('package file structure', () => { diff --git a/scripts/shared/monorepoUtils.js b/scripts/shared/monorepoUtils.js index eecb81250a01..0e43fa0f8455 100644 --- a/scripts/shared/monorepoUtils.js +++ b/scripts/shared/monorepoUtils.js @@ -19,11 +19,14 @@ const WORKSPACES_CONFIG = '{packages,private}/*'; export type PackageJson = { name: string, version: string, - private?: boolean, dependencies?: Record, devDependencies?: Record, - peerDependencies?: Record, + files?: ReadonlyArray, + license?: string, main?: string, + peerDependencies?: Record, + private?: boolean, + repository?: {type?: string, url?: string, directory?: string, ...}, ... }; From 1790c61ac39843948959a3cfbfc75dd60be754c9 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Sat, 11 Jul 2026 06:19:22 -0700 Subject: [PATCH 040/834] Fix incorrect asset path in WS test server (#57519) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57519 Changelog: [Internal] ___ Differential Revision: D111561996 fbshipit-source-id: 13188ccad847c095a9ca020e4eca90590204310c --- .../rn-tester/js/examples/WebSocket/websocket_test_server.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/rn-tester/js/examples/WebSocket/websocket_test_server.js b/packages/rn-tester/js/examples/WebSocket/websocket_test_server.js index ce985636cb77..e8a6c13d9799 100755 --- a/packages/rn-tester/js/examples/WebSocket/websocket_test_server.js +++ b/packages/rn-tester/js/examples/WebSocket/websocket_test_server.js @@ -35,7 +35,9 @@ server.on('connection', ws => { message = Buffer.from(message); } if (message === 'getImage') { - message = fs.readFileSync(path.resolve(__dirname, 'flux@3x.png')); + message = fs.readFileSync( + path.resolve(__dirname, '../../assets/flux@3x.png'), + ); } console.log('Replying with:', message); ws.send(message); From b31ba29d2d3d527d7c63fe7c05338ce1d056ba39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nivaldo=20Bondan=C3=A7a?= Date: Sun, 12 Jul 2026 14:57:47 -0700 Subject: [PATCH 041/834] Update ktfmt component on FBS:master Reviewed By: cortinico Differential Revision: D110624565 fbshipit-source-id: c18b2b2fc22a8b060a30ae9bd2bc18af862e08f2 --- build.gradle.kts | 19 +- .../kotlin/com/facebook/react/ReactPlugin.kt | 89 +- .../facebook/react/tasks/BundleHermesCTask.kt | 13 +- .../com/facebook/react/utils/PathUtils.kt | 25 +- .../com/facebook/react/ReactPluginTest.kt | 33 +- .../ModelAutolinkingDependenciesJsonTest.kt | 12 +- ...AutolinkingNewArchitecturesFileTaskTest.kt | 130 ++- .../tasks/GeneratePackageListTaskTest.kt | 73 +- .../utils/PrefabPreprocessingEntryTest.kt | 9 +- .../react/utils/DependencyUtilsTest.kt | 36 +- .../react/popupmenu/PopupMenuPackage.kt | 8 +- .../react/popupmenu/ReactPopupMenuManager.kt | 11 +- .../hermes-engine/build.gradle.kts | 53 +- .../debug/tags/ReactDebugOverlayTags.kt | 11 +- .../react/ReactAndroidHWInputDeviceHelper.kt | 43 +- .../animated/InterpolationAnimatedNode.kt | 19 +- .../bridge/queue/MessageQueueThreadImpl.kt | 23 +- .../queue/ReactQueueConfigurationSpec.kt | 9 +- .../react/defaults/DefaultReactHost.kt | 34 +- .../DefaultTurboModuleManagerDelegate.kt | 11 +- .../devsupport/DebugOverlayController.kt | 9 +- .../DefaultDevLoadingViewImplementation.kt | 11 +- .../react/devsupport/DevServerHelper.kt | 18 +- .../react/devsupport/DevSupportManagerBase.kt | 17 +- .../devsupport/PerftestDevSupportManager.kt | 13 +- .../react/devsupport/RedBoxContentView.kt | 15 +- .../react/devsupport/StackTraceHelper.kt | 17 +- .../fabric/ViewTransitionSnapshotManager.kt | 13 +- .../react/fabric/mounting/MountingManager.kt | 17 +- .../mountitems/FabricNameComponentMapping.kt | 43 +- .../turbomodule/core/TurboModuleManager.kt | 11 +- .../react/jstasks/HeadlessJsTaskContext.kt | 15 +- .../react/modules/dialog/DialogModule.kt | 15 +- .../react/modules/network/NetworkingModule.kt | 53 +- .../react/modules/toast/ToastModule.kt | 15 +- .../react/runtime/CoreReactPackage.kt | 23 +- .../facebook/react/runtime/ReactHostImpl.kt | 32 +- .../facebook/react/runtime/ReactInstance.kt | 9 +- .../react/runtime/internal/bolts/Task.kt | 42 +- .../facebook/react/shell/MainReactPackage.kt | 179 ++-- .../uimanager/BackgroundStyleApplicator.kt | 85 +- .../react/uimanager/JSPointerDispatcher.kt | 37 +- .../react/uimanager/LayoutShadowNode.kt | 17 +- .../uimanager/ReactAccessibilityDelegate.kt | 17 +- .../com/facebook/react/uimanager/Spacing.kt | 29 +- .../react/uimanager/TouchTargetHelper.kt | 39 +- .../react/uimanager/TransformHelper.kt | 11 +- .../uimanager/UIManagerModuleConstants.kt | 120 ++- .../UIManagerModuleConstantsHelper.kt | 24 +- .../com/facebook/react/uimanager/ViewProps.kt | 141 ++- .../uimanager/drawable/BorderDrawable.kt | 47 +- .../drawable/InsetBoxShadowDrawable.kt | 13 +- .../react/uimanager/style/RadialGradient.kt | 13 +- .../views/drawer/ReactDrawerLayoutManager.kt | 9 +- .../react/views/image/ReactImageView.kt | 15 +- .../scroll/ReactScrollViewCommandHelper.kt | 11 +- .../views/scroll/ReactScrollViewManager.kt | 22 +- .../react/views/scroll/ScrollEvent.kt | 27 +- .../react/views/text/ReactTextUpdate.kt | 15 +- .../react/views/text/TextLayoutManager.kt | 254 +++--- .../views/textinput/ReactTextInputManager.kt | 106 ++- .../views/textinput/ReactTextScrollWatcher.kt | 27 +- .../react/views/view/ReactViewManager.kt | 25 +- .../NativeAnimatedInterpolationTest.kt | 9 +- .../facebook/react/bridge/ReactTestHelper.kt | 21 +- ...abricMountingManagerInstrumentationTest.kt | 50 +- .../fabric/events/TouchEventDispatchTest.kt | 860 +++++++++--------- .../modules/model/ReactModuleInfoTest.kt | 17 +- .../modules/network/NetworkingModuleTest.kt | 20 +- .../react/runtime/ReactHostDelegateTest.kt | 13 +- .../react/uimanager/BorderRadiusStyleTest.kt | 196 ++-- .../uimanager/JSPointerDispatcherTest.kt | 11 +- .../react/uimanager/MatrixMathHelperTest.kt | 444 +++++---- .../react/uimanager/style/ColorStopTest.kt | 87 +- .../textinput/ReactTextInputPropertyTest.kt | 31 +- .../facebook/react/uiapp/RNTesterActivity.kt | 11 +- .../react/uiapp/RNTesterApplication.kt | 24 +- .../uiapp/component/MyLegacyViewManager.kt | 11 +- private/react-native-fantom/build.gradle.kts | 35 +- 79 files changed, 2006 insertions(+), 2166 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d23181ed7ce7..a4badbe79e5e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -188,17 +188,16 @@ listOf("ktfmtCheck", "ktfmtFormat").forEach { taskName -> allprojects { // Apply exclusions for specific files that should not be formatted - val excludePatterns = - listOf( - "**/build/**", - "**/hermes-engine/**", - "**/internal/featureflags/**", - "**/systeminfo/ReactNativeVersion.kt", - ) + val excludePatterns = listOf( + "**/build/**", + "**/hermes-engine/**", + "**/internal/featureflags/**", + "**/systeminfo/ReactNativeVersion.kt", + ) listOf( - com.ncorti.ktfmt.gradle.tasks.KtfmtCheckTask::class, - com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask::class, - ) + com.ncorti.ktfmt.gradle.tasks.KtfmtCheckTask::class, + com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask::class, + ) .forEach { tasks.withType(it) { exclude(excludePatterns) } } // Disable the problematic ktfmt script tasks due to symbolic link issues in subprojects diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt index 4af3f14f34ce..0acc972feb4a 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt @@ -200,44 +200,42 @@ class ReactPlugin : Plugin { } // We create the tasks to produce schema from JS files and generate artifacts from schema. - val generateCodegenArtifactsTask = - registerCodegenTasks( - project = project, - rootExtension = rootExtension, - generatedSrcDir = generatedSrcDir, - packageJsonFile = { findPackageJsonFile(project, rootExtension.root) }, - schemaTaskName = "generateCodegenSchemaFromJavaScript", - artifactsTaskName = "generateCodegenArtifactsFromSchema", - configureJsRoot = { task, packageJson -> - // We're reading the package.json at configuration time to properly feed - // the `jsRootDir` @Input property of this task & the onlyIf. Therefore, the - // parsePackageJson should be invoked inside this lambda. - val parsedPackageJson = packageJson?.let { JsonUtils.fromPackageJson(it) } - val jsSrcsDirInPackageJson = parsedPackageJson?.codegenConfig?.jsSrcsDir - - if (packageJson != null && jsSrcsDirInPackageJson != null) { - task.jsRootDir.set(File(packageJson.parentFile, jsSrcsDirInPackageJson)) - } else { - task.jsRootDir.set(localExtension.jsRootDir) - } - }, - configureCodegenArtifacts = { task, _ -> - task.codegenJavaPackageName.set(localExtension.codegenJavaPackageName) - task.libraryName.set(localExtension.libraryName) - }, - onlyIf = { packageJson -> - // Please note that needsCodegenFromPackageJson is triggering a read of the - // package.json at configuration time as we need to feed the onlyIf condition of this - // task. Therefore, needsCodegenFromPackageJson needs to be invoked inside this - // lambda. - val needsCodegenFromPackageJson = - project.needsCodegenFromPackageJson(rootExtension.root) - val parsedPackageJson = packageJson?.let { JsonUtils.fromPackageJson(it) } - val includesGeneratedCode = - parsedPackageJson?.codegenConfig?.includesGeneratedCode ?: false - (isLibrary || needsCodegenFromPackageJson) && !includesGeneratedCode - }, - ) + val generateCodegenArtifactsTask = registerCodegenTasks( + project = project, + rootExtension = rootExtension, + generatedSrcDir = generatedSrcDir, + packageJsonFile = { findPackageJsonFile(project, rootExtension.root) }, + schemaTaskName = "generateCodegenSchemaFromJavaScript", + artifactsTaskName = "generateCodegenArtifactsFromSchema", + configureJsRoot = { task, packageJson -> + // We're reading the package.json at configuration time to properly feed + // the `jsRootDir` @Input property of this task & the onlyIf. Therefore, the + // parsePackageJson should be invoked inside this lambda. + val parsedPackageJson = packageJson?.let { JsonUtils.fromPackageJson(it) } + val jsSrcsDirInPackageJson = parsedPackageJson?.codegenConfig?.jsSrcsDir + + if (packageJson != null && jsSrcsDirInPackageJson != null) { + task.jsRootDir.set(File(packageJson.parentFile, jsSrcsDirInPackageJson)) + } else { + task.jsRootDir.set(localExtension.jsRootDir) + } + }, + configureCodegenArtifacts = { task, _ -> + task.codegenJavaPackageName.set(localExtension.codegenJavaPackageName) + task.libraryName.set(localExtension.libraryName) + }, + onlyIf = { packageJson -> + // Please note that needsCodegenFromPackageJson is triggering a read of the + // package.json at configuration time as we need to feed the onlyIf condition of this + // task. Therefore, needsCodegenFromPackageJson needs to be invoked inside this + // lambda. + val needsCodegenFromPackageJson = project.needsCodegenFromPackageJson(rootExtension.root) + val parsedPackageJson = packageJson?.let { JsonUtils.fromPackageJson(it) } + val includesGeneratedCode = + parsedPackageJson?.codegenConfig?.includesGeneratedCode ?: false + (isLibrary || needsCodegenFromPackageJson) && !includesGeneratedCode + }, + ) // We update the android configuration to include the generated sources. // This is equivalent to this DSL: @@ -355,14 +353,13 @@ class ReactPlugin : Plugin { project.rootProject.layout.buildDirectory.file("generated/autolinking/autolinking.json") val pureCxxDependencies = getPureCxxCodegenDependencies(rootGeneratedAutolinkingFile.get().asFile) - val pureCxxCodegenTasks = - configurePureCxxDependenciesCodegen( - project, - extension, - rootExtension, - generatedPureCxxSourceDir, - pureCxxDependencies, - ) + val pureCxxCodegenTasks = configurePureCxxDependenciesCodegen( + project, + extension, + rootExtension, + generatedPureCxxSourceDir, + pureCxxDependencies, + ) // We add a task called generateAutolinkingPackageList to do not clash with the existing task // called generatePackageList. This can to be renamed once we unlink the rn <-> cli diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/BundleHermesCTask.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/BundleHermesCTask.kt index 89ba3656a3e6..1ed09caa17de 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/BundleHermesCTask.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/tasks/BundleHermesCTask.kt @@ -109,13 +109,12 @@ abstract class BundleHermesCTask : DefaultTask() { val reactNativeDir = reactNativeDir.get().asFile val composeScriptFile = File(reactNativeDir, "scripts/compose-source-maps.js") - val composeSourceMapsCommand = - getComposeSourceMapsCommand( - composeScriptFile, - packagerSourceMap, - compilerSourceMap, - outputSourceMap, - ) + val composeSourceMapsCommand = getComposeSourceMapsCommand( + composeScriptFile, + packagerSourceMap, + compilerSourceMap, + outputSourceMap, + ) runCommand(composeSourceMapsCommand) } } diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PathUtils.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PathUtils.kt index e1acfc8f62d7..7ba0cd1367a4 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PathUtils.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PathUtils.kt @@ -25,12 +25,14 @@ import org.gradle.api.file.DirectoryProperty * * @param config The [ReactExtension] configured for this project */ -internal fun detectedEntryFile(config: ReactExtension, envVariableOverride: String? = null): File = - detectEntryFile( - entryFile = config.entryFile.orNull?.asFile, - reactRoot = config.root.get().asFile, - envVariableOverride = envVariableOverride, - ) +internal fun detectedEntryFile( + config: ReactExtension, + envVariableOverride: String? = null, +): File = detectEntryFile( + entryFile = config.entryFile.orNull?.asFile, + reactRoot = config.root.get().asFile, + envVariableOverride = envVariableOverride, +) /** * Computes the CLI file for React Native. The Algo follows this order: @@ -39,12 +41,11 @@ internal fun detectedEntryFile(config: ReactExtension, envVariableOverride: Stri * 3. The `node_modules/react-native/cli.js` file if exists * 4. Fails otherwise */ -internal fun detectedCliFile(config: ReactExtension): File = - detectCliFile( - project = config.project, - reactNativeRoot = config.root.get().asFile, - preconfiguredCliFile = config.cliFile.asFile.orNull, - ) +internal fun detectedCliFile(config: ReactExtension): File = detectCliFile( + project = config.project, + reactNativeRoot = config.root.get().asFile, + preconfiguredCliFile = config.cliFile.asFile.orNull, +) /** * Computes the `hermesc` command location. The Algo follows this order: diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/ReactPluginTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/ReactPluginTest.kt index f19b140f4425..5465988e59d3 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/ReactPluginTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/ReactPluginTest.kt @@ -127,12 +127,11 @@ class ReactPluginTest { @Test fun taskNameSuffixForDependency_withNonAlphanumericCharacters_encodesThem() { - val dependency = - ModelAutolinkingDependenciesJson( - root = "./node_modules/@foo/bar-baz", - name = "@foo/bar-baz", - platforms = null, - ) + val dependency = ModelAutolinkingDependenciesJson( + root = "./node_modules/@foo/bar-baz", + name = "@foo/bar-baz", + platforms = null, + ) val result = ReactPlugin().taskNameSuffixForDependency(dependency) @@ -144,12 +143,11 @@ class ReactPluginTest { val plugin = ReactPlugin() val suffixes = listOf("@foo/bar", "foo.bar", "foo-bar", "foo_bar", "foo_45_bar").map { name -> - val dependency = - ModelAutolinkingDependenciesJson( - root = "./node_modules/$name", - name = name, - platforms = null, - ) + val dependency = ModelAutolinkingDependenciesJson( + root = "./node_modules/$name", + name = name, + platforms = null, + ) plugin.taskNameSuffixForDependency(dependency) } @@ -159,12 +157,11 @@ class ReactPluginTest { @Test fun taskNameSuffixForDependency_withLocalModuleRoot_usesPackageName() { - val dependency = - ModelAutolinkingDependenciesJson( - root = "./modules/local-module", - name = "local-module", - platforms = null, - ) + val dependency = ModelAutolinkingDependenciesJson( + root = "./modules/local-module", + name = "local-module", + platforms = null, + ) val result = ReactPlugin().taskNameSuffixForDependency(dependency) diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/model/ModelAutolinkingDependenciesJsonTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/model/ModelAutolinkingDependenciesJsonTest.kt index 53b32aaa6b89..676d9dfee3fc 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/model/ModelAutolinkingDependenciesJsonTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/model/ModelAutolinkingDependenciesJsonTest.kt @@ -36,13 +36,13 @@ class ModelAutolinkingDependenciesJsonTest { assertThat(ModelAutolinkingDependenciesJson("", "@react-native/package", null).nameCleansed) .isEqualTo("react-native_package") assertThat( - ModelAutolinkingDependenciesJson( - "", - "@this*is~a(more)complicated/example!of~weird)packages", - null, - ) - .nameCleansed + ModelAutolinkingDependenciesJson( + "", + "@this*is~a(more)complicated/example!of~weird)packages", + null, ) + .nameCleansed + ) .isEqualTo("this_is_a_more_complicated_example_of_weird_packages") } } diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GenerateAutolinkingNewArchitecturesFileTaskTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GenerateAutolinkingNewArchitecturesFileTaskTest.kt index df92f572e330..a2766d9c76c0 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GenerateAutolinkingNewArchitecturesFileTaskTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GenerateAutolinkingNewArchitecturesFileTaskTest.kt @@ -85,13 +85,12 @@ class GenerateAutolinkingNewArchitecturesFileTaskTest { @Test fun filterAndroidPackages_withValidAndroidObject_returnsIt() { val task = createTestTask() - val android = - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory/android", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - ) + val android = ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + ) val result = task.filterAndroidPackages( @@ -116,16 +115,15 @@ class GenerateAutolinkingNewArchitecturesFileTaskTest { @Test fun cmakeListsPathForDependency_withCmakeListsPath_returnsIt() { val task = createTestTask() - val dependency = - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - libraryName = "aPackage", - cmakeListsPath = "./a/directory/CMakeLists.txt", - isPureCxxDependency = true, - ) + val dependency = ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + libraryName = "aPackage", + cmakeListsPath = "./a/directory/CMakeLists.txt", + isPureCxxDependency = true, + ) assertThat(task.cmakeListsPathForDependency(dependency)) .isEqualTo("./a/directory/CMakeLists.txt") @@ -138,15 +136,14 @@ class GenerateAutolinkingNewArchitecturesFileTaskTest { createTestTask { it.generatedPureCxxSourceDirectory.set(generatedPureCxxSourceDirectory) } - val dependency = - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - libraryName = "aPackage", - isPureCxxDependency = true, - ) + val dependency = ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + libraryName = "aPackage", + isPureCxxDependency = true, + ) assertThat(task.cmakeListsPathForDependency(dependency)) .isEqualTo( @@ -157,15 +154,14 @@ class GenerateAutolinkingNewArchitecturesFileTaskTest { @Test fun cmakeListsPathForDependency_withMissingGeneratedDirectory_returnsNull() { val task = createTestTask() - val dependency = - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - libraryName = "aPackage", - isPureCxxDependency = true, - ) + val dependency = ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + libraryName = "aPackage", + isPureCxxDependency = true, + ) assertThat(task.cmakeListsPathForDependency(dependency)).isNull() } @@ -177,15 +173,14 @@ class GenerateAutolinkingNewArchitecturesFileTaskTest { createTestTask { it.generatedPureCxxSourceDirectory.set(generatedPureCxxSourceDirectory) } - val dependency = - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - libraryName = "aPackage", - isPureCxxDependency = false, - ) + val dependency = ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + libraryName = "aPackage", + isPureCxxDependency = false, + ) assertThat(task.cmakeListsPathForDependency(dependency)).isNull() } @@ -503,28 +498,27 @@ class GenerateAutolinkingNewArchitecturesFileTaskTest { assertThat(sanitizeCmakeListsPath(input)).isEqualTo("./a/dir\\ ectory/") } - private val testDependencies = - listOf( - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - libraryName = "aPackage", - componentDescriptors = emptyList(), - cmakeListsPath = "./a/directory/CMakeLists.txt", - ), - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./another/directory", - packageImportPath = "import com.facebook.react.anotherPackage;", - packageInstance = "new AnotherPackage()", - buildTypes = emptyList(), - libraryName = "anotherPackage", - componentDescriptors = listOf("AnotherPackageComponentDescriptor"), - cmakeListsPath = "./another/directory/with spaces/CMakeLists.txt", - cxxModuleCMakeListsPath = "./another/directory/cxx/CMakeLists.txt", - cxxModuleHeaderName = "AnotherCxxModule", - cxxModuleCMakeListsModuleName = "another_cxxModule", - ), - ) + private val testDependencies = listOf( + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + libraryName = "aPackage", + componentDescriptors = emptyList(), + cmakeListsPath = "./a/directory/CMakeLists.txt", + ), + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./another/directory", + packageImportPath = "import com.facebook.react.anotherPackage;", + packageInstance = "new AnotherPackage()", + buildTypes = emptyList(), + libraryName = "anotherPackage", + componentDescriptors = listOf("AnotherPackageComponentDescriptor"), + cmakeListsPath = "./another/directory/with spaces/CMakeLists.txt", + cxxModuleCMakeListsPath = "./another/directory/cxx/CMakeLists.txt", + cxxModuleHeaderName = "AnotherCxxModule", + cxxModuleCMakeListsModuleName = "another_cxxModule", + ), + ) } diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt index f8e455356a5a..d66f80afecc6 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/GeneratePackageListTaskTest.kt @@ -151,13 +151,12 @@ class GeneratePackageListTaskTest { @Test fun filterAndroidPackages_withValidAndroidObject_returnsIt() { val task = createTestTask() - val android = - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory/android", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - ) + val android = ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + ) val result = task.filterAndroidPackages( @@ -183,14 +182,13 @@ class GeneratePackageListTaskTest { @Test fun filterAndroidPackages_withIsPureCxxDependencyObject_returnsIt() { val task = createTestTask() - val android = - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory/android", - packageImportPath = "import com.facebook.react.aPackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - isPureCxxDependency = true, - ) + val android = ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory/android", + packageImportPath = "import com.facebook.react.aPackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + isPureCxxDependency = true, + ) val result = task.filterAndroidPackages( @@ -366,27 +364,26 @@ class GeneratePackageListTaskTest { ) } - private val testDependencies = - mapOf( - "@react-native/a-package" to - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./a/directory", - packageImportPath = "import com.facebook.react.APackage;", - packageInstance = "new APackage()", - buildTypes = emptyList(), - libraryName = "aPackage", - componentDescriptors = emptyList(), - cmakeListsPath = "./a/directory/CMakeLists.txt", - ), - "@react-native/another-package" to - ModelAutolinkingDependenciesPlatformAndroidJson( - sourceDir = "./another/directory", - packageImportPath = "import com.facebook.react.AnotherPackage;", - packageInstance = "new AnotherPackage()", - buildTypes = emptyList(), - libraryName = "anotherPackage", - componentDescriptors = emptyList(), - cmakeListsPath = "./another/directory/CMakeLists.txt", - ), - ) + private val testDependencies = mapOf( + "@react-native/a-package" to + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./a/directory", + packageImportPath = "import com.facebook.react.APackage;", + packageInstance = "new APackage()", + buildTypes = emptyList(), + libraryName = "aPackage", + componentDescriptors = emptyList(), + cmakeListsPath = "./a/directory/CMakeLists.txt", + ), + "@react-native/another-package" to + ModelAutolinkingDependenciesPlatformAndroidJson( + sourceDir = "./another/directory", + packageImportPath = "import com.facebook.react.AnotherPackage;", + packageInstance = "new AnotherPackage()", + buildTypes = emptyList(), + libraryName = "anotherPackage", + componentDescriptors = emptyList(), + cmakeListsPath = "./another/directory/CMakeLists.txt", + ), + ) } diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/internal/utils/PrefabPreprocessingEntryTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/internal/utils/PrefabPreprocessingEntryTest.kt index b83c26d46875..dbff6173649e 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/internal/utils/PrefabPreprocessingEntryTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/tasks/internal/utils/PrefabPreprocessingEntryTest.kt @@ -14,11 +14,10 @@ class PrefabPreprocessingEntryTest { @Test fun secondaryConstructor_createsAList() { - val sampleEntry = - PrefabPreprocessingEntry( - libraryName = "justALibrary", - pathToPrefixCouple = "aPath" to "andAPrefix", - ) + val sampleEntry = PrefabPreprocessingEntry( + libraryName = "justALibrary", + pathToPrefixCouple = "aPath" to "andAPrefix", + ) assertThat(sampleEntry.pathToPrefixCouples.size).isEqualTo(1) assertThat(sampleEntry.pathToPrefixCouples[0].first).isEqualTo("aPath") diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/DependencyUtilsTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/DependencyUtilsTest.kt index 5c57bb296fa4..9e76531687fa 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/DependencyUtilsTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/DependencyUtilsTest.kt @@ -689,14 +689,13 @@ class DependencyUtilsTest { @Test fun isNightly_returnsTrue_forValidNightlyVersions() { - val trueCases = - listOf( - "0.85.0-nightly-20260128-36f07a1b2", - "0.82.0-nightly-date-commit", - "0.0.0-20230505-2109-9b69263a1", - "0.0.0-date-commit", - "0.0.0-nightly-", - ) + val trueCases = listOf( + "0.85.0-nightly-20260128-36f07a1b2", + "0.82.0-nightly-date-commit", + "0.0.0-20230505-2109-9b69263a1", + "0.0.0-date-commit", + "0.0.0-nightly-", + ) trueCases.forEach { version -> assert(version.isNightly()) { "Expected '$version' to be detected as nightly" } @@ -705,17 +704,16 @@ class DependencyUtilsTest { @Test fun isNightly_returnsFalse_forNonNightlyVersions() { - val falseCases = - listOf( - "0.83.0", // Standard version - "0.0.1", - "nightly", // Missing hyphens - "0.83.0-nightly", // Missing trailing hyphen - "any-nightly", // Missing trailing hyphen - "nightly-build", // Missing leading hyphen - "", // Empty string - " ", // Blank string - ) + val falseCases = listOf( + "0.83.0", // Standard version + "0.0.1", + "nightly", // Missing hyphens + "0.83.0-nightly", // Missing trailing hyphen + "any-nightly", // Missing trailing hyphen + "nightly-build", // Missing leading hyphen + "", // Empty string + " ", // Blank string + ) falseCases.forEach { version -> assert(!version.isNightly()) { "Expected '$version' to NOT be detected as nightly" } diff --git a/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/PopupMenuPackage.kt b/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/PopupMenuPackage.kt index 199713731adf..93a4bfc50a9f 100644 --- a/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/PopupMenuPackage.kt +++ b/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/PopupMenuPackage.kt @@ -18,11 +18,9 @@ import com.facebook.react.uimanager.ViewManager @ReactModuleList(nativeModules = arrayOf()) public class PopupMenuPackage() : BaseReactPackage(), ViewManagerOnDemandReactPackage { - private val viewManagersMap: Map = - mapOf( - ReactPopupMenuManager.REACT_CLASS to - ModuleSpec.viewManagerSpec({ ReactPopupMenuManager() }), - ) + private val viewManagersMap: Map = mapOf( + ReactPopupMenuManager.REACT_CLASS to ModuleSpec.viewManagerSpec({ ReactPopupMenuManager() }), + ) override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { return null diff --git a/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/ReactPopupMenuManager.kt b/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/ReactPopupMenuManager.kt index 17b3376d558d..756bfa702ab4 100644 --- a/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/ReactPopupMenuManager.kt +++ b/packages/react-native-popup-menu-android/android/src/main/java/com/facebook/react/popupmenu/ReactPopupMenuManager.kt @@ -53,11 +53,10 @@ public class ReactPopupMenuManager : public companion object { public const val REACT_CLASS: String = "AndroidPopupMenu" private const val REGISTRATION_NAME = "registrationName" - private val DIRECT_EVENT_TYPE_CONSTANT = - mapOf( - PopupMenuSelectionEvent.EVENT_NAME to - mapOf(REGISTRATION_NAME to "onPopupMenuSelectionChange"), - PopupMenuDismissEvent.EVENT_NAME to mapOf(REGISTRATION_NAME to "onPopupMenuDismiss"), - ) + private val DIRECT_EVENT_TYPE_CONSTANT = mapOf( + PopupMenuSelectionEvent.EVENT_NAME to + mapOf(REGISTRATION_NAME to "onPopupMenuSelectionChange"), + PopupMenuDismissEvent.EVENT_NAME to mapOf(REGISTRATION_NAME to "onPopupMenuDismiss"), + ) } } diff --git a/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts b/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts index ffcec5a9e4c2..777f69ece661 100644 --- a/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts +++ b/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts @@ -80,11 +80,10 @@ val hermesBuildOutputFileTree = val hermesVersionProvider: Provider = providers.provider { var hermesVersion = "250829098.0.0-stable" - val hermesVersionFile = - File( - reactNativeRootDir, - "sdks/.hermesv1version", - ) + val hermesVersionFile = File( + reactNativeRootDir, + "sdks/.hermesv1version", + ) if (hermesVersionFile.exists()) { hermesVersion = hermesVersionFile.readText() @@ -150,19 +149,18 @@ fun configureBuildForHermesCommandLineArgs( jsiDir: File, enableDebugger: Boolean, ): List { - var cmakeCommandLine = - windowsAwareCommandLine( - cmakeBinaryPath, - // Suppress all warnings as this is the Hermes build and we can't fix them. - "--log-level=ERROR", - "-Wno-dev", - "-S", - ".", - "-B", - hermesBuildDir.toString(), - "-DJSI_DIR=" + jsiDir.absolutePath, - "-DCMAKE_BUILD_TYPE=Release", - ) + var cmakeCommandLine = windowsAwareCommandLine( + cmakeBinaryPath, + // Suppress all warnings as this is the Hermes build and we can't fix them. + "--log-level=ERROR", + "-Wno-dev", + "-S", + ".", + "-B", + hermesBuildDir.toString(), + "-DJSI_DIR=" + jsiDir.absolutePath, + "-DCMAKE_BUILD_TYPE=Release", + ) if (enableDebugger) { cmakeCommandLine = cmakeCommandLine + "-DHERMES_ENABLE_DEBUGGER=True" } @@ -174,16 +172,15 @@ fun configureBuildForHermesCommandLineArgs( return cmakeCommandLine } -fun buildHermesCCommandLineArgs() = - listOf( - cmakeBinaryPath, - "--build", - hermesBuildDir.toString(), - "--target", - "hermesc", - "-j", - ndkBuildJobs, - ) +fun buildHermesCCommandLineArgs() = listOf( + cmakeBinaryPath, + "--build", + hermesBuildDir.toString(), + "--target", + "hermesc", + "-j", + ndkBuildJobs, +) val configureBuildForHermes by tasks.registering(CustomExecTask::class) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/debug/tags/ReactDebugOverlayTags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/debug/tags/ReactDebugOverlayTags.kt index faf64f2a64fe..40089646df04 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/debug/tags/ReactDebugOverlayTags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/debug/tags/ReactDebugOverlayTags.kt @@ -28,10 +28,9 @@ internal object ReactDebugOverlayTags { val NATIVE_MODULE: DebugOverlayTag = DebugOverlayTag("Native Module", "Native Module init", Color.rgb(0x80, 0x00, 0x80)) @JvmField - val UI_MANAGER: DebugOverlayTag = - DebugOverlayTag( - "UI Manager", - "UI Manager View Operations (requires restart\nwarning: this is spammy)", - Color.CYAN, - ) + val UI_MANAGER: DebugOverlayTag = DebugOverlayTag( + "UI Manager", + "UI Manager View Operations (requires restart\nwarning: this is spammy)", + Color.CYAN, + ) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactAndroidHWInputDeviceHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactAndroidHWInputDeviceHelper.kt index 42e01e8304c2..22388c9fbef4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactAndroidHWInputDeviceHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactAndroidHWInputDeviceHelper.kt @@ -75,27 +75,26 @@ internal class ReactAndroidHWInputDeviceHelper { * Contains a mapping between handled KeyEvents and the corresponding navigation event that * should be fired when the KeyEvent is received. */ - private val KEY_EVENTS_ACTIONS: Map = - mapOf( - KeyEvent.KEYCODE_DPAD_CENTER to "select", - KeyEvent.KEYCODE_ENTER to "select", - KeyEvent.KEYCODE_SPACE to "select", - KeyEvent.KEYCODE_MEDIA_PLAY to "play", - KeyEvent.KEYCODE_MEDIA_PAUSE to "pause", - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE to "playPause", - KeyEvent.KEYCODE_MEDIA_REWIND to "rewind", - KeyEvent.KEYCODE_MEDIA_FAST_FORWARD to "fastForward", - KeyEvent.KEYCODE_MEDIA_STOP to "stop", - KeyEvent.KEYCODE_MEDIA_NEXT to "next", - KeyEvent.KEYCODE_MEDIA_PREVIOUS to "previous", - KeyEvent.KEYCODE_DPAD_UP to "up", - KeyEvent.KEYCODE_DPAD_RIGHT to "right", - KeyEvent.KEYCODE_DPAD_DOWN to "down", - KeyEvent.KEYCODE_DPAD_LEFT to "left", - KeyEvent.KEYCODE_INFO to "info", - KeyEvent.KEYCODE_MENU to "menu", - KeyEvent.KEYCODE_CHANNEL_UP to "channelUp", - KeyEvent.KEYCODE_CHANNEL_DOWN to "channelDown", - ) + private val KEY_EVENTS_ACTIONS: Map = mapOf( + KeyEvent.KEYCODE_DPAD_CENTER to "select", + KeyEvent.KEYCODE_ENTER to "select", + KeyEvent.KEYCODE_SPACE to "select", + KeyEvent.KEYCODE_MEDIA_PLAY to "play", + KeyEvent.KEYCODE_MEDIA_PAUSE to "pause", + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE to "playPause", + KeyEvent.KEYCODE_MEDIA_REWIND to "rewind", + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD to "fastForward", + KeyEvent.KEYCODE_MEDIA_STOP to "stop", + KeyEvent.KEYCODE_MEDIA_NEXT to "next", + KeyEvent.KEYCODE_MEDIA_PREVIOUS to "previous", + KeyEvent.KEYCODE_DPAD_UP to "up", + KeyEvent.KEYCODE_DPAD_RIGHT to "right", + KeyEvent.KEYCODE_DPAD_DOWN to "down", + KeyEvent.KEYCODE_DPAD_LEFT to "left", + KeyEvent.KEYCODE_INFO to "info", + KeyEvent.KEYCODE_MENU to "menu", + KeyEvent.KEYCODE_CHANNEL_UP to "channelUp", + KeyEvent.KEYCODE_CHANNEL_DOWN to "channelDown", + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.kt index 236bf26239e8..aeb74a2767e1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/InterpolationAnimatedNode.kt @@ -260,16 +260,15 @@ internal class InterpolationAnimatedNode(config: ReadableMap) : ValueAnimatedNod val m = numericPattern.matcher(pattern) var i = 0 while (m.find() && i < outputRange[rangeIndex].size) { - val v = - interpolate( - value, - inputRange[rangeIndex], - inputRange[rangeIndex + 1], - outputRange[rangeIndex][i], - outputRange[rangeIndex + 1][i], - extrapolateLeft, - extrapolateRight, - ) + val v = interpolate( + value, + inputRange[rangeIndex], + inputRange[rangeIndex + 1], + outputRange[rangeIndex][i], + outputRange[rangeIndex + 1][i], + extrapolateLeft, + extrapolateRight, + ) val intVal = v.toInt() m.appendReplacement(sb, if (intVal.toDouble() != v) v.toString() else intVal.toString()) i++ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/MessageQueueThreadImpl.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/MessageQueueThreadImpl.kt index dbc15af9927b..4e29037207a5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/MessageQueueThreadImpl.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/MessageQueueThreadImpl.kt @@ -142,18 +142,17 @@ private constructor( exceptionHandler: QueueThreadExceptionHandler, ): MessageQueueThreadImpl { val looperFuture = SimpleSettableFuture() - val bgThread = - Thread( - null, - { - Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY) - Looper.prepare() - looperFuture.set(Looper.myLooper()) - Looper.loop() - }, - "mqt_$name", - stackSize, - ) + val bgThread = Thread( + null, + { + Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY) + Looper.prepare() + looperFuture.set(Looper.myLooper()) + Looper.loop() + }, + "mqt_$name", + stackSize, + ) bgThread.start() val looper = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/ReactQueueConfigurationSpec.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/ReactQueueConfigurationSpec.kt index 310ec3249ef0..1d6f3e7cac22 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/ReactQueueConfigurationSpec.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/queue/ReactQueueConfigurationSpec.kt @@ -44,10 +44,9 @@ public constructor( public fun builder(): Builder = Builder() @JvmStatic - public fun createDefault(): ReactQueueConfigurationSpec = - ReactQueueConfigurationSpec( - newBackgroundThreadSpec("native_modules"), - newBackgroundThreadSpec("js"), - ) + public fun createDefault(): ReactQueueConfigurationSpec = ReactQueueConfigurationSpec( + newBackgroundThreadSpec("native_modules"), + newBackgroundThreadSpec("js"), + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultReactHost.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultReactHost.kt index a2b934f830f3..8a01895c6cf0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultReactHost.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultReactHost.kt @@ -87,27 +87,25 @@ public object DefaultReactHost { } val defaultTmmDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder() cxxReactPackageProviders.forEach { defaultTmmDelegateBuilder.addCxxReactPackage(it) } - val defaultReactHostDelegate = - DefaultReactHostDelegate( - jsMainModulePath = jsMainModulePath, - jsBundleLoader = bundleLoader, - reactPackages = packageList, - jsRuntimeFactory = jsRuntimeFactory ?: HermesInstance(), - bindingsInstaller = bindingsInstaller, - turboModuleManagerDelegateBuilder = defaultTmmDelegateBuilder, - exceptionHandler = exceptionHandler, - ) + val defaultReactHostDelegate = DefaultReactHostDelegate( + jsMainModulePath = jsMainModulePath, + jsBundleLoader = bundleLoader, + reactPackages = packageList, + jsRuntimeFactory = jsRuntimeFactory ?: HermesInstance(), + bindingsInstaller = bindingsInstaller, + turboModuleManagerDelegateBuilder = defaultTmmDelegateBuilder, + exceptionHandler = exceptionHandler, + ) val componentFactory = ComponentFactory() DefaultComponentsRegistry.register(componentFactory) // TODO: T164788699 find alternative of accessing ReactHostImpl for initialising reactHost - val newReactHost = - ReactHostImpl( - context, - defaultReactHostDelegate, - componentFactory, - true /* allowPackagerServerAccess */, - useDevSupport, - ) + val newReactHost = ReactHostImpl( + context, + defaultReactHostDelegate, + componentFactory, + true /* allowPackagerServerAccess */, + useDevSupport, + ) reactHost = newReactHost return newReactHost } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultTurboModuleManagerDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultTurboModuleManagerDelegate.kt index becc5987e786..3674eccfd30c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultTurboModuleManagerDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/defaults/DefaultTurboModuleManagerDelegate.kt @@ -59,12 +59,11 @@ private constructor( override fun build( context: ReactApplicationContext, packages: List, - ): DefaultTurboModuleManagerDelegate = - DefaultTurboModuleManagerDelegate( - context, - packages, - cxxReactPackageProviders.flatMap { provider -> provider(context) }, - ) + ): DefaultTurboModuleManagerDelegate = DefaultTurboModuleManagerDelegate( + context, + packages, + cxxReactPackageProviders.flatMap { provider -> provider(context) }, + ) } private companion object { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DebugOverlayController.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DebugOverlayController.kt index 79f13c0e66ef..6947cb064f09 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DebugOverlayController.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DebugOverlayController.kt @@ -61,11 +61,10 @@ internal class DebugOverlayController(private val reactContext: ReactContext) { fun requestPermission(context: Context) { // Get permission to show debug overlay in dev builds. if (!Settings.canDrawOverlays(context)) { - val intent = - Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:" + context.packageName), - ) + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + context.packageName), + ) intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) FLog.w( ReactConstants.TAG, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt index ae2753ef44cd..d8bb1dc5355c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt @@ -140,12 +140,11 @@ public class DefaultDevLoadingViewImplementation( // Allow tapping anywhere on the banner to dismiss rootView.setOnClickListener { hideInternal() } - val popup = - PopupWindow( - rootView, - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + val popup = PopupWindow( + rootView, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) popup.showAtLocation(currentActivity.window.decorView, Gravity.NO_GRAVITY, 0, topOffset) devLoadingView = textView // Store the TextView for updateProgress() devLoadingPopup = popup diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt index 127b6a8dbeee..bae2ace113f2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt @@ -176,11 +176,11 @@ public open class DevServerHelper( checkNotNull(clientId) packagerClient = JSPackagerClient( - clientId, - packagerConnectionSettings, - handlers, - onPackagerConnectedCallback, - ) + clientId, + packagerConnectionSettings, + handlers, + onPackagerConnectedCallback, + ) .apply { init() } return null @@ -217,10 +217,10 @@ public open class DevServerHelper( } inspectorPackagerConnection = CxxInspectorPackagerConnection( - this@DevServerHelper.inspectorDeviceUrl, - deviceName, - packageName, - ) + this@DevServerHelper.inspectorDeviceUrl, + deviceName, + packageName, + ) .apply { connect() } return null } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt index 0608c07b78b5..1e18107468c3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt @@ -107,15 +107,14 @@ public abstract class DevSupportManagerBase( public final override var currentReactContext: ReactContext? = null private set - public final override val devSettings: DeveloperSettings = - DevInternalSettings( - applicationContext, - object : DevInternalSettings.Listener { - override fun onInternalSettingsChanged() { - this@DevSupportManagerBase.reloadSettings() - } - }, - ) + public final override val devSettings: DeveloperSettings = DevInternalSettings( + applicationContext, + object : DevInternalSettings.Listener { + override fun onInternalSettingsChanged() { + this@DevSupportManagerBase.reloadSettings() + } + }, + ) override val currentActivity: Activity? get() = reactInstanceDevHelper.currentActivity diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PerftestDevSupportManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PerftestDevSupportManager.kt index f0cd50b3cda9..b9f75d1f1ddf 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PerftestDevSupportManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PerftestDevSupportManager.kt @@ -18,13 +18,12 @@ internal class PerftestDevSupportManager( applicationContext: Context, ) : ReleaseDevSupportManager() { - override val devSettings: DeveloperSettings = - DevInternalSettings( - applicationContext, - object : DevInternalSettings.Listener { - override fun onInternalSettingsChanged() = Unit - }, - ) + override val devSettings: DeveloperSettings = DevInternalSettings( + applicationContext, + object : DevInternalSettings.Listener { + override fun onInternalSettingsChanged() = Unit + }, + ) private val devServerHelper: DevServerHelper = DevServerHelper(devSettings, applicationContext, devSettings.packagerConnectionSettings) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/RedBoxContentView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/RedBoxContentView.kt index 3d7b7626165a..3868216247a2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/RedBoxContentView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/RedBoxContentView.kt @@ -181,15 +181,14 @@ internal class RedBoxContentView( companion object { private val JSON: MediaType? = MediaType.parse("application/json; charset=utf-8") - private fun stackFrameToJson(frame: StackFrame) = - JSONObject( - mapOf( - "file" to frame.file, - "methodName" to frame.method, - "lineNumber" to frame.line, - "column" to frame.column, - ) + private fun stackFrameToJson(frame: StackFrame) = JSONObject( + mapOf( + "file" to frame.file, + "methodName" to frame.method, + "lineNumber" to frame.line, + "column" to frame.column, ) + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.kt index 77c40e2e07b4..6b8c5daaa2c8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/StackTraceHelper.kt @@ -248,15 +248,14 @@ public object StackTraceHelper { ) : StackFrame { /** Convert the stack frame to a JSON representation. */ - override fun toJSON(): JSONObject = - JSONObject( - mapOf( - FILE_KEY to (file.orEmpty()), - METHOD_NAME_KEY to method, - LINE_NUMBER_KEY to line, - COLUMN_KEY to column, - COLLAPSE_KEY to isCollapsed, - ) + override fun toJSON(): JSONObject = JSONObject( + mapOf( + FILE_KEY to (file.orEmpty()), + METHOD_NAME_KEY to method, + LINE_NUMBER_KEY to line, + COLUMN_KEY to column, + COLLAPSE_KEY to isCollapsed, ) + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt index 4e62a4318166..edb39de7bdd3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt @@ -129,13 +129,12 @@ internal class ViewTransitionSnapshotManager( // and the partial result gets stretched to fill the pseudo-element. val windowWidth = window.decorView.width val windowHeight = window.decorView.height - val clampedRect = - Rect( - viewRect.left.coerceAtLeast(0), - viewRect.top.coerceAtLeast(0), - viewRect.right.coerceAtMost(windowWidth), - viewRect.bottom.coerceAtMost(windowHeight), - ) + val clampedRect = Rect( + viewRect.left.coerceAtLeast(0), + viewRect.top.coerceAtLeast(0), + viewRect.right.coerceAtMost(windowWidth), + viewRect.bottom.coerceAtMost(windowHeight), + ) if (clampedRect.isEmpty) { // Entirely off-screen — nothing to capture. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt index 48410a98298a..8a06e0d1a67f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountingManager.kt @@ -64,15 +64,14 @@ internal class MountingManager( reactContext: ThemedReactContext?, rootView: View?, ): SurfaceMountingManager { - val surfaceMountingManager = - SurfaceMountingManager( - surfaceId, - jsResponderHandler, - viewManagerRegistry, - rootViewManager, - mountItemExecutor, - checkNotNull(reactContext), - ) + val surfaceMountingManager = SurfaceMountingManager( + surfaceId, + jsResponderHandler, + viewManagerRegistry, + rootViewManager, + mountItemExecutor, + checkNotNull(reactContext), + ) // There could technically be a race condition here if addRootView is called twice from // different threads, though this is (probably) extremely unlikely, and likely an error. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt index 31d93c32b695..8721e35b753d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/FabricNameComponentMapping.kt @@ -9,28 +9,27 @@ package com.facebook.react.fabric.mounting.mountitems /** Utility class for Fabric components, this will be removed */ internal object FabricNameComponentMapping { - private val componentNames: Map = - mapOf( - // TODO T97384889: unify component names between JS - Android - iOS - C++ - "View" to "RCTView", - "Image" to "RCTImageView", - "ScrollView" to "RCTScrollView", - "Slider" to "RCTSlider", - "ModalHostView" to "RCTModalHostView", - "Paragraph" to "RCTText", - "SelectableParagraph" to "RCTSelectableText", - "Text" to "RCTText", - "RawText" to "RCTRawText", - "ActivityIndicatorView" to "AndroidProgressBar", - "ShimmeringView" to "RKShimmeringView", - "TemplateView" to "RCTTemplateView", - "AxialGradientView" to "RCTAxialGradientView", - "Video" to "RCTVideo", - "Map" to "RCTMap", - "WebView" to "RCTWebView", - "Keyframes" to "RCTKeyframes", - "ImpressionTrackingView" to "RCTImpressionTrackingView", - ) + private val componentNames: Map = mapOf( + // TODO T97384889: unify component names between JS - Android - iOS - C++ + "View" to "RCTView", + "Image" to "RCTImageView", + "ScrollView" to "RCTScrollView", + "Slider" to "RCTSlider", + "ModalHostView" to "RCTModalHostView", + "Paragraph" to "RCTText", + "SelectableParagraph" to "RCTSelectableText", + "Text" to "RCTText", + "RawText" to "RCTRawText", + "ActivityIndicatorView" to "AndroidProgressBar", + "ShimmeringView" to "RKShimmeringView", + "TemplateView" to "RCTTemplateView", + "AxialGradientView" to "RCTAxialGradientView", + "Video" to "RCTVideo", + "Map" to "RCTMap", + "WebView" to "RCTWebView", + "Keyframes" to "RCTKeyframes", + "ImpressionTrackingView" to "RCTImpressionTrackingView", + ) /** @return the name of component in the Fabric environment */ @JvmStatic diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/turbomodule/core/TurboModuleManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/turbomodule/core/TurboModuleManager.kt index 5a55e7c80506..f83c72602413 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/turbomodule/core/TurboModuleManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/turbomodule/core/TurboModuleManager.kt @@ -50,12 +50,11 @@ public class TurboModuleManager( @Suppress("NoHungarianNotation") @DoNotStrip - private val mHybridData: HybridData = - initHybrid( - jsCallInvokerHolder as CallInvokerHolderImpl, - nativeMethodCallInvokerHolder as NativeMethodCallInvokerHolderImpl, - delegate, - ) + private val mHybridData: HybridData = initHybrid( + jsCallInvokerHolder as CallInvokerHolderImpl, + nativeMethodCallInvokerHolder as NativeMethodCallInvokerHolderImpl, + delegate, + ) init { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.kt index faad9b4df7c5..989001814554 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.kt @@ -117,14 +117,13 @@ public class HeadlessJsTaskContext private constructor(reactContext: ReactContex } removeTimeout(taskId) - val taskConfig = - HeadlessJsTaskConfig( - sourceTaskConfig.taskKey, - sourceTaskConfig.data, - sourceTaskConfig.timeout, - sourceTaskConfig.isAllowedInForeground, - retryPolicy.update(), - ) + val taskConfig = HeadlessJsTaskConfig( + sourceTaskConfig.taskKey, + sourceTaskConfig.data, + sourceTaskConfig.timeout, + sourceTaskConfig.isAllowedInForeground, + retryPolicy.update(), + ) val retryAttempt = Runnable { startTask(taskConfig, taskId) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/DialogModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/DialogModule.kt index 984bc8aaa702..4a78c7b33dd9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/DialogModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/dialog/DialogModule.kt @@ -191,13 +191,12 @@ public class DialogModule(reactContext: ReactApplicationContext?) : private const val KEY_ITEMS: String = "items" private const val KEY_CANCELABLE: String = "cancelable" - private val CONSTANTS: Map = - mapOf( - ACTION_BUTTON_CLICKED to ACTION_BUTTON_CLICKED, - ACTION_DISMISSED to ACTION_DISMISSED, - KEY_BUTTON_POSITIVE to DialogInterface.BUTTON_POSITIVE, - KEY_BUTTON_NEGATIVE to DialogInterface.BUTTON_NEGATIVE, - KEY_BUTTON_NEUTRAL to DialogInterface.BUTTON_NEUTRAL, - ) + private val CONSTANTS: Map = mapOf( + ACTION_BUTTON_CLICKED to ACTION_BUTTON_CLICKED, + ACTION_DISMISSED to ACTION_DISMISSED, + KEY_BUTTON_POSITIVE to DialogInterface.BUTTON_POSITIVE, + KEY_BUTTON_NEGATIVE to DialogInterface.BUTTON_NEGATIVE, + KEY_BUTTON_NEUTRAL to DialogInterface.BUTTON_NEUTRAL, + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt index c7f5b85b8221..c1b330157b26 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt @@ -378,33 +378,32 @@ public class NetworkingModule( clientBuilder.addNetworkInterceptor { chain -> val originalResponse = chain.proceed(chain.request()) val originalResponseBody = checkNotNull(originalResponse.body()) - val responseBody = - ProgressResponseBody( - originalResponseBody, - object : ProgressListener { - var last: Long = System.nanoTime() - - override fun onProgress(bytesWritten: Long, contentLength: Long, done: Boolean) { - val now = System.nanoTime() - if (!done && !shouldDispatch(now, last)) { - return - } - if (responseType == "text") { - // For 'text' responses we continuously send response data with progress - // info to - // JS below, so no need to do anything here. - return - } - NetworkEventUtil.onDataReceivedProgress( - reactApplicationContext, - requestId, - bytesWritten, - contentLength, - ) - last = now - } - }, - ) + val responseBody = ProgressResponseBody( + originalResponseBody, + object : ProgressListener { + var last: Long = System.nanoTime() + + override fun onProgress(bytesWritten: Long, contentLength: Long, done: Boolean) { + val now = System.nanoTime() + if (!done && !shouldDispatch(now, last)) { + return + } + if (responseType == "text") { + // For 'text' responses we continuously send response data with progress + // info to + // JS below, so no need to do anything here. + return + } + NetworkEventUtil.onDataReceivedProgress( + reactApplicationContext, + requestId, + bytesWritten, + contentLength, + ) + last = now + } + }, + ) originalResponse.newBuilder().body(responseBody).build() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/toast/ToastModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/toast/ToastModule.kt index eeffccfab380..2ea8110568ba 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/toast/ToastModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/toast/ToastModule.kt @@ -20,14 +20,13 @@ import com.facebook.react.module.annotations.ReactModule internal class ToastModule(reactContext: ReactApplicationContext) : NativeToastAndroidSpec(reactContext) { - override fun getTypedExportedConstants(): Map = - mutableMapOf( - DURATION_SHORT_KEY to Toast.LENGTH_SHORT, - DURATION_LONG_KEY to Toast.LENGTH_LONG, - GRAVITY_TOP_KEY to (Gravity.TOP or Gravity.CENTER_HORIZONTAL), - GRAVITY_BOTTOM_KEY to (Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL), - GRAVITY_CENTER to (Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL), - ) + override fun getTypedExportedConstants(): Map = mutableMapOf( + DURATION_SHORT_KEY to Toast.LENGTH_SHORT, + DURATION_LONG_KEY to Toast.LENGTH_LONG, + GRAVITY_TOP_KEY to (Gravity.TOP or Gravity.CENTER_HORIZONTAL), + GRAVITY_BOTTOM_KEY to (Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL), + GRAVITY_CENTER to (Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL), + ) override fun show(message: String?, durationDouble: Double) { val duration = durationDouble.toInt() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/CoreReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/CoreReactPackage.kt index 560281dc501f..18d72c158860 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/CoreReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/CoreReactPackage.kt @@ -89,18 +89,17 @@ internal class CoreReactPackage( private fun fallbackForMissingClass(): ReactModuleInfoProvider { // In OSS case, the annotation processor does not run. We fall back on creating this byhand - val moduleList: Array> = - arrayOf>( - AndroidInfoModule::class.java, - DeviceInfoModule::class.java, - SourceCodeModule::class.java, - DevMenuModule::class.java, - DevSettingsModule::class.java, - DeviceEventManagerModule::class.java, - LogBoxModule::class.java, - ExceptionsManagerModule::class.java, - HeadlessJsTaskSupportModule::class.java, - ) + val moduleList: Array> = arrayOf>( + AndroidInfoModule::class.java, + DeviceInfoModule::class.java, + SourceCodeModule::class.java, + DevMenuModule::class.java, + DevSettingsModule::class.java, + DeviceEventManagerModule::class.java, + LogBoxModule::class.java, + ExceptionsManagerModule::class.java, + HeadlessJsTaskSupportModule::class.java, + ) val reactModuleInfoMap: MutableMap = HashMap() for (moduleClass in moduleList) { val reactModule = moduleClass.getAnnotation(ReactModule::class.java) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt index 56ffb4092686..aa303d00ff09 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt @@ -1033,16 +1033,15 @@ public class ReactHostImpl( reactContext.jsExceptionHandler = devSupportManager stateTracker.enterState(method, "Creating ReactInstance") - val instance = - ReactInstance( - reactContext, - reactHostDelegate, - componentFactory, - devSupportManager, - { e: Exception -> this.handleHostException(e) }, - useDevSupport, - getOrCreateReactHostInspectorTarget(), - ) + val instance = ReactInstance( + reactContext, + reactHostDelegate, + componentFactory, + devSupportManager, + { e: Exception -> this.handleHostException(e) }, + useDevSupport, + getOrCreateReactHostInspectorTarget(), + ) reactInstance = instance val memoryPressureListener = createMemoryPressureListener(instance) @@ -1602,13 +1601,12 @@ public class ReactHostImpl( TracingState.ENABLED_IN_BACKGROUND_MODE, TracingState.ENABLED_IN_CDP_MODE -> { if (InspectorFlags.getFrameRecordingEnabled()) { - val observer = - FrameTimingsObserver( - _screenshotsEnabled, - { frameTimingsSequence -> - inspectorTarget.recordFrameTimings(frameTimingsSequence) - }, - ) + val observer = FrameTimingsObserver( + _screenshotsEnabled, + { frameTimingsSequence -> + inspectorTarget.recordFrameTimings(frameTimingsSequence) + }, + ) observer.setCurrentWindow(currentActivity?.window) observer.start() frameTimingsObserver = observer diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactInstance.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactInstance.kt index 63b6802ad3db..e6160b2eba2f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactInstance.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactInstance.kt @@ -119,11 +119,10 @@ internal class ReactInstance( * Prepare the ReactInstance by installing JSI bindings, initializing Fabric + TurboModules, and * loading the JS bundle. */ - val spec = - ReactQueueConfigurationSpec( - MessageQueueThreadSpec.newBackgroundThreadSpec("v_native"), - MessageQueueThreadSpec.newBackgroundThreadSpec("v_js"), - ) + val spec = ReactQueueConfigurationSpec( + MessageQueueThreadSpec.newBackgroundThreadSpec("v_native"), + MessageQueueThreadSpec.newBackgroundThreadSpec("v_js"), + ) reactQueueConfiguration = ReactQueueConfigurationImpl.create(spec, exceptionHandler) FLog.d(TAG, "Calling initializeMessageQueueThreads()") context.initializeMessageQueueThreads(reactQueueConfiguration) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/internal/bolts/Task.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/internal/bolts/Task.kt index 4341d3577def..afc24484c100 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/internal/bolts/Task.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/internal/bolts/Task.kt @@ -167,17 +167,16 @@ public class Task : TaskInterface { public fun onSuccess( continuation: Continuation, executor: Executor = IMMEDIATE_EXECUTOR, - ): Task = - continueWithTask( - { task -> - when { - task.isCancelled() -> cancelled() - task.isFaulted() -> forError(task.getError()) - else -> task.continueWith(continuation) - } - }, - executor, - ) + ): Task = continueWithTask( + { task -> + when { + task.isCancelled() -> cancelled() + task.isFaulted() -> forError(task.getError()) + else -> task.continueWith(continuation) + } + }, + executor, + ) /** * Runs a continuation when a task completes successfully, forwarding along [java.lang.Exception]s @@ -186,17 +185,16 @@ public class Task : TaskInterface { public fun onSuccessTask( continuation: Continuation>, executor: Executor = IMMEDIATE_EXECUTOR, - ): Task = - continueWithTask( - { task -> - when { - task.isCancelled() -> cancelled() - task.isFaulted() -> forError(task.getError()) - else -> task.continueWithTask(continuation) - } - }, - executor, - ) + ): Task = continueWithTask( + { task -> + when { + task.isCancelled() -> cancelled() + task.isFaulted() -> forError(task.getError()) + else -> task.continueWithTask(continuation) + } + }, + executor, + ) private fun runContinuations() = synchronized(lock) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt index be24b4d62097..16b6e5c56ad0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt @@ -137,28 +137,29 @@ constructor(private val config: MainPackageConfig? = null) : } @Suppress("DEPRECATION") - override fun createViewManagers(reactContext: ReactApplicationContext): List> = - listOf( - ReactDrawerLayoutManager(), - ReactHorizontalScrollViewManager(), - ReactHorizontalScrollContainerViewManager(), - ReactProgressBarViewManager(), - if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) ReactNestedScrollViewManager() - else ReactScrollViewManager(), - ReactSwitchManager(), - ReactSafeAreaViewManager(), - SwipeRefreshLayoutManager(), - // Native equivalents - ReactImageManager(), - ReactModalHostManager(), - ReactTextInputManager(), - if (ReactNativeFeatureFlags.enablePreparedTextLayout()) PreparedLayoutTextViewManager() - else ReactTextViewManager(), - SelectableTextViewManager(), - ReactViewManager(), - ReactVirtualViewManager(), - ReactUnimplementedViewManager(), - ) + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List> = listOf( + ReactDrawerLayoutManager(), + ReactHorizontalScrollViewManager(), + ReactHorizontalScrollContainerViewManager(), + ReactProgressBarViewManager(), + if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) ReactNestedScrollViewManager() + else ReactScrollViewManager(), + ReactSwitchManager(), + ReactSafeAreaViewManager(), + SwipeRefreshLayoutManager(), + // Native equivalents + ReactImageManager(), + ReactModalHostManager(), + ReactTextInputManager(), + if (ReactNativeFeatureFlags.enablePreparedTextLayout()) PreparedLayoutTextViewManager() + else ReactTextViewManager(), + SelectableTextViewManager(), + ReactViewManager(), + ReactVirtualViewManager(), + ReactUnimplementedViewManager(), + ) /** * A map of view managers that should be registered with @@ -166,46 +167,41 @@ constructor(private val config: MainPackageConfig? = null) : */ @Suppress("DEPRECATION") @SuppressLint("VisibleForTests") - public val viewManagersMap: Map = - mapOf( - ReactDrawerLayoutManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactDrawerLayoutManager() }, - ReactHorizontalScrollViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactHorizontalScrollViewManager() }, - ReactHorizontalScrollContainerViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactHorizontalScrollContainerViewManager() }, - ReactProgressBarViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactProgressBarViewManager() }, - ReactSafeAreaViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactSafeAreaViewManager() }, - ReactScrollViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { - if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) - ReactNestedScrollViewManager() - else ReactScrollViewManager() - }, - ReactSwitchManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactSwitchManager() }, - SwipeRefreshLayoutManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { SwipeRefreshLayoutManager() }, - ReactImageManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactImageManager() }, - ReactModalHostManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactModalHostManager() }, - ReactTextInputManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactTextInputManager() }, - ReactTextViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { - if (ReactNativeFeatureFlags.enablePreparedTextLayout()) - PreparedLayoutTextViewManager() - else ReactTextViewManager() - }, - SelectableTextViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { SelectableTextViewManager() }, - ReactViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactViewManager() }, - ReactVirtualViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactVirtualViewManager() }, - ReactUnimplementedViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactUnimplementedViewManager() }, - ) + public val viewManagersMap: Map = mapOf( + ReactDrawerLayoutManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactDrawerLayoutManager() }, + ReactHorizontalScrollViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactHorizontalScrollViewManager() }, + ReactHorizontalScrollContainerViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactHorizontalScrollContainerViewManager() }, + ReactProgressBarViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactProgressBarViewManager() }, + ReactSafeAreaViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactSafeAreaViewManager() }, + ReactScrollViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { + if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) ReactNestedScrollViewManager() + else ReactScrollViewManager() + }, + ReactSwitchManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactSwitchManager() }, + SwipeRefreshLayoutManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { SwipeRefreshLayoutManager() }, + ReactImageManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactImageManager() }, + ReactModalHostManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactModalHostManager() }, + ReactTextInputManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactTextInputManager() }, + ReactTextViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { + if (ReactNativeFeatureFlags.enablePreparedTextLayout()) PreparedLayoutTextViewManager() + else ReactTextViewManager() + }, + SelectableTextViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { SelectableTextViewManager() }, + ReactViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactViewManager() }, + ReactVirtualViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactVirtualViewManager() }, + ReactUnimplementedViewManager.REACT_CLASS to + ModuleSpec.viewManagerSpec { ReactUnimplementedViewManager() }, + ) public override fun getViewManagers(reactContext: ReactApplicationContext): List = viewManagersMap.values.toList() @@ -251,36 +247,35 @@ constructor(private val config: MainPackageConfig? = null) : private fun fallbackForMissingClass(): ReactModuleInfoProvider { // In the OSS case, the annotation processor does not run. // We fall back to creating this by hand - val moduleList: Array> = - arrayOf( - AccessibilityInfoModule::class.java, - AppearanceModule::class.java, - AppStateModule::class.java, - BlobModule::class.java, - DevLoadingModule::class.java, - FileReaderModule::class.java, - ClipboardModule::class.java, - DialogModule::class.java, - FrescoModule::class.java, - I18nManagerModule::class.java, - ImageLoaderModule::class.java, - ImageStoreManager::class.java, - IntentModule::class.java, - if (ReactNativeFeatureFlags.cxxNativeAnimatedEnabled()) null - else NativeAnimatedModule::class.java, - NetworkingModule::class.java, - PermissionsModule::class.java, - ReactDevToolsSettingsManagerModule::class.java, - ReactDevToolsRuntimeSettingsModule::class.java, - ShareModule::class.java, - StatusBarModule::class.java, - SoundManagerModule::class.java, - ToastModule::class.java, - VibrationModule::class.java, - WebSocketModule::class.java, - ) - .filterNotNull() - .toTypedArray() + val moduleList: Array> = arrayOf( + AccessibilityInfoModule::class.java, + AppearanceModule::class.java, + AppStateModule::class.java, + BlobModule::class.java, + DevLoadingModule::class.java, + FileReaderModule::class.java, + ClipboardModule::class.java, + DialogModule::class.java, + FrescoModule::class.java, + I18nManagerModule::class.java, + ImageLoaderModule::class.java, + ImageStoreManager::class.java, + IntentModule::class.java, + if (ReactNativeFeatureFlags.cxxNativeAnimatedEnabled()) null + else NativeAnimatedModule::class.java, + NetworkingModule::class.java, + PermissionsModule::class.java, + ReactDevToolsSettingsManagerModule::class.java, + ReactDevToolsRuntimeSettingsModule::class.java, + ShareModule::class.java, + StatusBarModule::class.java, + SoundManagerModule::class.java, + ToastModule::class.java, + VibrationModule::class.java, + WebSocketModule::class.java, + ) + .filterNotNull() + .toTypedArray() val moduleMap = moduleList diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index 5582ad102d84..1b8f221c1037 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -535,13 +535,12 @@ public object BackgroundStyleApplicator { paddingBoxRect.bottom = composite.bounds.bottom - (computedBorderInsets?.bottom?.dpToPx() ?: 0f) if (composite.borderRadius?.hasRoundedBorders() == true) { - val paddingBoxPath = - createPaddingBoxPath( - view, - composite, - paddingBoxRect, - computedBorderInsets, - ) + val paddingBoxPath = createPaddingBoxPath( + view, + composite, + paddingBoxRect, + computedBorderInsets, + ) paddingBoxPath.offset(drawingRect.left.toFloat(), drawingRect.top.toFloat()) // On Android 28 and below, use antialiased clipping with Porter-Duff compositing. On newer @@ -764,46 +763,38 @@ public object BackgroundStyleApplicator { val paddingBoxPath = Path() - val innerTopLeftRadiusX = - getInnerBorderRadius( - computedBorderRadius?.topLeft?.horizontal?.dpToPx(), - computedBorderInsets?.left?.dpToPx(), - ) - val innerTopLeftRadiusY = - getInnerBorderRadius( - computedBorderRadius?.topLeft?.vertical?.dpToPx(), - computedBorderInsets?.top?.dpToPx(), - ) - val innerTopRightRadiusX = - getInnerBorderRadius( - computedBorderRadius?.topRight?.horizontal?.dpToPx(), - computedBorderInsets?.right?.dpToPx(), - ) - val innerTopRightRadiusY = - getInnerBorderRadius( - computedBorderRadius?.topRight?.vertical?.dpToPx(), - computedBorderInsets?.top?.dpToPx(), - ) - val innerBottomRightRadiusX = - getInnerBorderRadius( - computedBorderRadius?.bottomRight?.horizontal?.dpToPx(), - computedBorderInsets?.right?.dpToPx(), - ) - val innerBottomRightRadiusY = - getInnerBorderRadius( - computedBorderRadius?.bottomRight?.vertical?.dpToPx(), - computedBorderInsets?.bottom?.dpToPx(), - ) - val innerBottomLeftRadiusX = - getInnerBorderRadius( - computedBorderRadius?.bottomLeft?.horizontal?.dpToPx(), - computedBorderInsets?.left?.dpToPx(), - ) - val innerBottomLeftRadiusY = - getInnerBorderRadius( - computedBorderRadius?.bottomLeft?.vertical?.dpToPx(), - computedBorderInsets?.bottom?.dpToPx(), - ) + val innerTopLeftRadiusX = getInnerBorderRadius( + computedBorderRadius?.topLeft?.horizontal?.dpToPx(), + computedBorderInsets?.left?.dpToPx(), + ) + val innerTopLeftRadiusY = getInnerBorderRadius( + computedBorderRadius?.topLeft?.vertical?.dpToPx(), + computedBorderInsets?.top?.dpToPx(), + ) + val innerTopRightRadiusX = getInnerBorderRadius( + computedBorderRadius?.topRight?.horizontal?.dpToPx(), + computedBorderInsets?.right?.dpToPx(), + ) + val innerTopRightRadiusY = getInnerBorderRadius( + computedBorderRadius?.topRight?.vertical?.dpToPx(), + computedBorderInsets?.top?.dpToPx(), + ) + val innerBottomRightRadiusX = getInnerBorderRadius( + computedBorderRadius?.bottomRight?.horizontal?.dpToPx(), + computedBorderInsets?.right?.dpToPx(), + ) + val innerBottomRightRadiusY = getInnerBorderRadius( + computedBorderRadius?.bottomRight?.vertical?.dpToPx(), + computedBorderInsets?.bottom?.dpToPx(), + ) + val innerBottomLeftRadiusX = getInnerBorderRadius( + computedBorderRadius?.bottomLeft?.horizontal?.dpToPx(), + computedBorderInsets?.left?.dpToPx(), + ) + val innerBottomLeftRadiusY = getInnerBorderRadius( + computedBorderRadius?.bottomLeft?.vertical?.dpToPx(), + computedBorderInsets?.bottom?.dpToPx(), + ) paddingBoxPath.addRoundRect( paddingBoxRect, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.kt index 2f7c853f602b..8d8574e324f1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSPointerDispatcher.kt @@ -334,12 +334,11 @@ public class JSPointerDispatcher(private val rootViewGroup: ViewGroup) { // https://suragch.medium.com/how-touch-events-are-delivered-in-android-eee3b607b038 val isExitFromRoot = isCapture && action == MotionEvent.ACTION_HOVER_EXIT - val eventState = - createEventState( - activePointerId, - motionEvent, - clearHitPathForActivePointer = isExitFromRoot, - ) + val eventState = createEventState( + activePointerId, + motionEvent, + clearHitPathForActivePointer = isExitFromRoot, + ) // Calculate the targetTag, with special handling for when we exit the root view. In that case, // we use the root viewId of the last event @@ -497,13 +496,12 @@ public class JSPointerDispatcher(private val rootViewGroup: ViewGroup) { } // target -> root - val leaveViewTargets = - filterByShouldDispatch( - lastHitPath.subList(0, lastHitPath.size - firstDivergentIndexFromBack), - EVENT.LEAVE, - EVENT.LEAVE_CAPTURE, - nonDivergentListeningToLeave, - ) + val leaveViewTargets = filterByShouldDispatch( + lastHitPath.subList(0, lastHitPath.size - firstDivergentIndexFromBack), + EVENT.LEAVE, + EVENT.LEAVE_CAPTURE, + nonDivergentListeningToLeave, + ) if (leaveViewTargets.isNotEmpty()) { // We want to dispatch from target -> root, so no need to reverse dispatchEventForViewTargets( @@ -525,13 +523,12 @@ public class JSPointerDispatcher(private val rootViewGroup: ViewGroup) { } // target -> root - val enterViewTargets = - filterByShouldDispatch( - activeHitPath.subList(0, activeHitPath.size - firstDivergentIndexFromBack), - EVENT.ENTER, - EVENT.ENTER_CAPTURE, - nonDivergentListeningToEnter, - ) + val enterViewTargets = filterByShouldDispatch( + activeHitPath.subList(0, activeHitPath.size - firstDivergentIndexFromBack), + EVENT.ENTER, + EVENT.ENTER_CAPTURE, + nonDivergentListeningToEnter, + ) if (enterViewTargets.isNotEmpty()) { // We want to iterate these from root -> target so we need to reverse diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LayoutShadowNode.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LayoutShadowNode.kt index cf67f6beb0c7..6adaf26effc0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LayoutShadowNode.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/LayoutShadowNode.kt @@ -664,15 +664,14 @@ public open class LayoutShadowNode : ReactShadowNodeImpl() { return } - val positionSpacingTypes = - intArrayOf( - Spacing.START, - Spacing.END, - Spacing.LEFT, - Spacing.RIGHT, - Spacing.TOP, - Spacing.BOTTOM, - ) + val positionSpacingTypes = intArrayOf( + Spacing.START, + Spacing.END, + Spacing.LEFT, + Spacing.RIGHT, + Spacing.TOP, + Spacing.BOTTOM, + ) val spacingType = maybeTransformLeftRightToStartEnd(positionSpacingTypes[index]) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt index 3c1cbe917d11..4f50c5e000f4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt @@ -560,15 +560,14 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac public companion object { public const val TOP_ACCESSIBILITY_ACTION_EVENT: String = "topAccessibilityAction" - private val actionIdMap = - mapOf( - "activate" to AccessibilityActionCompat.ACTION_CLICK.id, - "longpress" to AccessibilityActionCompat.ACTION_LONG_CLICK.id, - "increment" to AccessibilityActionCompat.ACTION_SCROLL_FORWARD.id, - "decrement" to AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.id, - "expand" to AccessibilityActionCompat.ACTION_EXPAND.id, - "collapse" to AccessibilityActionCompat.ACTION_COLLAPSE.id, - ) + private val actionIdMap = mapOf( + "activate" to AccessibilityActionCompat.ACTION_CLICK.id, + "longpress" to AccessibilityActionCompat.ACTION_LONG_CLICK.id, + "increment" to AccessibilityActionCompat.ACTION_SCROLL_FORWARD.id, + "decrement" to AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.id, + "expand" to AccessibilityActionCompat.ACTION_EXPAND.id, + "collapse" to AccessibilityActionCompat.ACTION_COLLAPSE.id, + ) private const val TAG = "ReactAccessibilityDelegate" private var customActionCounter = 0x3f000000 diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/Spacing.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/Spacing.kt index 2439018b76b1..b3b82ecca055 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/Spacing.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/Spacing.kt @@ -169,21 +169,20 @@ public class Spacing(private val defaultValue: Float, private val spacing: Float /** Spacing type that represents the block start direction (top). E.g. `marginBlockStart`. */ public const val BLOCK_START: Int = 11 - private val flagsMap = - intArrayOf( - 1, /*LEFT*/ - 2, /*TOP*/ - 4, /*RIGHT*/ - 8, /*BOTTOM*/ - 16, /*START*/ - 32, /*END*/ - 64, /*HORIZONTAL*/ - 128, /*VERTICAL*/ - 256, /*ALL*/ - 512, /*BLOCK*/ - 1024, /*BLOCK_END*/ - 2048, - ) + private val flagsMap = intArrayOf( + 1, /*LEFT*/ + 2, /*TOP*/ + 4, /*RIGHT*/ + 8, /*BOTTOM*/ + 16, /*START*/ + 32, /*END*/ + 64, /*HORIZONTAL*/ + 128, /*VERTICAL*/ + 256, /*ALL*/ + 512, /*BLOCK*/ + 1024, /*BLOCK_END*/ + 2048, + ) private fun newFullSpacingArray(): FloatArray { return floatArrayOf( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt index b38d417f330e..3debb1cb10a7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt @@ -355,25 +355,23 @@ public object TouchTargetHelper { } PointerEvents.BOX_ONLY -> { // This view may be the target, its children don't matter - val targetView = - findTouchTargetView( - eventCoords, - view, - EnumSet.of(TouchTargetReturnType.SELF), - pathAccumulator, - ) + val targetView = findTouchTargetView( + eventCoords, + view, + EnumSet.of(TouchTargetReturnType.SELF), + pathAccumulator, + ) targetView?.let { pathAccumulator?.add(ViewTarget(view.id, view)) } targetView } PointerEvents.BOX_NONE -> { // This view can't be the target, but its children might. - val targetView = - findTouchTargetView( - eventCoords, - view, - EnumSet.of(TouchTargetReturnType.CHILD), - pathAccumulator, - ) + val targetView = findTouchTargetView( + eventCoords, + view, + EnumSet.of(TouchTargetReturnType.CHILD), + pathAccumulator, + ) if (targetView != null) { pathAccumulator?.add(ViewTarget(view.id, view)) @@ -412,13 +410,12 @@ public object TouchTargetHelper { return view } - val result = - findTouchTargetView( - eventCoords, - view, - EnumSet.of(TouchTargetReturnType.SELF, TouchTargetReturnType.CHILD), - pathAccumulator, - ) + val result = findTouchTargetView( + eventCoords, + view, + EnumSet.of(TouchTargetReturnType.SELF, TouchTargetReturnType.CHILD), + pathAccumulator, + ) result?.let { pathAccumulator?.add(ViewTarget(view.id, view)) } result } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt index 513989a14e94..6c6dcdfef735 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TransformHelper.kt @@ -83,12 +83,11 @@ public object TransformHelper { val helperMatrix = helperMatrix.get()!! MatrixMathHelper.resetIdentityMatrix(result) - val offsets = - getTranslateForTransformOrigin( - viewWidth, - viewHeight, - transformOrigin, - ) + val offsets = getTranslateForTransformOrigin( + viewWidth, + viewHeight, + transformOrigin, + ) if (offsets != null) { MatrixMathHelper.resetIdentityMatrix(helperMatrix) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.kt index 5967be095a70..082b1d567915 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstants.kt @@ -14,39 +14,38 @@ import com.facebook.react.uimanager.events.TouchEventType /** Constants exposed to JS from [UIManagerModule]. */ internal object UIManagerModuleConstants { @JvmField - val bubblingEventTypeConstants: Map = - mapOf( - "topChange" to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onChange", "captured" to "onChangeCapture") - ), - "topSelect" to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onSelect", "captured" to "onSelectCapture") - ), - TouchEventType.getJSEventName(TouchEventType.START) to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onTouchStart", "captured" to "onTouchStartCapture") - ), - TouchEventType.getJSEventName(TouchEventType.MOVE) to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onTouchMove", "captured" to "onTouchMoveCapture") - ), - TouchEventType.getJSEventName(TouchEventType.END) to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onTouchEnd", "captured" to "onTouchEndCapture") - ), - TouchEventType.getJSEventName(TouchEventType.CANCEL) to - mapOf( - "phasedRegistrationNames" to - mapOf("bubbled" to "onTouchCancel", "captured" to "onTouchCancelCapture") - ), - ) + val bubblingEventTypeConstants: Map = mapOf( + "topChange" to + mapOf( + "phasedRegistrationNames" to + mapOf("bubbled" to "onChange", "captured" to "onChangeCapture") + ), + "topSelect" to + mapOf( + "phasedRegistrationNames" to + mapOf("bubbled" to "onSelect", "captured" to "onSelectCapture") + ), + TouchEventType.getJSEventName(TouchEventType.START) to + mapOf( + "phasedRegistrationNames" to + mapOf("bubbled" to "onTouchStart", "captured" to "onTouchStartCapture") + ), + TouchEventType.getJSEventName(TouchEventType.MOVE) to + mapOf( + "phasedRegistrationNames" to + mapOf("bubbled" to "onTouchMove", "captured" to "onTouchMoveCapture") + ), + TouchEventType.getJSEventName(TouchEventType.END) to + mapOf( + "phasedRegistrationNames" to + mapOf("bubbled" to "onTouchEnd", "captured" to "onTouchEndCapture") + ), + TouchEventType.getJSEventName(TouchEventType.CANCEL) to + mapOf( + "phasedRegistrationNames" to + mapOf("bubbled" to "onTouchCancel", "captured" to "onTouchCancelCapture") + ), + ) @JvmField val directEventTypeConstants: Map = run { @@ -71,32 +70,31 @@ internal object UIManagerModuleConstants { } @JvmField - val constants: Map = - mapOf( - "UIView" to - mapOf( - "ContentMode" to - mapOf( - "ScaleAspectFit" to ImageView.ScaleType.FIT_CENTER.ordinal, - "ScaleAspectFill" to ImageView.ScaleType.CENTER_CROP.ordinal, - "ScaleAspectCenter" to ImageView.ScaleType.CENTER_INSIDE.ordinal, - ) - ), - "StyleConstants" to - mapOf( - "PointerEventsValues" to - mapOf( - "none" to PointerEvents.NONE.ordinal, - "boxNone" to PointerEvents.BOX_NONE.ordinal, - "boxOnly" to PointerEvents.BOX_ONLY.ordinal, - "unspecified" to PointerEvents.AUTO.ordinal, - ) - ), - "AccessibilityEventTypes" to - mapOf( - "typeWindowStateChanged" to AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, - "typeViewFocused" to AccessibilityEvent.TYPE_VIEW_FOCUSED, - "typeViewClicked" to AccessibilityEvent.TYPE_VIEW_CLICKED, - ), - ) + val constants: Map = mapOf( + "UIView" to + mapOf( + "ContentMode" to + mapOf( + "ScaleAspectFit" to ImageView.ScaleType.FIT_CENTER.ordinal, + "ScaleAspectFill" to ImageView.ScaleType.CENTER_CROP.ordinal, + "ScaleAspectCenter" to ImageView.ScaleType.CENTER_INSIDE.ordinal, + ) + ), + "StyleConstants" to + mapOf( + "PointerEventsValues" to + mapOf( + "none" to PointerEvents.NONE.ordinal, + "boxNone" to PointerEvents.BOX_NONE.ordinal, + "boxOnly" to PointerEvents.BOX_ONLY.ordinal, + "unspecified" to PointerEvents.AUTO.ordinal, + ) + ), + "AccessibilityEventTypes" to + mapOf( + "typeWindowStateChanged" to AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, + "typeViewFocused" to AccessibilityEvent.TYPE_VIEW_FOCUSED, + "typeViewClicked" to AccessibilityEvent.TYPE_VIEW_CLICKED, + ), + ) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.kt index 3039c01cdace..3ead4430977a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModuleConstantsHelper.kt @@ -41,11 +41,10 @@ internal object UIManagerModuleConstantsHelper { @JvmStatic val defaultExportableEventTypes: Map - get() = - mapOf( - BUBBLING_EVENTS_KEY to UIManagerModuleConstants.bubblingEventTypeConstants, - DIRECT_EVENTS_KEY to UIManagerModuleConstants.directEventTypeConstants, - ) + get() = mapOf( + BUBBLING_EVENTS_KEY to UIManagerModuleConstants.bubblingEventTypeConstants, + DIRECT_EVENTS_KEY to UIManagerModuleConstants.directEventTypeConstants, + ) private fun validateDirectEventNames( viewManagerName: String, @@ -111,14 +110,13 @@ internal object UIManagerModuleConstantsHelper { for (viewManager in viewManagers) { val viewManagerName = viewManager.getName() - val viewManagerConstants: MutableMap<*, *> = - createConstantsForViewManager( - viewManager, - null, - null, - allBubblingEventTypes, - allDirectEventTypes, - ) + val viewManagerConstants: MutableMap<*, *> = createConstantsForViewManager( + viewManager, + null, + null, + allBubblingEventTypes, + allDirectEventTypes, + ) if (!viewManagerConstants.isEmpty()) { constants[viewManagerName] = viewManagerConstants } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 5990dc477ca8..b39a5894a0c9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -187,80 +187,77 @@ public object ViewProps { internal const val ON_CLICK: String = "onClick" internal const val ON_CLICK_CAPTURE: String = "onClickCapture" @JvmField - public val BORDER_SPACING_TYPES: IntArray = - intArrayOf( - Spacing.ALL, - Spacing.START, - Spacing.END, - Spacing.TOP, - Spacing.BOTTOM, - Spacing.LEFT, - Spacing.RIGHT, - ) + public val BORDER_SPACING_TYPES: IntArray = intArrayOf( + Spacing.ALL, + Spacing.START, + Spacing.END, + Spacing.TOP, + Spacing.BOTTOM, + Spacing.LEFT, + Spacing.RIGHT, + ) @JvmField - public val PADDING_MARGIN_SPACING_TYPES: IntArray = - intArrayOf( - Spacing.ALL, - Spacing.VERTICAL, - Spacing.HORIZONTAL, - Spacing.START, - Spacing.END, - Spacing.TOP, - Spacing.BOTTOM, - Spacing.LEFT, - Spacing.RIGHT, - ) - private val LAYOUT_ONLY_PROPS: HashSet = - HashSet( - listOf( - ALIGN_SELF, - ALIGN_ITEMS, - COLLAPSABLE, - FLEX, - FLEX_BASIS, - FLEX_DIRECTION, - FLEX_GROW, - ROW_GAP, - COLUMN_GAP, - GAP, - FLEX_SHRINK, - FLEX_WRAP, - JUSTIFY_CONTENT, - ALIGN_CONTENT, - DISPLAY, /* position */ - POSITION, - RIGHT, - TOP, - BOTTOM, - LEFT, - START, - END, /* dimensions */ - WIDTH, - HEIGHT, - MIN_WIDTH, - MAX_WIDTH, - MIN_HEIGHT, - MAX_HEIGHT, /* margins */ - MARGIN, - MARGIN_VERTICAL, - MARGIN_HORIZONTAL, - MARGIN_LEFT, - MARGIN_RIGHT, - MARGIN_TOP, - MARGIN_BOTTOM, - MARGIN_START, - MARGIN_END, /* paddings */ - PADDING, - PADDING_VERTICAL, - PADDING_HORIZONTAL, - PADDING_LEFT, - PADDING_RIGHT, - PADDING_TOP, - PADDING_BOTTOM, - PADDING_START, - PADDING_END, - ) + public val PADDING_MARGIN_SPACING_TYPES: IntArray = intArrayOf( + Spacing.ALL, + Spacing.VERTICAL, + Spacing.HORIZONTAL, + Spacing.START, + Spacing.END, + Spacing.TOP, + Spacing.BOTTOM, + Spacing.LEFT, + Spacing.RIGHT, + ) + private val LAYOUT_ONLY_PROPS: HashSet = HashSet( + listOf( + ALIGN_SELF, + ALIGN_ITEMS, + COLLAPSABLE, + FLEX, + FLEX_BASIS, + FLEX_DIRECTION, + FLEX_GROW, + ROW_GAP, + COLUMN_GAP, + GAP, + FLEX_SHRINK, + FLEX_WRAP, + JUSTIFY_CONTENT, + ALIGN_CONTENT, + DISPLAY, /* position */ + POSITION, + RIGHT, + TOP, + BOTTOM, + LEFT, + START, + END, /* dimensions */ + WIDTH, + HEIGHT, + MIN_WIDTH, + MAX_WIDTH, + MIN_HEIGHT, + MAX_HEIGHT, /* margins */ + MARGIN, + MARGIN_VERTICAL, + MARGIN_HORIZONTAL, + MARGIN_LEFT, + MARGIN_RIGHT, + MARGIN_TOP, + MARGIN_BOTTOM, + MARGIN_START, + MARGIN_END, /* paddings */ + PADDING, + PADDING_VERTICAL, + PADDING_HORIZONTAL, + PADDING_LEFT, + PADDING_RIGHT, + PADDING_TOP, + PADDING_BOTTOM, + PADDING_START, + PADDING_END, ) + ) @JvmStatic public fun isLayoutOnly(map: ReadableMap, prop: String): Boolean { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt index d1293ee48d9c..60b4e1cc9cdb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BorderDrawable.kt @@ -137,26 +137,24 @@ internal class BorderDrawable( @Deprecated("Deprecated in Java") override fun getOpacity(): Int { - val maxBorderAlpha = - maxOf( - (Color.alpha(multiplyColorAlpha(computedBorderColors.left, borderAlpha))), - (Color.alpha(multiplyColorAlpha(computedBorderColors.top, borderAlpha))), - (Color.alpha(multiplyColorAlpha(computedBorderColors.right, borderAlpha))), - (Color.alpha(multiplyColorAlpha(computedBorderColors.bottom, borderAlpha))), - ) + val maxBorderAlpha = maxOf( + (Color.alpha(multiplyColorAlpha(computedBorderColors.left, borderAlpha))), + (Color.alpha(multiplyColorAlpha(computedBorderColors.top, borderAlpha))), + (Color.alpha(multiplyColorAlpha(computedBorderColors.right, borderAlpha))), + (Color.alpha(multiplyColorAlpha(computedBorderColors.bottom, borderAlpha))), + ) // If the highest alpha value of all border edges is 0, then the drawable is TRANSPARENT. if (maxBorderAlpha == 0) { return PixelFormat.TRANSPARENT } - val minBorderAlpha = - minOf( - (Color.alpha(multiplyColorAlpha(computedBorderColors.left, borderAlpha))), - (Color.alpha(multiplyColorAlpha(computedBorderColors.top, borderAlpha))), - (Color.alpha(multiplyColorAlpha(computedBorderColors.right, borderAlpha))), - (Color.alpha(multiplyColorAlpha(computedBorderColors.bottom, borderAlpha))), - ) + val minBorderAlpha = minOf( + (Color.alpha(multiplyColorAlpha(computedBorderColors.left, borderAlpha))), + (Color.alpha(multiplyColorAlpha(computedBorderColors.top, borderAlpha))), + (Color.alpha(multiplyColorAlpha(computedBorderColors.right, borderAlpha))), + (Color.alpha(multiplyColorAlpha(computedBorderColors.bottom, borderAlpha))), + ) /* * If the lowest alpha value of all border edges is 255, then the drawable is OPAQUE. @@ -243,17 +241,16 @@ internal class BorderDrawable( val top = bounds.top // Check for fast path to border drawing. - val fastBorderColor = - fastBorderCompatibleColorOrZero( - borderLeft, - borderTop, - borderRight, - borderBottom, - computedBorderColors.left, - computedBorderColors.top, - computedBorderColors.right, - computedBorderColors.bottom, - ) + val fastBorderColor = fastBorderCompatibleColorOrZero( + borderLeft, + borderTop, + borderRight, + borderBottom, + computedBorderColors.left, + computedBorderColors.top, + computedBorderColors.right, + computedBorderColors.bottom, + ) if (fastBorderColor != 0) { if (Color.alpha(fastBorderColor) != 0) { // Border color is not transparent. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt index 8e38a8d0ac56..06a4fa56d64a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt @@ -85,13 +85,12 @@ internal class InsetBoxShadowDrawable( val computedBorderRadii = computeBorderRadii() val computedBorderInsets = computeBorderInsets() - val paddingBoxRect = - RectF( - bounds.left + (computedBorderInsets?.left ?: 0f), - bounds.top + (computedBorderInsets?.top ?: 0f), - bounds.right - (computedBorderInsets?.right ?: 0f), - bounds.bottom - (computedBorderInsets?.bottom ?: 0f), - ) + val paddingBoxRect = RectF( + bounds.left + (computedBorderInsets?.left ?: 0f), + bounds.top + (computedBorderInsets?.top ?: 0f), + bounds.right - (computedBorderInsets?.right ?: 0f), + bounds.bottom - (computedBorderInsets?.bottom ?: 0f), + ) val paddingBoxRadii = computedBorderRadii?.let { floatArrayOf( innerRadius(it.topLeft.horizontal, computedBorderInsets?.left), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/RadialGradient.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/RadialGradient.kt index deaceccc87f6..8f48ed0a04bb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/RadialGradient.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/RadialGradient.kt @@ -327,13 +327,12 @@ internal class RadialGradient( height: Float, sizeKeyword: GradientSize.KeywordType, ): Pair { - val corners = - arrayOf( - Pair(0f, 0f), // top-left - Pair(width, 0f), // top-right - Pair(width, height), // bottom-right - Pair(0f, height), // bottom-left - ) + val corners = arrayOf( + Pair(0f, 0f), // top-left + Pair(width, 0f), // top-right + Pair(width, height), // bottom-right + Pair(0f, height), // bottom-left + ) var cornerIndex = 0 var distance = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt index c650fda90f03..71eda73fe5b9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt @@ -150,11 +150,10 @@ public class ReactDrawerLayoutManager : return true } - public override fun getCommandsMap(): Map = - mapOf( - COMMAND_OPEN_DRAWER to OPEN_DRAWER, - COMMAND_CLOSE_DRAWER to CLOSE_DRAWER, - ) + public override fun getCommandsMap(): Map = mapOf( + COMMAND_OPEN_DRAWER to OPEN_DRAWER, + COMMAND_CLOSE_DRAWER to CLOSE_DRAWER, + ) @Deprecated( message = diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt index 1e9126710689..fa01aecb789c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt @@ -290,14 +290,13 @@ public class ReactImageView( for (idx in 0 until sources.size()) { val source = sources.getMap(idx) ?: continue val cacheControl = computeCacheControl(source.getString("cache")) - var imageSource = - ImageSource( - context, - source.getString("uri"), - source.getDouble("width"), - source.getDouble("height"), - cacheControl, - ) + var imageSource = ImageSource( + context, + source.getString("uri"), + source.getDouble("width"), + source.getDouble("height"), + cacheControl, + ) if (Uri.EMPTY == imageSource.uri) { warnImageSource(source.getString("uri")) imageSource = getTransparentBitmapImageSource(context) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.kt index a922cdbfd6fc..3bdff097b4ba 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.kt @@ -18,12 +18,11 @@ public class ReactScrollViewCommandHelper { public const val COMMAND_FLASH_SCROLL_INDICATORS: Int = 3 @JvmStatic - public fun getCommandsMap(): Map = - hashMapOf( - "scrollTo" to COMMAND_SCROLL_TO, - "scrollToEnd" to COMMAND_SCROLL_TO_END, - "flashScrollIndicators" to COMMAND_FLASH_SCROLL_INDICATORS, - ) + public fun getCommandsMap(): Map = hashMapOf( + "scrollTo" to COMMAND_SCROLL_TO, + "scrollToEnd" to COMMAND_SCROLL_TO_END, + "flashScrollIndicators" to COMMAND_FLASH_SCROLL_INDICATORS, + ) @JvmStatic public fun receiveCommand( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt index f96baf0200d5..7094127e6d9a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt @@ -442,17 +442,15 @@ constructor(private val fpsListener: FpsListener? = null) : public companion object { public const val REACT_CLASS: String = "RCTScrollView" - public fun createExportedCustomDirectEventTypeConstants(): Map = - mapOf( - getJSEventName(ScrollEventType.SCROLL) to mapOf("registrationName" to "onScroll"), - getJSEventName(ScrollEventType.BEGIN_DRAG) to - mapOf("registrationName" to "onScrollBeginDrag"), - getJSEventName(ScrollEventType.END_DRAG) to - mapOf("registrationName" to "onScrollEndDrag"), - getJSEventName(ScrollEventType.MOMENTUM_BEGIN) to - mapOf("registrationName" to "onMomentumScrollBegin"), - getJSEventName(ScrollEventType.MOMENTUM_END) to - mapOf("registrationName" to "onMomentumScrollEnd"), - ) + public fun createExportedCustomDirectEventTypeConstants(): Map = mapOf( + getJSEventName(ScrollEventType.SCROLL) to mapOf("registrationName" to "onScroll"), + getJSEventName(ScrollEventType.BEGIN_DRAG) to + mapOf("registrationName" to "onScrollBeginDrag"), + getJSEventName(ScrollEventType.END_DRAG) to mapOf("registrationName" to "onScrollEndDrag"), + getJSEventName(ScrollEventType.MOMENTUM_BEGIN) to + mapOf("registrationName" to "onMomentumScrollBegin"), + getJSEventName(ScrollEventType.MOMENTUM_END) to + mapOf("registrationName" to "onMomentumScrollEnd"), + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ScrollEvent.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ScrollEvent.kt index e7bcd77cc30c..c105abedbfa4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ScrollEvent.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ScrollEvent.kt @@ -170,19 +170,18 @@ public class ScrollEvent private constructor() : Event() { contentHeight: Int, scrollViewWidth: Int, scrollViewHeight: Int, - ): ScrollEvent = - obtain( - ViewUtil.NO_SURFACE_ID, - viewTag, - scrollEventType, - scrollX, - scrollY, - xVelocity, - yVelocity, - contentWidth, - contentHeight, - scrollViewWidth, - scrollViewHeight, - ) + ): ScrollEvent = obtain( + ViewUtil.NO_SURFACE_ID, + viewTag, + scrollEventType, + scrollX, + scrollY, + xVelocity, + yVelocity, + contentWidth, + contentHeight, + scrollViewWidth, + scrollViewHeight, + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.kt index dad6ce9aa1c5..327d2656f645 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextUpdate.kt @@ -25,13 +25,12 @@ internal class ReactTextUpdate( textAlign: Int, textBreakStrategy: Int, justificationMode: Int, - ): ReactTextUpdate = - ReactTextUpdate( - text, - jsEventCounter, - textAlign, - textBreakStrategy, - justificationMode, - ) + ): ReactTextUpdate = ReactTextUpdate( + text, + jsEventCounter, + textAlign, + textBreakStrategy, + justificationMode, + ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 9fb9553ee634..317025499133 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -668,14 +668,13 @@ internal object TextLayoutManager { fontWeightAdjustment: Int, attributedString: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback?, - ): Spannable = - getOrCreateSpannableForText( - assets, - fontWeightAdjustment, - attributedString, - reactTextViewManagerCallback, - null, - ) + ): Spannable = getOrCreateSpannableForText( + assets, + fontWeightAdjustment, + attributedString, + reactTextViewManagerCallback, + null, + ) @OptIn(UnstableReactNativeAPI::class) internal fun getOrCreateSpannableForText( @@ -683,14 +682,13 @@ internal object TextLayoutManager { attributedString: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry?, - ): Spannable = - getOrCreateSpannableForText( - assets, - 0, - attributedString, - reactTextViewManagerCallback, - textEffectRegistry, - ) + ): Spannable = getOrCreateSpannableForText( + assets, + 0, + attributedString, + reactTextViewManagerCallback, + textEffectRegistry, + ) @OptIn(UnstableReactNativeAPI::class) internal fun getOrCreateSpannableForText( @@ -729,14 +727,13 @@ internal object TextLayoutManager { textEffectRegistry: TextEffectRegistry? = null, ): Spannable { if (ReactNativeFeatureFlags.enableAndroidTextMeasurementOptimizations()) { - val spannable = - buildSpannableFromFragmentsOptimized( - assets, - fontWeightAdjustment, - fragments, - outputReactTags, - textEffectRegistry, - ) + val spannable = buildSpannableFromFragmentsOptimized( + assets, + fontWeightAdjustment, + fragments, + outputReactTags, + textEffectRegistry, + ) reactTextViewManagerCallback?.onPostProcessSpannable(spannable) return spannable @@ -958,14 +955,13 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, ): Layout { - val text = - getOrCreateSpannableForText( - assets, - fontWeightAdjustment, - attributedString, - reactTextViewManagerCallback, - textEffectRegistry, - ) + val text = getOrCreateSpannableForText( + assets, + fontWeightAdjustment, + attributedString, + reactTextViewManagerCallback, + textEffectRegistry, + ) val paint: TextPaint if (attributedString.contains(AS_KEY_CACHE_ID)) { @@ -977,15 +973,15 @@ internal object TextLayoutManager { } return createLayout( - text, - paint, - attributedString, - paragraphAttributes, - width, - widthYogaMeasureMode, - height, - heightYogaMeasureMode, - ) + text, + paint, + attributedString, + paragraphAttributes, + width, + widthYogaMeasureMode, + height, + heightYogaMeasureMode, + ) .layout } @@ -1088,19 +1084,18 @@ internal object TextLayoutManager { heightYogaMeasureMode: YogaMeasureMode, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, - ): PreparedLayout = - createPreparedLayout( - assets, - 0, - attributedString, - paragraphAttributes, - width, - widthYogaMeasureMode, - height, - heightYogaMeasureMode, - reactTextViewManagerCallback, - textEffectRegistry, - ) + ): PreparedLayout = createPreparedLayout( + assets, + 0, + attributedString, + paragraphAttributes, + width, + widthYogaMeasureMode, + height, + heightYogaMeasureMode, + reactTextViewManagerCallback, + textEffectRegistry, + ) @JvmStatic @OptIn(UnstableReactNativeAPI::class) @@ -1118,42 +1113,39 @@ internal object TextLayoutManager { ): PreparedLayout { val fragments = attributedString.getMapBuffer(AS_KEY_FRAGMENTS) val reactTags = IntArray(fragments.count) - val text = - createSpannableFromAttributedString( - assets, - fontWeightAdjustment, - fragments, - reactTextViewManagerCallback, - reactTags, - textEffectRegistry, - ) + val text = createSpannableFromAttributedString( + assets, + fontWeightAdjustment, + fragments, + reactTextViewManagerCallback, + reactTags, + textEffectRegistry, + ) val baseTextAttributes = TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) - val result = - createLayout( - text, - newPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment), - attributedString, - paragraphAttributes, - width, - widthYogaMeasureMode, - height, - heightYogaMeasureMode, - ) + val result = createLayout( + text, + newPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment), + attributedString, + paragraphAttributes, + width, + widthYogaMeasureMode, + height, + heightYogaMeasureMode, + ) val maximumNumberOfLines = if (paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES)) paragraphAttributes.getInt(PA_KEY_MAX_NUMBER_OF_LINES) else ReactConstants.UNSET - val verticalOffset = - getVerticalOffset( - result.layout, - paragraphAttributes, - height, - heightYogaMeasureMode, - maximumNumberOfLines, - ) + val verticalOffset = getVerticalOffset( + result.layout, + paragraphAttributes, + height, + heightYogaMeasureMode, + maximumNumberOfLines, + ) return PreparedLayout( result.layout, @@ -1283,20 +1275,19 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, attachmentsPositions: FloatArray?, textEffectRegistry: TextEffectRegistry? = null, - ): Long = - measureText( - assets, - 0, - attributedString, - paragraphAttributes, - width, - widthYogaMeasureMode, - height, - heightYogaMeasureMode, - reactTextViewManagerCallback, - attachmentsPositions, - textEffectRegistry, - ) + ): Long = measureText( + assets, + 0, + attributedString, + paragraphAttributes, + width, + widthYogaMeasureMode, + height, + heightYogaMeasureMode, + reactTextViewManagerCallback, + attachmentsPositions, + textEffectRegistry, + ) @JvmStatic @OptIn(UnstableReactNativeAPI::class) @@ -1314,19 +1305,18 @@ internal object TextLayoutManager { textEffectRegistry: TextEffectRegistry? = null, ): Long { // TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic) - val layout = - createLayoutForMeasurement( - assets, - fontWeightAdjustment, - attributedString, - paragraphAttributes, - width, - widthYogaMeasureMode, - height, - heightYogaMeasureMode, - reactTextViewManagerCallback, - textEffectRegistry, - ) + val layout = createLayoutForMeasurement( + assets, + fontWeightAdjustment, + attributedString, + paragraphAttributes, + width, + widthYogaMeasureMode, + height, + heightYogaMeasureMode, + reactTextViewManagerCallback, + textEffectRegistry, + ) val maximumNumberOfLines = if (paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES)) @@ -1583,17 +1573,16 @@ internal object TextLayoutManager { height: Float, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, - ): WritableArray = - measureLines( - assetManager, - 0, - attributedString, - paragraphAttributes, - width, - height, - reactTextViewManagerCallback, - textEffectRegistry, - ) + ): WritableArray = measureLines( + assetManager, + 0, + attributedString, + paragraphAttributes, + width, + height, + reactTextViewManagerCallback, + textEffectRegistry, + ) @JvmStatic @OptIn(UnstableReactNativeAPI::class) @@ -1607,19 +1596,18 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, ): WritableArray { - val layout = - createLayoutForMeasurement( - assetManager, - fontWeightAdjustment, - attributedString, - paragraphAttributes, - width, - YogaMeasureMode.EXACTLY, - height, - YogaMeasureMode.EXACTLY, - reactTextViewManagerCallback, - textEffectRegistry, - ) + val layout = createLayoutForMeasurement( + assetManager, + fontWeightAdjustment, + attributedString, + paragraphAttributes, + width, + YogaMeasureMode.EXACTLY, + height, + YogaMeasureMode.EXACTLY, + reactTextViewManagerCallback, + textEffectRegistry, + ) return FontMetricsUtil.getFontMetrics( layout.text, layout, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt index b56a59fdf047..5ff43109dc69 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt @@ -1058,60 +1058,58 @@ public open class ReactTextInputManager public constructor() : // private const val TX_STATE_KEY_HASH: Short = 2 private const val TX_STATE_KEY_MOST_RECENT_EVENT_COUNT: Short = 3 - private val REACT_PROPS_AUTOFILL_HINTS_MAP: Map = - mapOf( - "2fa-app-otp" to HintConstants.AUTOFILL_HINT_2FA_APP_OTP, - "birthdate-day" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_DAY, - "birthdate-full" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_FULL, - "birthdate-month" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_MONTH, - "birthdate-year" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_YEAR, - "cc-csc" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, - "cc-exp" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, - "cc-exp-day" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY, - "cc-exp-month" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, - "cc-exp-year" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, - "cc-number" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_NUMBER, - "email" to HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, - "email-otp" to HintConstants.AUTOFILL_HINT_EMAIL_OTP, - "flight-confirmation-code" to HintConstants.AUTOFILL_HINT_FLIGHT_CONFIRMATION_CODE, - "flight-number" to HintConstants.AUTOFILL_HINT_FLIGHT_NUMBER, - "gender" to HintConstants.AUTOFILL_HINT_GENDER, - "gift-card-number" to HintConstants.AUTOFILL_HINT_GIFT_CARD_NUMBER, - "gift-card-pin" to HintConstants.AUTOFILL_HINT_GIFT_CARD_PIN, - "loyalty-account-number" to HintConstants.AUTOFILL_HINT_LOYALTY_ACCOUNT_NUMBER, - "name" to HintConstants.AUTOFILL_HINT_PERSON_NAME, - "name-family" to HintConstants.AUTOFILL_HINT_PERSON_NAME_FAMILY, - "name-given" to HintConstants.AUTOFILL_HINT_PERSON_NAME_GIVEN, - "name-middle" to HintConstants.AUTOFILL_HINT_PERSON_NAME_MIDDLE, - "name-middle-initial" to HintConstants.AUTOFILL_HINT_PERSON_NAME_MIDDLE_INITIAL, - "name-prefix" to HintConstants.AUTOFILL_HINT_PERSON_NAME_PREFIX, - "name-suffix" to HintConstants.AUTOFILL_HINT_PERSON_NAME_SUFFIX, - "password" to HintConstants.AUTOFILL_HINT_PASSWORD, - "password-new" to HintConstants.AUTOFILL_HINT_NEW_PASSWORD, - "postal-address" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS, - "postal-address-country" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY, - "postal-address-dependent-locality" to - HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_DEPENDENT_LOCALITY, - "postal-address-extended" to - HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_ADDRESS, - "postal-address-extended-postal-code" to - HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_POSTAL_CODE, - "postal-address-locality" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY, - "postal-address-region" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_REGION, - "postal-address-unit" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_APT_NUMBER, - "postal-code" to HintConstants.AUTOFILL_HINT_POSTAL_CODE, - "promo-code" to HintConstants.AUTOFILL_HINT_PROMO_CODE, - "street-address" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS, - "sms-otp" to HintConstants.AUTOFILL_HINT_SMS_OTP, - "tel" to HintConstants.AUTOFILL_HINT_PHONE_NUMBER, - "tel-country-code" to HintConstants.AUTOFILL_HINT_PHONE_COUNTRY_CODE, - "tel-national" to HintConstants.AUTOFILL_HINT_PHONE_NATIONAL, - "tel-device" to HintConstants.AUTOFILL_HINT_PHONE_NUMBER_DEVICE, - "upi-vpa" to HintConstants.AUTOFILL_HINT_UPI_VPA, - "wifi-password" to HintConstants.AUTOFILL_HINT_WIFI_PASSWORD, - "username" to HintConstants.AUTOFILL_HINT_USERNAME, - "username-new" to HintConstants.AUTOFILL_HINT_NEW_USERNAME, - ) + private val REACT_PROPS_AUTOFILL_HINTS_MAP: Map = mapOf( + "2fa-app-otp" to HintConstants.AUTOFILL_HINT_2FA_APP_OTP, + "birthdate-day" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_DAY, + "birthdate-full" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_FULL, + "birthdate-month" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_MONTH, + "birthdate-year" to HintConstants.AUTOFILL_HINT_BIRTH_DATE_YEAR, + "cc-csc" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, + "cc-exp" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, + "cc-exp-day" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DAY, + "cc-exp-month" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH, + "cc-exp-year" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_YEAR, + "cc-number" to HintConstants.AUTOFILL_HINT_CREDIT_CARD_NUMBER, + "email" to HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS, + "email-otp" to HintConstants.AUTOFILL_HINT_EMAIL_OTP, + "flight-confirmation-code" to HintConstants.AUTOFILL_HINT_FLIGHT_CONFIRMATION_CODE, + "flight-number" to HintConstants.AUTOFILL_HINT_FLIGHT_NUMBER, + "gender" to HintConstants.AUTOFILL_HINT_GENDER, + "gift-card-number" to HintConstants.AUTOFILL_HINT_GIFT_CARD_NUMBER, + "gift-card-pin" to HintConstants.AUTOFILL_HINT_GIFT_CARD_PIN, + "loyalty-account-number" to HintConstants.AUTOFILL_HINT_LOYALTY_ACCOUNT_NUMBER, + "name" to HintConstants.AUTOFILL_HINT_PERSON_NAME, + "name-family" to HintConstants.AUTOFILL_HINT_PERSON_NAME_FAMILY, + "name-given" to HintConstants.AUTOFILL_HINT_PERSON_NAME_GIVEN, + "name-middle" to HintConstants.AUTOFILL_HINT_PERSON_NAME_MIDDLE, + "name-middle-initial" to HintConstants.AUTOFILL_HINT_PERSON_NAME_MIDDLE_INITIAL, + "name-prefix" to HintConstants.AUTOFILL_HINT_PERSON_NAME_PREFIX, + "name-suffix" to HintConstants.AUTOFILL_HINT_PERSON_NAME_SUFFIX, + "password" to HintConstants.AUTOFILL_HINT_PASSWORD, + "password-new" to HintConstants.AUTOFILL_HINT_NEW_PASSWORD, + "postal-address" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS, + "postal-address-country" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_COUNTRY, + "postal-address-dependent-locality" to + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_DEPENDENT_LOCALITY, + "postal-address-extended" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_ADDRESS, + "postal-address-extended-postal-code" to + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_EXTENDED_POSTAL_CODE, + "postal-address-locality" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_LOCALITY, + "postal-address-region" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_REGION, + "postal-address-unit" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_APT_NUMBER, + "postal-code" to HintConstants.AUTOFILL_HINT_POSTAL_CODE, + "promo-code" to HintConstants.AUTOFILL_HINT_PROMO_CODE, + "street-address" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_STREET_ADDRESS, + "sms-otp" to HintConstants.AUTOFILL_HINT_SMS_OTP, + "tel" to HintConstants.AUTOFILL_HINT_PHONE_NUMBER, + "tel-country-code" to HintConstants.AUTOFILL_HINT_PHONE_COUNTRY_CODE, + "tel-national" to HintConstants.AUTOFILL_HINT_PHONE_NATIONAL, + "tel-device" to HintConstants.AUTOFILL_HINT_PHONE_NUMBER_DEVICE, + "upi-vpa" to HintConstants.AUTOFILL_HINT_UPI_VPA, + "wifi-password" to HintConstants.AUTOFILL_HINT_WIFI_PASSWORD, + "username" to HintConstants.AUTOFILL_HINT_USERNAME, + "username-new" to HintConstants.AUTOFILL_HINT_NEW_USERNAME, + ) private const val FOCUS_TEXT_INPUT = 1 private const val BLUR_TEXT_INPUT = 2 diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextScrollWatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextScrollWatcher.kt index 7d2dc1690644..51d082e63ec9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextScrollWatcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextScrollWatcher.kt @@ -26,20 +26,19 @@ internal class ReactTextScrollWatcher(private val editText: ReactEditText) : Scr override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) { if (previousHorizontal != horiz || previousVert != vert) { - val event = - obtain( - surfaceId, - editText.id, - ScrollEventType.SCROLL, - horiz.toFloat(), - vert.toFloat(), - 0f, // can't get x velocity - 0f, // can't get y velocity - 0, // can't get content width - 0, // can't get content height - editText.width, - editText.height, - ) + val event = obtain( + surfaceId, + editText.id, + ScrollEventType.SCROLL, + horiz.toFloat(), + vert.toFloat(), + 0f, // can't get x velocity + 0f, // can't get y velocity + 0, // can't get content width + 0, // can't get content height + editText.width, + editText.height, + ) eventDispatcher?.dispatchEvent(event) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt index 767c6ae49826..81218b1a93b4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt @@ -46,19 +46,18 @@ public open class ReactViewManager : ReactClippingViewManager() public companion object { public const val REACT_CLASS: String = ViewProps.VIEW_CLASS_NAME - private val SPACING_TYPES = - intArrayOf( - Spacing.ALL, - Spacing.LEFT, - Spacing.RIGHT, - Spacing.TOP, - Spacing.BOTTOM, - Spacing.START, - Spacing.END, - Spacing.BLOCK, - Spacing.BLOCK_END, - Spacing.BLOCK_START, - ) + private val SPACING_TYPES = intArrayOf( + Spacing.ALL, + Spacing.LEFT, + Spacing.RIGHT, + Spacing.TOP, + Spacing.BOTTOM, + Spacing.START, + Spacing.END, + Spacing.BLOCK, + Spacing.BLOCK_END, + Spacing.BLOCK_START, + ) private const val CMD_HOTSPOT_UPDATE = 1 private const val CMD_SET_PRESSED = 2 private const val HOTSPOT_UPDATE_KEY = "hotspotUpdate" diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.kt index 96558a36ee09..ecde6d702289 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedInterpolationTest.kt @@ -130,11 +130,10 @@ class NativeAnimatedInterpolationTest { @Test fun testInterpolateString() { val input = doubleArrayOf(0.0, 1.0) - val output = - arrayOf( - doubleArrayOf(20.0, 20.0, 20.0, 80.0, 80.0, 80.0, 80.0, 20.0), - doubleArrayOf(40.0, 40.0, 33.0, 60.0, 60.0, 60.0, 65.0, 40.0), - ) + val output = arrayOf( + doubleArrayOf(20.0, 20.0, 20.0, 80.0, 80.0, 80.0, 80.0, 20.0), + doubleArrayOf(40.0, 40.0, 33.0, 60.0, 60.0, 60.0, 65.0, 40.0), + ) val pattern = "M20,20L20,80L80,80L80,20Z" assertThat( InterpolationAnimatedNode.interpolateString( diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.kt index f3d642352740..3b077075f861 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.kt @@ -58,18 +58,17 @@ object ReactTestHelper { @OptIn(UnstableReactNativeAPI::class) fun createTestReactApplicationContext(application: Application): ReactApplicationContext { - val reactHost = - spy( - ReactHostImpl( - RuntimeEnvironment.getApplication(), - mock(), - mock(), - Task.Companion.IMMEDIATE_EXECUTOR, - Task.Companion.IMMEDIATE_EXECUTOR, - false /* allowPackagerServerAccess */, - false /* useDevSupport */, - ) + val reactHost = spy( + ReactHostImpl( + RuntimeEnvironment.getApplication(), + mock(), + mock(), + Task.Companion.IMMEDIATE_EXECUTOR, + Task.Companion.IMMEDIATE_EXECUTOR, + false /* allowPackagerServerAccess */, + false /* useDevSupport */, ) + ) return BridgelessReactContext(application, reactHost) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricMountingManagerInstrumentationTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricMountingManagerInstrumentationTest.kt index 1c6951b59226..0c0e4333f195 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricMountingManagerInstrumentationTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricMountingManagerInstrumentationTest.kt @@ -84,16 +84,15 @@ class FabricMountingManagerInstrumentationTest { index: Int, componentName: String = "RCTView", ): IntBufferBatchMountItem { - val intBuffer = - intArrayOf( - IntBufferBatchMountItem.INSTRUCTION_CREATE, - reactTag, - 1, - IntBufferBatchMountItem.INSTRUCTION_INSERT, - reactTag, - parentTag, - index, - ) + val intBuffer = intArrayOf( + IntBufferBatchMountItem.INSTRUCTION_CREATE, + reactTag, + 1, + IntBufferBatchMountItem.INSTRUCTION_INSERT, + reactTag, + parentTag, + index, + ) val objBuffer = arrayOf(componentName, JavaOnlyMap.of(), null, null) return IntBufferBatchMountItem(surfaceId, intBuffer, objBuffer, 1) } @@ -175,22 +174,21 @@ class FabricMountingManagerInstrumentationTest { initialMount.execute(mountingManager) assertThat(smm.getView(42)).isNotNull() - val intBuffer = - intArrayOf( - IntBufferBatchMountItem.INSTRUCTION_REMOVE, - 42, - surfaceId, - 0, - IntBufferBatchMountItem.INSTRUCTION_DELETE, - 42, - IntBufferBatchMountItem.INSTRUCTION_CREATE, - 42, - 1, - IntBufferBatchMountItem.INSTRUCTION_INSERT, - 42, - surfaceId, - 0, - ) + val intBuffer = intArrayOf( + IntBufferBatchMountItem.INSTRUCTION_REMOVE, + 42, + surfaceId, + 0, + IntBufferBatchMountItem.INSTRUCTION_DELETE, + 42, + IntBufferBatchMountItem.INSTRUCTION_CREATE, + 42, + 1, + IntBufferBatchMountItem.INSTRUCTION_INSERT, + 42, + surfaceId, + 0, + ) val objBuffer = arrayOf("RCTView", JavaOnlyMap.of(), null, null) val batchMount = IntBufferBatchMountItem(surfaceId, intBuffer, objBuffer, 2) batchMount.execute(mountingManager) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt index ebecb3a9ef97..00e45c3d92a6 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/events/TouchEventDispatchTest.kt @@ -45,462 +45,450 @@ class TouchEventDispatchTest { private val touchEventCoalescingKeyHelper = TouchEventCoalescingKeyHelper() /** Events (1 pointer): START -> MOVE -> MOVE -> UP */ - private val startMoveEndSequence = - listOf( - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_DOWN, - pointerId = 0, - pointerIds = intArrayOf(0), - pointerCoords = arrayOf(pointerCoords(1f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_MOVE, - pointerId = 0, - pointerIds = intArrayOf(0), - pointerCoords = arrayOf(pointerCoords(1f, 2f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_MOVE, - pointerId = 0, - pointerIds = intArrayOf(0), - pointerCoords = arrayOf(pointerCoords(1f, 3f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_UP, - pointerId = 0, - pointerIds = intArrayOf(0), - pointerCoords = arrayOf(pointerCoords(1f, 3f)), - ), - ) + private val startMoveEndSequence = listOf( + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_DOWN, + pointerId = 0, + pointerIds = intArrayOf(0), + pointerCoords = arrayOf(pointerCoords(1f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_MOVE, + pointerId = 0, + pointerIds = intArrayOf(0), + pointerCoords = arrayOf(pointerCoords(1f, 2f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_MOVE, + pointerId = 0, + pointerIds = intArrayOf(0), + pointerCoords = arrayOf(pointerCoords(1f, 3f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_UP, + pointerId = 0, + pointerIds = intArrayOf(0), + pointerCoords = arrayOf(pointerCoords(1f, 3f)), + ), + ) /** Expected values for [startMoveEndSequence] */ - private val startMoveEndExpectedSequence = - listOf( - /* - * START event for touch 1: - * { - * touches: [touch1], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), - ), - /* - * MOVE event for touch 1: - * { - * touches: [touch1], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 2f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), - ), - /* - * MOVE event for touch 1: - * { - * touches: [touch1], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 3f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0)), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0)), - ), - /* - * END event for touch 1: - * { - * touches: [], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 3f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = emptyList(), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0)), - ), - ) + private val startMoveEndExpectedSequence = listOf( + /* + * START event for touch 1: + * { + * touches: [touch1], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), + ), + /* + * MOVE event for touch 1: + * { + * touches: [touch1], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 2f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), + ), + /* + * MOVE event for touch 1: + * { + * touches: [touch1], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 3f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0)), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0)), + ), + /* + * END event for touch 1: + * { + * touches: [], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 3f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = emptyList(), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0)), + ), + ) /** Events (2 pointer): START 1st -> START 2nd -> MOVE 1st -> UP 2st -> UP 1st */ - private val startPointerMoveUpSequence = - listOf( - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_DOWN, - pointerId = 0, - pointerIds = intArrayOf(0), - pointerCoords = arrayOf(pointerCoords(1f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_POINTER_DOWN, - pointerId = 1, - pointerIds = intArrayOf(0, 1), - pointerCoords = arrayOf(pointerCoords(1f, 1f), pointerCoords(2f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_MOVE, - pointerId = 0, - pointerIds = intArrayOf(0, 1), - pointerCoords = arrayOf(pointerCoords(1f, 2f), pointerCoords(2f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_POINTER_UP, - pointerId = 1, - pointerIds = intArrayOf(0, 1), - pointerCoords = arrayOf(pointerCoords(1f, 2f), pointerCoords(2f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_POINTER_UP, - pointerId = 0, - pointerIds = intArrayOf(0), - pointerCoords = arrayOf(pointerCoords(1f, 2f)), - ), - ) + private val startPointerMoveUpSequence = listOf( + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_DOWN, + pointerId = 0, + pointerIds = intArrayOf(0), + pointerCoords = arrayOf(pointerCoords(1f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_POINTER_DOWN, + pointerId = 1, + pointerIds = intArrayOf(0, 1), + pointerCoords = arrayOf(pointerCoords(1f, 1f), pointerCoords(2f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_MOVE, + pointerId = 0, + pointerIds = intArrayOf(0, 1), + pointerCoords = arrayOf(pointerCoords(1f, 2f), pointerCoords(2f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_POINTER_UP, + pointerId = 1, + pointerIds = intArrayOf(0, 1), + pointerCoords = arrayOf(pointerCoords(1f, 2f), pointerCoords(2f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_POINTER_UP, + pointerId = 0, + pointerIds = intArrayOf(0), + pointerCoords = arrayOf(pointerCoords(1f, 2f)), + ), + ) /** Expected values for [startPointerMoveUpSequence] */ - private val startPointerMoveUpExpectedSequence = - listOf( - /* - * START event for touch 1: - * { - * touch: 0, - * touches: [touch1], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), - ), - /* - * START event for touch 2: - * { - * touch: 1, - * touches: [touch0, touch1], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 2f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 1, - touches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1)), - ), - /* - * MOVE event for touch 1: - * { - * touch: 0, - * touches: [touch0, touch1], - * changed: [touch0, touch1] - * } - * { - * touch: 1, - * touches: [touch0, touch1], - * changed: [touch0, touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 2f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - changedTouches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - ), - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 2f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 1, - touches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - changedTouches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - ), - /* - * UP event pointer 1: - * { - * touch: 1, - * touches: [touch0], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 2f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 1, - touches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1)), - ), - /* - * UP event pointer 0: - * { - * touch: 0, - * touches: [], - * changed: [touch0] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 2f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = emptyList(), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), - ), - ) + private val startPointerMoveUpExpectedSequence = listOf( + /* + * START event for touch 1: + * { + * touch: 0, + * touches: [touch1], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), + ), + /* + * START event for touch 2: + * { + * touch: 1, + * touches: [touch0, touch1], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 2f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 1, + touches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1)), + ), + /* + * MOVE event for touch 1: + * { + * touch: 0, + * touches: [touch0, touch1], + * changed: [touch0, touch1] + * } + * { + * touch: 1, + * touches: [touch0, touch1], + * changed: [touch0, touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 2f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + changedTouches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + ), + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 2f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 1, + touches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + changedTouches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + ), + /* + * UP event pointer 1: + * { + * touch: 1, + * touches: [touch0], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 2f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 1, + touches = listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1)), + ), + /* + * UP event pointer 0: + * { + * touch: 0, + * touches: [], + * changed: [touch0] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 2f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = emptyList(), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0)), + ), + ) /** Events (2 pointer): START 1st -> START 2nd -> MOVE 1st -> CANCEL */ - private val startMoveCancelSequence = - listOf( - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_DOWN, - pointerId = 0, - pointerIds = intArrayOf(0), - pointerCoords = arrayOf(pointerCoords(1f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_POINTER_DOWN, - pointerId = 1, - pointerIds = intArrayOf(0, 1), - pointerCoords = arrayOf(pointerCoords(1f, 1f), pointerCoords(2f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_MOVE, - pointerId = 0, - pointerIds = intArrayOf(0, 1), - pointerCoords = arrayOf(pointerCoords(1f, 2f), pointerCoords(2f, 1f)), - ), - createTouchEvent( - gestureTime = GESTURE_START_TIME, - action = MotionEvent.ACTION_CANCEL, - pointerId = 0, - pointerIds = intArrayOf(0, 1), - pointerCoords = arrayOf(pointerCoords(1f, 3f), pointerCoords(2f, 1f)), - ), - ) + private val startMoveCancelSequence = listOf( + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_DOWN, + pointerId = 0, + pointerIds = intArrayOf(0), + pointerCoords = arrayOf(pointerCoords(1f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_POINTER_DOWN, + pointerId = 1, + pointerIds = intArrayOf(0, 1), + pointerCoords = arrayOf(pointerCoords(1f, 1f), pointerCoords(2f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_MOVE, + pointerId = 0, + pointerIds = intArrayOf(0, 1), + pointerCoords = arrayOf(pointerCoords(1f, 2f), pointerCoords(2f, 1f)), + ), + createTouchEvent( + gestureTime = GESTURE_START_TIME, + action = MotionEvent.ACTION_CANCEL, + pointerId = 0, + pointerIds = intArrayOf(0, 1), + pointerCoords = arrayOf(pointerCoords(1f, 3f), pointerCoords(2f, 1f)), + ), + ) /** Expected values for [startMoveCancelSequence] */ - private val startMoveCancelExpectedSequence = - listOf( - /* - * START event for touch 1: - * { - * touch: 0, - * touches: [touch1], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), - ), - /* - * START event for touch 2: - * { - * touch: 1, - * touches: [touch0, touch1], - * changed: [touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 2f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 1, - touches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - changedTouches = - listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1)), - ), - /* - * MOVE event for touch 1: - * { - * touch: 0, - * touches: [touch0, touch1], - * changed: [touch0, touch1] - * } - * { - * touch: 1, - * touches: [touch0, touch1], - * changed: [touch0, touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 2f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - changedTouches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - ), - buildGestureEvent( - SURFACE_ID, - TARGET_VIEW_ID, - 2f, - 1f, - GESTURE_START_TIME, - 1, + private val startMoveCancelExpectedSequence = listOf( + /* + * START event for touch 1: + * { + * touch: 0, + * touches: [touch1], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0)), + ), + /* + * START event for touch 2: + * { + * touch: 1, + * touches: [touch0, touch1], + * changed: [touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 2f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 1, + touches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 1f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + changedTouches = + listOf(buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1)), + ), + /* + * MOVE event for touch 1: + * { + * touch: 0, + * touches: [touch0, touch1], + * changed: [touch0, touch1] + * } + * { + * touch: 1, + * touches: [touch0, touch1], + * changed: [touch0, touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 2f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = listOf( buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), ), + changedTouches = listOf( buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), ), + ), + buildGestureEvent( + SURFACE_ID, + TARGET_VIEW_ID, + 2f, + 1f, + GESTURE_START_TIME, + 1, + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), ), - /* - * CANCEL event: - * { - * touch: 0, - * touches: [], - * changed: [touch0, touch1] - * } - * { - * touch: 1, - * touches: [], - * changed: [touch0, touch1] - * } - */ - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 1f, - locationY = 3f, - time = GESTURE_START_TIME, - pointerId = 0, - touches = emptyList(), - changedTouches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), - ), - buildGestureEvent( - surfaceId = SURFACE_ID, - viewTag = TARGET_VIEW_ID, - locationX = 2f, - locationY = 1f, - time = GESTURE_START_TIME, - pointerId = 1, - touches = emptyList(), - changedTouches = - listOf( - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0), - buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), - ), + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 2f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), ), - ) + ), + /* + * CANCEL event: + * { + * touch: 0, + * touches: [], + * changed: [touch0, touch1] + * } + * { + * touch: 1, + * touches: [], + * changed: [touch0, touch1] + * } + */ + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 1f, + locationY = 3f, + time = GESTURE_START_TIME, + pointerId = 0, + touches = emptyList(), + changedTouches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + ), + buildGestureEvent( + surfaceId = SURFACE_ID, + viewTag = TARGET_VIEW_ID, + locationX = 2f, + locationY = 1f, + time = GESTURE_START_TIME, + pointerId = 1, + touches = emptyList(), + changedTouches = + listOf( + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 1f, 3f, GESTURE_START_TIME, 0), + buildGesture(SURFACE_ID, TARGET_VIEW_ID, 2f, 1f, GESTURE_START_TIME, 1), + ), + ), + ) private lateinit var eventDispatcher: EventDispatcher private lateinit var eventEmitter: FabricEventEmitter diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/model/ReactModuleInfoTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/model/ReactModuleInfoTest.kt index 85576444a541..22ffbd76bbb2 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/model/ReactModuleInfoTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/model/ReactModuleInfoTest.kt @@ -16,15 +16,14 @@ class ReactModuleInfoTest { @Test fun testCreateReactModuleInfo() { - val reactModuleInfo = - ReactModuleInfo( - /* name = */ "name", - /* className = */ "class", - /* canOverrideExistingModule = */ false, - /* needsEagerInit = */ false, - /* isCxxModule = */ false, - /* isTurboModule = */ false, - ) + val reactModuleInfo = ReactModuleInfo( + /* name = */ "name", + /* className = */ "class", + /* canOverrideExistingModule = */ false, + /* needsEagerInit = */ false, + /* isCxxModule = */ false, + /* isTurboModule = */ false, + ) assertThat(reactModuleInfo.name).isEqualTo("name") assertThat(reactModuleInfo.canOverrideExistingModule).isFalse() assertThat(reactModuleInfo.needsEagerInit).isFalse() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt index 4adbd86b64e1..29a1acedb893 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/network/NetworkingModuleTest.kt @@ -225,11 +225,10 @@ class NetworkingModuleTest { @Test fun testHeaders() { - val headers = - listOf( - JavaOnlyArray.of("Accept", "text/plain"), - JavaOnlyArray.of("User-Agent", "React test agent/1.0"), - ) + val headers = listOf( + JavaOnlyArray.of("Accept", "text/plain"), + JavaOnlyArray.of("User-Agent", "React test agent/1.0"), + ) networkingModule.sendRequest( "GET", @@ -371,12 +370,11 @@ class NetworkingModuleTest { @Test fun testMultipartPostRequestHeaders() { setupRequestBodyUtil() - val headers = - listOf( - JavaOnlyArray.of("Accept", "text/plain"), - JavaOnlyArray.of("User-Agent", "React test agent/1.0"), - JavaOnlyArray.of("content-type", "multipart/form-data"), - ) + val headers = listOf( + JavaOnlyArray.of("Accept", "text/plain"), + JavaOnlyArray.of("User-Agent", "React test agent/1.0"), + JavaOnlyArray.of("content-type", "multipart/form-data"), + ) val body = JavaOnlyMap() val formData = JavaOnlyArray() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactHostDelegateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactHostDelegateTest.kt index f3aefed9303f..e3081d5b4d90 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactHostDelegateTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/runtime/ReactHostDelegateTest.kt @@ -36,13 +36,12 @@ class ReactHostDelegateTest { Mockito.mock(ReactPackageTurboModuleManagerDelegate.Builder::class.java) val hermesInstance: JSRuntimeFactory = Mockito.mock(HermesInstance::class.java) val jsMainModulePathMocked = "mockedJSMainModulePath" - val delegate = - DefaultReactHostDelegate( - jsMainModulePath = jsMainModulePathMocked, - jsBundleLoader = jsBundleLoader, - jsRuntimeFactory = hermesInstance, - turboModuleManagerDelegateBuilder = turboModuleManagerDelegateBuilderMock, - ) + val delegate = DefaultReactHostDelegate( + jsMainModulePath = jsMainModulePathMocked, + jsBundleLoader = jsBundleLoader, + jsRuntimeFactory = hermesInstance, + turboModuleManagerDelegateBuilder = turboModuleManagerDelegateBuilderMock, + ) assertThat(delegate.jsMainModulePath).isEqualTo(jsMainModulePathMocked) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BorderRadiusStyleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BorderRadiusStyleTest.kt index 4a002f15fb8c..7f15a7fb1c05 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BorderRadiusStyleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BorderRadiusStyleTest.kt @@ -28,37 +28,36 @@ class BorderRadiusStyleTest { @Test fun testCorrectPriorityLTR() { - val propertyOrderMap = - mapOf( - ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_LEFT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, - BorderRadiusProp.BORDER_TOP_START_RADIUS, - BorderRadiusProp.BORDER_START_START_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_RIGHT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, - BorderRadiusProp.BORDER_TOP_END_RADIUS, - BorderRadiusProp.BORDER_START_END_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_LEFT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, - BorderRadiusProp.BORDER_END_START_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_RIGHT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, - BorderRadiusProp.BORDER_END_END_RADIUS, - ), - ) + val propertyOrderMap = mapOf( + ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_LEFT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, + BorderRadiusProp.BORDER_TOP_START_RADIUS, + BorderRadiusProp.BORDER_START_START_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_RIGHT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, + BorderRadiusProp.BORDER_TOP_END_RADIUS, + BorderRadiusProp.BORDER_START_END_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_LEFT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, + BorderRadiusProp.BORDER_END_START_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_RIGHT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, + BorderRadiusProp.BORDER_END_END_RADIUS, + ), + ) propertyOrderMap.forEach { order -> val borderRadiusStyle = BorderRadiusStyle() @@ -81,37 +80,36 @@ class BorderRadiusStyleTest { @Test fun testCorrectPriorityRTL() { setContextLeftAndRightSwap(ctx, true) - val propertyOrderMap = - mapOf( - ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_LEFT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, - BorderRadiusProp.BORDER_TOP_END_RADIUS, - BorderRadiusProp.BORDER_START_END_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_RIGHT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, - BorderRadiusProp.BORDER_TOP_START_RADIUS, - BorderRadiusProp.BORDER_START_START_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_LEFT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, - BorderRadiusProp.BORDER_END_END_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_RIGHT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, - BorderRadiusProp.BORDER_END_START_RADIUS, - ), - ) + val propertyOrderMap = mapOf( + ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_LEFT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, + BorderRadiusProp.BORDER_TOP_END_RADIUS, + BorderRadiusProp.BORDER_START_END_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_RIGHT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, + BorderRadiusProp.BORDER_TOP_START_RADIUS, + BorderRadiusProp.BORDER_START_START_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_LEFT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, + BorderRadiusProp.BORDER_END_END_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_RIGHT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, + BorderRadiusProp.BORDER_END_START_RADIUS, + ), + ) propertyOrderMap.forEach { order -> val borderRadiusStyle = BorderRadiusStyle() @@ -129,37 +127,36 @@ class BorderRadiusStyleTest { @Test fun testCorrectPriorityRTLNoSwap() { setContextLeftAndRightSwap(ctx, false) - val propertyOrderMap = - mapOf( - ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_LEFT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, - BorderRadiusProp.BORDER_TOP_END_RADIUS, - BorderRadiusProp.BORDER_START_END_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_RIGHT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, - BorderRadiusProp.BORDER_TOP_START_RADIUS, - BorderRadiusProp.BORDER_START_START_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_LEFT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, - BorderRadiusProp.BORDER_END_END_RADIUS, - ), - ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_RIGHT_RADIUS to - arrayOf( - BorderRadiusProp.BORDER_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, - BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, - BorderRadiusProp.BORDER_END_START_RADIUS, - ), - ) + val propertyOrderMap = mapOf( + ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_LEFT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, + BorderRadiusProp.BORDER_TOP_END_RADIUS, + BorderRadiusProp.BORDER_START_END_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_RIGHT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, + BorderRadiusProp.BORDER_TOP_START_RADIUS, + BorderRadiusProp.BORDER_START_START_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_LEFT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_START_RADIUS, + BorderRadiusProp.BORDER_END_END_RADIUS, + ), + ComputedBorderRadiusProp.COMPUTED_BORDER_BOTTOM_RIGHT_RADIUS to + arrayOf( + BorderRadiusProp.BORDER_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, + BorderRadiusProp.BORDER_BOTTOM_END_RADIUS, + BorderRadiusProp.BORDER_END_START_RADIUS, + ), + ) propertyOrderMap.forEach { order -> val borderRadiusStyle = BorderRadiusStyle() @@ -176,13 +173,12 @@ class BorderRadiusStyleTest { @Test fun testBorderRadiusPercentages() { - val borderRadiusStyle = - BorderRadiusStyle( - topLeft = LengthPercentage(0f, LengthPercentageType.PERCENT), - topRight = LengthPercentage(10f, LengthPercentageType.PERCENT), - bottomLeft = LengthPercentage(20f, LengthPercentageType.PERCENT), - bottomRight = LengthPercentage(30f, LengthPercentageType.PERCENT), - ) + val borderRadiusStyle = BorderRadiusStyle( + topLeft = LengthPercentage(0f, LengthPercentageType.PERCENT), + topRight = LengthPercentage(10f, LengthPercentageType.PERCENT), + bottomLeft = LengthPercentage(20f, LengthPercentageType.PERCENT), + bottomRight = LengthPercentage(30f, LengthPercentageType.PERCENT), + ) val resolved = borderRadiusStyle.resolve(0, context = ctx, width = 1000f, height = 1000f) assertThat(resolved.topLeft).isEqualTo(CornerRadii(0f, 0f)) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/JSPointerDispatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/JSPointerDispatcherTest.kt index 63279df820b1..21041d1c773f 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/JSPointerDispatcherTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/JSPointerDispatcherTest.kt @@ -74,12 +74,11 @@ class JSPointerDispatcherTest { @Test fun testPointerEnter() { val childRect = getChildViewRectInRootCoordinates(0) - val ev = - createMotionEvent( - MotionEvent.ACTION_DOWN, - childRect.centerX().toFloat(), - childRect.centerY().toFloat(), - ) + val ev = createMotionEvent( + MotionEvent.ACTION_DOWN, + childRect.centerX().toFloat(), + childRect.centerY().toFloat(), + ) val mockDispatcher: EventDispatcher = mock() pointerDispatcher.handleMotionEvent(ev, mockDispatcher, false) verify(mockDispatcher).dispatchEvent(argThat(EventWithName(PointerEventHelper.POINTER_DOWN))) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/MatrixMathHelperTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/MatrixMathHelperTest.kt index 27a03f9d12e7..969409489e96 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/MatrixMathHelperTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/MatrixMathHelperTest.kt @@ -126,67 +126,64 @@ class MatrixMathHelperTest { @Test fun testMultiplyInto() { - val matrixA = - doubleArrayOf( - 1.0, - 2.0, - 3.0, - 4.0, - 5.0, - 6.0, - 7.0, - 8.0, - 9.0, - 10.0, - 11.0, - 12.0, - 13.0, - 14.0, - 15.0, - 16.0, - ) - val matrixB = - doubleArrayOf( - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - ) + val matrixA = doubleArrayOf( + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 6.0, + 7.0, + 8.0, + 9.0, + 10.0, + 11.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + ) + val matrixB = doubleArrayOf( + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + ) val result = DoubleArray(16) MatrixMathHelper.multiplyInto(result, matrixA, matrixB) - val expected = - doubleArrayOf( - 2.0, - 4.0, - 6.0, - 8.0, - 10.0, - 12.0, - 14.0, - 16.0, - 18.0, - 20.0, - 22.0, - 24.0, - 26.0, - 28.0, - 30.0, - 32.0, - ) + val expected = doubleArrayOf( + 2.0, + 4.0, + 6.0, + 8.0, + 10.0, + 12.0, + 14.0, + 16.0, + 18.0, + 20.0, + 22.0, + 24.0, + 26.0, + 28.0, + 30.0, + 32.0, + ) assertThat(result).containsExactly(*expected) } @@ -195,72 +192,69 @@ class MatrixMathHelperTest { fun testCreateIdentityMatrix() { val identity = MatrixMathHelper.createIdentityMatrix() - val expected = - doubleArrayOf( - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ) + val expected = doubleArrayOf( + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ) assertThat(identity).containsExactly(*expected) } @Test fun testResetIdentityMatrix() { - val matrix = - doubleArrayOf( - 5.0, - 2.0, - 3.0, - 4.0, - 1.0, - 6.0, - 7.0, - 8.0, - 9.0, - 10.0, - 11.0, - 12.0, - 13.0, - 14.0, - 15.0, - 16.0, - ) + val matrix = doubleArrayOf( + 5.0, + 2.0, + 3.0, + 4.0, + 1.0, + 6.0, + 7.0, + 8.0, + 9.0, + 10.0, + 11.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + ) MatrixMathHelper.resetIdentityMatrix(matrix) - val expected = - doubleArrayOf( - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ) + val expected = doubleArrayOf( + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ) assertThat(matrix).containsExactly(*expected) } @@ -270,118 +264,113 @@ class MatrixMathHelperTest { val identityMatrix = MatrixMathHelper.createIdentityMatrix() assertThat(MatrixMathHelper.determinant(identityMatrix)).isEqualTo(1.0) - val matrix = - doubleArrayOf( - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - ) + val matrix = doubleArrayOf( + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + ) assertThat(MatrixMathHelper.determinant(matrix)).isEqualTo(16.0) } @Test fun testInverse() { - val matrix = - doubleArrayOf( - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - ) + val matrix = doubleArrayOf( + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + ) val inverse = MatrixMathHelper.inverse(matrix) - val expected = - doubleArrayOf( - 0.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.5, - 0.0, - 0.0, - 0.0, - 0.0, - 0.5, - ) + val expected = doubleArrayOf( + 0.5, + 0.0, + 0.0, + 0.0, + 0.0, + 0.5, + 0.0, + 0.0, + 0.0, + 0.0, + 0.5, + 0.0, + 0.0, + 0.0, + 0.0, + 0.5, + ) assertThat(inverse).containsExactly(*expected) } @Test fun testTranspose() { - val matrix = - doubleArrayOf( - 1.0, - 2.0, - 3.0, - 4.0, - 5.0, - 6.0, - 7.0, - 8.0, - 9.0, - 10.0, - 11.0, - 12.0, - 13.0, - 14.0, - 15.0, - 16.0, - ) + val matrix = doubleArrayOf( + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 6.0, + 7.0, + 8.0, + 9.0, + 10.0, + 11.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + ) val transposed = MatrixMathHelper.transpose(matrix) - val expected = - doubleArrayOf( - 1.0, - 5.0, - 9.0, - 13.0, - 2.0, - 6.0, - 10.0, - 14.0, - 3.0, - 7.0, - 11.0, - 15.0, - 4.0, - 8.0, - 12.0, - 16.0, - ) + val expected = doubleArrayOf( + 1.0, + 5.0, + 9.0, + 13.0, + 2.0, + 6.0, + 10.0, + 14.0, + 3.0, + 7.0, + 11.0, + 15.0, + 4.0, + 8.0, + 12.0, + 16.0, + ) assertThat(transposed).containsExactly(*expected) } @@ -389,25 +378,24 @@ class MatrixMathHelperTest { @Test fun testMultiplyVectorByMatrix() { val vector = doubleArrayOf(1.0, 2.0, 3.0, 1.0) - val matrix = - doubleArrayOf( - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ) + val matrix = doubleArrayOf( + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ) val result = DoubleArray(4) MatrixMathHelper.multiplyVectorByMatrix(vector, matrix, result) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt index 209133c2a048..005b0bf6d4c6 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt @@ -30,11 +30,10 @@ class ColorStopTest { @Test fun testBasicColorStops() { - val colorStops = - listOf( - ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), - ColorStop(Color.GREEN, LengthPercentage(42f, LengthPercentageType.PERCENT)), - ) + val colorStops = listOf( + ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), + ColorStop(Color.GREEN, LengthPercentage(42f, LengthPercentageType.PERCENT)), + ) val processed = ColorStopUtils.getFixedColorStops(colorStops, 60f) assertThat(processed).hasSize(2) @@ -46,12 +45,11 @@ class ColorStopTest { @Test fun testColorStopsWithFirstAndLastPositionsMissing() { - val colorStops = - listOf( - ColorStop(Color.RED), - ColorStop(Color.GREEN, LengthPercentage(30f, LengthPercentageType.PERCENT)), - ColorStop(Color.BLUE), - ) + val colorStops = listOf( + ColorStop(Color.RED), + ColorStop(Color.GREEN, LengthPercentage(30f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE), + ) val processed = ColorStopUtils.getFixedColorStops(colorStops, 80f) assertThat(processed).hasSize(3) @@ -65,14 +63,13 @@ class ColorStopTest { @Test fun testColorStopsWithLessPositionValueThanPreviousPosition() { - val colorStops = - listOf( - ColorStop(Color.RED), - ColorStop(Color.GREEN, LengthPercentage(30f, LengthPercentageType.PERCENT)), - ColorStop(Color.BLUE, LengthPercentage(20f, LengthPercentageType.PERCENT)), - ColorStop(Color.GRAY, LengthPercentage(60f, LengthPercentageType.PERCENT)), - ColorStop(Color.CYAN, LengthPercentage(50f, LengthPercentageType.PERCENT)), - ) + val colorStops = listOf( + ColorStop(Color.RED), + ColorStop(Color.GREEN, LengthPercentage(30f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE, LengthPercentage(20f, LengthPercentageType.PERCENT)), + ColorStop(Color.GRAY, LengthPercentage(60f, LengthPercentageType.PERCENT)), + ColorStop(Color.CYAN, LengthPercentage(50f, LengthPercentageType.PERCENT)), + ) val processed = ColorStopUtils.getFixedColorStops(colorStops, 80f) assertThat(processed).hasSize(5) @@ -90,13 +87,12 @@ class ColorStopTest { @Test fun testColorStopsWithMissingMiddlePositions() { - val colorStops = - listOf( - ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), - ColorStop(Color.GREEN), - ColorStop(Color.BLUE), - ColorStop(Color.TRANSPARENT, LengthPercentage(100f, LengthPercentageType.PERCENT)), - ) + val colorStops = listOf( + ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), + ColorStop(Color.GREEN), + ColorStop(Color.BLUE), + ColorStop(Color.TRANSPARENT, LengthPercentage(100f, LengthPercentageType.PERCENT)), + ) val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f) assertThat(processed).hasSize(4) @@ -112,11 +108,10 @@ class ColorStopTest { @Test fun testColorStopsWithMixedUnits() { - val colorStops = - listOf( - ColorStop(Color.YELLOW, LengthPercentage(100f, LengthPercentageType.POINT)), - ColorStop(Color.BLUE, LengthPercentage(50f, LengthPercentageType.PERCENT)), - ) + val colorStops = listOf( + ColorStop(Color.YELLOW, LengthPercentage(100f, LengthPercentageType.POINT)), + ColorStop(Color.BLUE, LengthPercentage(50f, LengthPercentageType.PERCENT)), + ) val processed200px = ColorStopUtils.getFixedColorStops(colorStops, 200f) assertThat(processed200px).hasSize(2) @@ -137,14 +132,13 @@ class ColorStopTest { @Test fun testColorStopsWithMultipleTransitionHints() { - val colorStops = - listOf( - ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), - ColorStop(null, LengthPercentage(10f, LengthPercentageType.PERCENT)), - ColorStop(Color.GREEN, LengthPercentage(50f, LengthPercentageType.PERCENT)), - ColorStop(null, LengthPercentage(85f, LengthPercentageType.PERCENT)), - ColorStop(Color.BLUE, LengthPercentage(100f, LengthPercentageType.PERCENT)), - ) + val colorStops = listOf( + ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), + ColorStop(null, LengthPercentage(10f, LengthPercentageType.PERCENT)), + ColorStop(Color.GREEN, LengthPercentage(50f, LengthPercentageType.PERCENT)), + ColorStop(null, LengthPercentage(85f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE, LengthPercentage(100f, LengthPercentageType.PERCENT)), + ) val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f) assertThat(processed.size).isEqualTo(21) assertThat(processed.first().color).isEqualTo(Color.RED) @@ -161,14 +155,13 @@ class ColorStopTest { @Test fun testColorStopsWithPositionedStopAdjacentToUnpositionedStop() { - val colorStops = - listOf( - ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), - ColorStop(Color.GREEN, LengthPercentage(20f, LengthPercentageType.PERCENT)), - ColorStop(Color.BLUE), - ColorStop(Color.YELLOW, LengthPercentage(80f, LengthPercentageType.PERCENT)), - ColorStop(Color.MAGENTA, LengthPercentage(100f, LengthPercentageType.PERCENT)), - ) + val colorStops = listOf( + ColorStop(Color.RED, LengthPercentage(0f, LengthPercentageType.PERCENT)), + ColorStop(Color.GREEN, LengthPercentage(20f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE), + ColorStop(Color.YELLOW, LengthPercentage(80f, LengthPercentageType.PERCENT)), + ColorStop(Color.MAGENTA, LengthPercentage(100f, LengthPercentageType.PERCENT)), + ) val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f) assertThat(processed).hasSize(5) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt index d99a2c8be1cd..f6e38cdcb250 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt @@ -225,22 +225,21 @@ class ReactTextInputPropertyTest { return } - val expectedHints = - listOf( - "2fa-app-otp" to HintConstants.AUTOFILL_HINT_2FA_APP_OTP, - "email-otp" to HintConstants.AUTOFILL_HINT_EMAIL_OTP, - "flight-confirmation-code" to HintConstants.AUTOFILL_HINT_FLIGHT_CONFIRMATION_CODE, - "flight-number" to HintConstants.AUTOFILL_HINT_FLIGHT_NUMBER, - "gift-card-number" to HintConstants.AUTOFILL_HINT_GIFT_CARD_NUMBER, - "gift-card-pin" to HintConstants.AUTOFILL_HINT_GIFT_CARD_PIN, - "loyalty-account-number" to HintConstants.AUTOFILL_HINT_LOYALTY_ACCOUNT_NUMBER, - "postal-address-dependent-locality" to - HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_DEPENDENT_LOCALITY, - "postal-address-unit" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_APT_NUMBER, - "promo-code" to HintConstants.AUTOFILL_HINT_PROMO_CODE, - "upi-vpa" to HintConstants.AUTOFILL_HINT_UPI_VPA, - "wifi-password" to HintConstants.AUTOFILL_HINT_WIFI_PASSWORD, - ) + val expectedHints = listOf( + "2fa-app-otp" to HintConstants.AUTOFILL_HINT_2FA_APP_OTP, + "email-otp" to HintConstants.AUTOFILL_HINT_EMAIL_OTP, + "flight-confirmation-code" to HintConstants.AUTOFILL_HINT_FLIGHT_CONFIRMATION_CODE, + "flight-number" to HintConstants.AUTOFILL_HINT_FLIGHT_NUMBER, + "gift-card-number" to HintConstants.AUTOFILL_HINT_GIFT_CARD_NUMBER, + "gift-card-pin" to HintConstants.AUTOFILL_HINT_GIFT_CARD_PIN, + "loyalty-account-number" to HintConstants.AUTOFILL_HINT_LOYALTY_ACCOUNT_NUMBER, + "postal-address-dependent-locality" to + HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_DEPENDENT_LOCALITY, + "postal-address-unit" to HintConstants.AUTOFILL_HINT_POSTAL_ADDRESS_APT_NUMBER, + "promo-code" to HintConstants.AUTOFILL_HINT_PROMO_CODE, + "upi-vpa" to HintConstants.AUTOFILL_HINT_UPI_VPA, + "wifi-password" to HintConstants.AUTOFILL_HINT_WIFI_PASSWORD, + ) expectedHints.forEach { (autoComplete, expectedHint) -> manager.updateProperties(view, buildStyles("autoComplete", autoComplete)) diff --git a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterActivity.kt b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterActivity.kt index 0ae099a8f379..776d86a6d869 100644 --- a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterActivity.kt +++ b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterActivity.kt @@ -67,12 +67,11 @@ internal class RNTesterActivity : ReactActivity() { maybeUpdateBackgroundColor() reactDelegate?.reactHost?.let { reactHost -> - val devMenuConfiguration = - DevMenuConfiguration( - devMenuEnabled = true, - shakeGestureEnabled = true, - keyboardShortcutsEnabled = true, - ) + val devMenuConfiguration = DevMenuConfiguration( + devMenuEnabled = true, + shakeGestureEnabled = true, + keyboardShortcutsEnabled = true, + ) reactHost.setDevMenuConfiguration(devMenuConfiguration) } diff --git a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt index 2a162c4c2493..49a940eb729d 100644 --- a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt +++ b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/RNTesterApplication.kt @@ -78,21 +78,21 @@ internal class RNTesterApplication : Application(), ReactApplication { ) add( object : ReactPackage, ViewManagerOnDemandReactPackage { - override fun getViewManagerNames(reactContext: ReactApplicationContext) = - listOf( - "RNTMyNativeView", - "RNTMyLegacyNativeView", - "RNTReportFullyDrawnView", - ) + override fun getViewManagerNames( + reactContext: ReactApplicationContext + ) = listOf( + "RNTMyNativeView", + "RNTMyLegacyNativeView", + "RNTReportFullyDrawnView", + ) override fun createViewManagers( reactContext: ReactApplicationContext - ): List> = - listOf( - MyNativeViewManager(), - MyLegacyViewManager(reactContext), - ReportFullyDrawnViewManager(), - ) + ): List> = listOf( + MyNativeViewManager(), + MyLegacyViewManager(reactContext), + ReportFullyDrawnViewManager(), + ) override fun createViewManager( reactContext: ReactApplicationContext, diff --git a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/component/MyLegacyViewManager.kt b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/component/MyLegacyViewManager.kt index 9b3e53325223..82a3916b6e2e 100644 --- a/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/component/MyLegacyViewManager.kt +++ b/packages/rn-tester/android/app/src/main/java/com/facebook/react/uiapp/component/MyLegacyViewManager.kt @@ -85,12 +85,11 @@ internal class MyLegacyViewManager(reactContext: ReactApplicationContext) : } } - override fun getCommandsMap(): Map = - mapOf( - "changeBackgroundColor" to COMMAND_CHANGE_BACKGROUND_COLOR, - "addOverlays" to COMMAND_ADD_OVERLAYS, - "removeOverlays" to COMMAND_REMOVE_OVERLAYS, - ) + override fun getCommandsMap(): Map = mapOf( + "changeBackgroundColor" to COMMAND_CHANGE_BACKGROUND_COLOR, + "addOverlays" to COMMAND_ADD_OVERLAYS, + "removeOverlays" to COMMAND_REMOVE_OVERLAYS, + ) companion object { const val REACT_CLASS = "RNTMyLegacyNativeView" diff --git a/private/react-native-fantom/build.gradle.kts b/private/react-native-fantom/build.gradle.kts index f23992c64692..69c1a037fdae 100644 --- a/private/react-native-fantom/build.gradle.kts +++ b/private/react-native-fantom/build.gradle.kts @@ -180,24 +180,23 @@ val configureFantomTester by workingDir(testerDir) inputs.dir(testerDir) outputs.files(testerBuildOutputFileTree) - val cmdArgs = - mutableListOf( - cmakeBinaryPath, - // Suppress all warnings as this is the Hermes build and we can't fix them. - "--log-level=ERROR", - "-S", - ".", - "-B", - testerBuildDir.toString(), - "-DCMAKE_BUILD_TYPE=Debug", - "-DFANTOM_CODEGEN_DIR=$buildDir/codegen", - "-DFANTOM_THIRD_PARTY_DIR=$buildDir/third-party", - "-DREACT_ANDROID_DIR=$reactAndroidDir", - "-DREACT_COMMON_DIR=$reactNativeDir/ReactCommon", - "-DREACT_CXX_PLATFORM_DIR=$reactNativeDir/ReactCxxPlatform", - "-DREACT_THIRD_PARTY_NDK_DIR=$reactAndroidBuildDir/third-party-ndk", - "-DRN_ENABLE_DEBUG_STRING_CONVERTIBLE=ON", - ) + val cmdArgs = mutableListOf( + cmakeBinaryPath, + // Suppress all warnings as this is the Hermes build and we can't fix them. + "--log-level=ERROR", + "-S", + ".", + "-B", + testerBuildDir.toString(), + "-DCMAKE_BUILD_TYPE=Debug", + "-DFANTOM_CODEGEN_DIR=$buildDir/codegen", + "-DFANTOM_THIRD_PARTY_DIR=$buildDir/third-party", + "-DREACT_ANDROID_DIR=$reactAndroidDir", + "-DREACT_COMMON_DIR=$reactNativeDir/ReactCommon", + "-DREACT_CXX_PLATFORM_DIR=$reactNativeDir/ReactCxxPlatform", + "-DREACT_THIRD_PARTY_NDK_DIR=$reactAndroidBuildDir/third-party-ndk", + "-DRN_ENABLE_DEBUG_STRING_CONVERTIBLE=ON", + ) cmdArgs.add("-DHERMES_V1_ENABLED=1") From 798b550dd4724e399d64522de19502b88ba09ff9 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 13 Jul 2026 09:02:22 -0700 Subject: [PATCH 042/834] Enable/mark enableBridgelessArchitecture flag as stable (#57529) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57529 The New Arch has been Stage 4 / rolled out since 0.76. Before cleaning up this flag completely, defensively flip its default value to `true`. Changelog: [Internal] - Net flag value is the same from all entry points Reviewed By: rozele, cortinico Differential Revision: D111703112 fbshipit-source-id: d3d08a75a949780899a701b8d3830fdf0fc4a45a --- .../featureflags/ReactNativeFeatureFlagsDefaults.kt | 4 ++-- ...ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt | 4 +--- .../ReactNativeNewArchitectureFeatureFlagsDefaults.kt | 2 -- .../jsinspector-modern/tests/JsiIntegrationTest.cpp | 2 +- .../react/featureflags/ReactNativeFeatureFlagsDefaults.h | 4 ++-- .../ReactNativeFeatureFlagsOverridesOSSCanary.h | 6 +----- .../ReactNativeFeatureFlagsOverridesOSSStable.h | 4 ---- .../scripts/featureflags/ReactNativeFeatureFlags.config.js | 4 ++-- .../src/private/featureflags/ReactNativeFeatureFlags.js | 4 ++-- 9 files changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index 3a4142d20d16..91f9ac37c7ec 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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<<5c36c0a5e9e67b43016ab02eab0ecd8f>> + * @generated SignedSource<<9b295106ef57d7fa85846c57bb74a090>> */ /** @@ -51,7 +51,7 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enableAndroidTextMeasurementOptimizations(): Boolean = false - override fun enableBridgelessArchitecture(): Boolean = false + override fun enableBridgelessArchitecture(): Boolean = true override fun enableCppPropsIteratorSetter(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt index 793f302d6c72..aceff9047196 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt @@ -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<<1664bdb6cd521d608b9bab74df43eb76>> */ /** @@ -27,8 +27,6 @@ public open class ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android : ReactN override fun enableAccessibilityOrder(): Boolean = true - override fun enableBridgelessArchitecture(): Boolean = true - override fun enableIntersectionObserverByDefault(): Boolean = true override fun enableSwiftUIBasedFilters(): Boolean = true diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeNewArchitectureFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeNewArchitectureFeatureFlagsDefaults.kt index 87b5088789fe..215266039888 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeNewArchitectureFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeNewArchitectureFeatureFlagsDefaults.kt @@ -23,8 +23,6 @@ package com.facebook.react.internal.featureflags public open class ReactNativeNewArchitectureFeatureFlagsDefaults() : ReactNativeFeatureFlagsDefaults() { - override fun enableBridgelessArchitecture(): Boolean = true - override fun useNativeViewConfigsInBridgelessMode(): Boolean = true override fun useTurboModuleInterop(): Boolean = true diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp index 11871ddbf2c1..6399ec18e8e4 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp @@ -360,7 +360,7 @@ TYPED_TEST(JsiIntegrationPortableTest, ReactNativeApplicationEnable) { "integrationName": "JsiIntegrationTest", "unstable_frameRecordingEnabled": false, "unstable_isProfilingBuild": false, - "unstable_networkInspectionEnabled": false + "unstable_networkInspectionEnabled": true } })")); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index e9403a7c6ebd..ca9ab82ce1ec 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<2c09412cdf8edaf4f7d6d90a446ce6fa>> */ /** @@ -84,7 +84,7 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { } bool enableBridgelessArchitecture() override { - return false; + return true; } bool enableCppPropsIteratorSetter() override { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h index a307c7a14436..c12a45156f2a 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h @@ -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<<5d286510a4f8642adcc9d38556dd60dd>> + * @generated SignedSource<<8f83d4d3c457e0f6acda799cfe58144a>> */ /** @@ -35,10 +35,6 @@ class ReactNativeFeatureFlagsOverridesOSSCanary : public ReactNativeFeatureFlags return true; } - bool enableBridgelessArchitecture() override { - return true; - } - bool enableIntersectionObserverByDefault() override { return true; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h index e40b317738e0..d60006ec86c3 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h @@ -13,10 +13,6 @@ namespace facebook::react { class ReactNativeFeatureFlagsOverridesOSSStable : public ReactNativeFeatureFlagsDefaults { public: - bool enableBridgelessArchitecture() override - { - return true; - } bool useNativeViewConfigsInBridgelessMode() override { return true; diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 792f79594b2b..7fa01e39e353 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -203,13 +203,13 @@ const definitions: FeatureFlagDefinitions = { ossReleaseStage: 'none', }, enableBridgelessArchitecture: { - defaultValue: false, + defaultValue: true, metadata: { description: 'Feature flag to enable the new bridgeless architecture.', expectedReleaseValue: true, purpose: 'release', }, - ossReleaseStage: 'canary', + ossReleaseStage: 'stable', }, enableCppPropsIteratorSetter: { defaultValue: false, diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 806a8ad0fcfd..e5eea64c6e37 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<36a98575851fdf73cb45a0d34c3a8755>> * @flow strict * @noformat */ @@ -270,7 +270,7 @@ export const enableAndroidTextMeasurementOptimizations: Getter = create /** * Feature flag to enable the new bridgeless architecture. */ -export const enableBridgelessArchitecture: Getter = createNativeFlagGetter('enableBridgelessArchitecture', false); +export const enableBridgelessArchitecture: Getter = createNativeFlagGetter('enableBridgelessArchitecture', true); /** * Enable prop iterator setter-style construction of Props in C++ (this flag is not used in Java). */ From 4970200a192197687a6b7e94abde88374ced2d99 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 13 Jul 2026 09:02:22 -0700 Subject: [PATCH 043/834] Decouple network inspection from enableBridgelessArchitecture (#57530) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57530 Replaces D111696522. Since bridgeless is stable/default-true, this is behavior-preserving. This feature also never strictly depended upon Bridgeless. Changelog: [Internal] Reviewed By: rubennorte Differential Revision: D111703862 fbshipit-source-id: aaf94b4abd41b009950242b035e71d659b895bd2 --- .../react/internal/featureflags/ReactNativeFeatureFlags.kt | 4 ++-- .../ReactCommon/jsinspector-modern/InspectorFlags.cpp | 1 - .../tests/utils/InspectorFlagOverridesGuard.cpp | 7 ------- .../react/featureflags/ReactNativeFeatureFlags.h | 4 ++-- .../scripts/featureflags/ReactNativeFeatureFlags.config.js | 2 +- .../src/private/featureflags/ReactNativeFeatureFlags.js | 4 ++-- 6 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 596ec7484e85..04d0b80b3c64 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -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<<854bb2a573f2c69d495aa1721f903ab0>> */ /** @@ -379,7 +379,7 @@ public object ReactNativeFeatureFlags { public fun fuseboxFrameRecordingEnabled(): Boolean = accessor.fuseboxFrameRecordingEnabled() /** - * Enable network inspection support in the React Native DevTools CDP backend. Requires `enableBridgelessArchitecture`. This flag is global and should not be changed across React Host lifetimes. + * Enable network inspection support in the React Native DevTools CDP backend. This flag is global and should not be changed across React Host lifetimes. */ @JvmStatic public fun fuseboxNetworkInspectionEnabled(): Boolean = accessor.fuseboxNetworkInspectionEnabled() diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorFlags.cpp b/packages/react-native/ReactCommon/jsinspector-modern/InspectorFlags.cpp index 6c64e2f1393c..59cae70d8b70 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/InspectorFlags.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorFlags.cpp @@ -82,7 +82,6 @@ const InspectorFlags::Values& InspectorFlags::loadFlagsAndAssertUnchanged() false, #endif .networkInspectionEnabled = - ReactNativeFeatureFlags::enableBridgelessArchitecture() && ReactNativeFeatureFlags::fuseboxNetworkInspectionEnabled(), .perfIssuesEnabled = ReactNativeFeatureFlags::perfIssuesEnabled(), }; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/utils/InspectorFlagOverridesGuard.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/utils/InspectorFlagOverridesGuard.cpp index 17a83c994139..6dcdcd696991 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/utils/InspectorFlagOverridesGuard.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/utils/InspectorFlagOverridesGuard.cpp @@ -46,13 +46,6 @@ class ReactNativeFeatureFlagsOverrides ReactNativeFeatureFlagsDefaults::fuseboxNetworkInspectionEnabled()); } - bool enableBridgelessArchitecture() override { - // NOTE: Network support is gated by (enableBridgelessArchitecture && - // fuseboxNetworkInspectionEnabled). - return overrides_.networkInspectionEnabled.value_or( - ReactNativeFeatureFlagsDefaults::enableBridgelessArchitecture()); - } - bool enableNetworkEventReporting() override { return overrides_.enableNetworkEventReporting.value_or( ReactNativeFeatureFlagsDefaults::enableNetworkEventReporting()); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index ec0b8ce66b86..a2ca86c275a7 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -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<<5c7e2061344bf998e5fc6b621af2ff45>> + * @generated SignedSource<<4276ae19d55acb679c9e635122e813b3>> */ /** @@ -330,7 +330,7 @@ class ReactNativeFeatureFlags { RN_EXPORT static bool fuseboxFrameRecordingEnabled(); /** - * Enable network inspection support in the React Native DevTools CDP backend. Requires `enableBridgelessArchitecture`. This flag is global and should not be changed across React Host lifetimes. + * Enable network inspection support in the React Native DevTools CDP backend. This flag is global and should not be changed across React Host lifetimes. */ RN_EXPORT static bool fuseboxNetworkInspectionEnabled(); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 7fa01e39e353..aaef339af772 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -673,7 +673,7 @@ const definitions: FeatureFlagDefinitions = { metadata: { dateAdded: '2024-01-31', description: - 'Enable network inspection support in the React Native DevTools CDP backend. Requires `enableBridgelessArchitecture`. This flag is global and should not be changed across React Host lifetimes.', + 'Enable network inspection support in the React Native DevTools CDP backend. This flag is global and should not be changed across React Host lifetimes.', expectedReleaseValue: true, purpose: 'experimentation', }, diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index e5eea64c6e37..a9c690414348 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<36a98575851fdf73cb45a0d34c3a8755>> + * @generated SignedSource<<71e307d568421d49e3943f58c4fcb163>> * @flow strict * @noformat */ @@ -444,7 +444,7 @@ export const fuseboxEnabledRelease: Getter = createNativeFlagGetter('fu */ export const fuseboxFrameRecordingEnabled: Getter = createNativeFlagGetter('fuseboxFrameRecordingEnabled', false); /** - * Enable network inspection support in the React Native DevTools CDP backend. Requires `enableBridgelessArchitecture`. This flag is global and should not be changed across React Host lifetimes. + * Enable network inspection support in the React Native DevTools CDP backend. This flag is global and should not be changed across React Host lifetimes. */ export const fuseboxNetworkInspectionEnabled: Getter = createNativeFlagGetter('fuseboxNetworkInspectionEnabled', true); /** From 4be68ef785b9b0e8f6a2c9e2247ff51f7e9b8163 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Mon, 13 Jul 2026 09:02:22 -0700 Subject: [PATCH 044/834] Promote fuseboxFrameRecordingEnabled to experimental (#57531) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57531 This feature is Stage 3 and intends to join the canary channel in the next release. In the meantime, opt this into the experimental channel. Changelog: [General][Added] - **React Native DevTools**: Experimental performance screenshots support and `Page.captureScreenshot` is added to the [`EXPERIMENTAL` channel](https://reactnative.dev/docs/releases/release-levels) Reviewed By: rubennorte Differential Revision: D111703888 fbshipit-source-id: 492012e36de324a00984b78924834a7937993f5e --- ...FeatureFlagsOverrides_RNOSS_Experimental_Android.kt | 6 +++++- .../ReactNativeFeatureFlagsOverridesOSSExperimental.h | 10 +++++++++- .../featureflags/ReactNativeFeatureFlags.config.js | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt index 14a604ced140..51aa291410be 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android.kt @@ -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<<7103d9c2fa3e2f152ce60b5dff12c0e4>> + * @generated SignedSource<> */ /** @@ -29,6 +29,10 @@ public open class ReactNativeFeatureFlagsOverrides_RNOSS_Experimental_Android : override fun enableSchedulerDelegateInvalidation(): Boolean = true + override fun fuseboxFrameRecordingEnabled(): Boolean = true + + override fun fuseboxScreenshotCaptureEnabled(): Boolean = true + override fun preventShadowTreeCommitExhaustion(): Boolean = true override fun useSharedAnimatedBackend(): Boolean = true diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h index 20ffb27c3e7a..1b1667bb726d 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSExperimental.h @@ -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<<7341b28bb77aeeec670051149faeae04>> + * @generated SignedSource<> */ /** @@ -39,6 +39,14 @@ class ReactNativeFeatureFlagsOverridesOSSExperimental : public ReactNativeFeatur return true; } + bool fuseboxFrameRecordingEnabled() override { + return true; + } + + bool fuseboxScreenshotCaptureEnabled() override { + return true; + } + bool preventShadowTreeCommitExhaustion() override { return true; } diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index aaef339af772..54ffc48eefc9 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -666,7 +666,7 @@ const definitions: FeatureFlagDefinitions = { expectedReleaseValue: true, purpose: 'experimentation', }, - ossReleaseStage: 'none', + ossReleaseStage: 'experimental', }, fuseboxNetworkInspectionEnabled: { defaultValue: true, @@ -688,7 +688,7 @@ const definitions: FeatureFlagDefinitions = { expectedReleaseValue: true, purpose: 'experimentation', }, - ossReleaseStage: 'none', + ossReleaseStage: 'experimental', }, optimizedAnimatedPropUpdates: { defaultValue: false, From e7bb11ae162411ece7569603f1628d24e08a2616 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Jul 2026 10:51:21 -0700 Subject: [PATCH 045/834] Fix RNTesterUnitTests build by importing react/bridging/ArrayBuffer.h (#57518) Summary: Imports `react/bridging/ArrayBuffer.h` in `RCTTurboModuleArrayBufferTests.mm`, so `RNTesterUnitTests` compiles and `yarn test-ios` works on main again. The test uses `detail::OwnedBytesBuffer`, which lives in `facebook::react::detail` in that header, but never imports it, and nothing in its include chain provides it, so `detail` resolves to `facebook::jsi::detail` and the target fails to compile before running a single test. The file already has `using namespace facebook::react;`, so the import is the entire fix. CI stays green because the `test_ios_rntester` jobs only build the app scheme and never compile `RNTesterUnitTests`, which means this file has never compiled in the repo since it landed in 54b88d9dea5, and the `yarn test-ios` flow the contributing docs point at is the only thing that compiles it. ``` RCTTurboModuleArrayBufferTests.mm:294:42: error: use of undeclared identifier 'detail'; did you mean 'facebook::jsi::detail'? RCTTurboModuleArrayBufferTests.mm:294:50: error: no member named 'OwnedBytesBuffer' in namespace 'facebook::jsi::detail' ``` ## Changelog: [INTERNAL] [FIXED] - Fix RNTesterUnitTests build by importing react/bridging/ArrayBuffer.h in RCTTurboModuleArrayBufferTests Pull Request resolved: https://github.com/react/react-native/pull/57518 Test Plan: - Without the import, `xcodebuild build-for-testing -workspace RNTesterPods.xcworkspace -scheme RNTester -sdk iphonesimulator` fails with the errors above, zero tests run. With it, the same build succeeds with no errors in the file. - The patched file passes `clang-format --dry-run --Werror`. - With the target compiling, the suite executes for the first time: 162 tests, 0 failures, including all RCTTurboModuleArrayBufferTests cases passing. - Reproduced on GitHub-hosted macOS runners: the same build [fails on pristine main](https://github.com/ramonclaudio/react-native/actions/runs/29134707109) with the errors above and [succeeds with this change](https://github.com/ramonclaudio/react-native/actions/runs/29134707611). Reviewed By: javache Differential Revision: D111707925 Pulled By: cortinico fbshipit-source-id: ac21bcbcd55ba214b2d9f13796198ddcdbec12c1 --- .../RNTesterUnitTests/RCTTurboModuleArrayBufferTests.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTurboModuleArrayBufferTests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTurboModuleArrayBufferTests.mm index f890da052714..fd242c2de83d 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTTurboModuleArrayBufferTests.mm +++ b/packages/rn-tester/RNTesterUnitTests/RCTTurboModuleArrayBufferTests.mm @@ -11,6 +11,7 @@ #import #import #import +#import #import #import From fe511aba7cc9a5152092493eec38bc06a0b39c6d Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 13 Jul 2026 11:06:41 -0700 Subject: [PATCH 046/834] Bump Metro to 0.87.0 (#57534) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57534 Bump Metro to 0.87.0 Changelog: [General][Changed] Bump Metro to 0.87.0 Reviewed By: huntie Differential Revision: D111711509 fbshipit-source-id: 800fa83127d88db77948dab20b3edb574ecb5fc0 --- package.json | 4 +- packages/community-cli-plugin/package.json | 4 +- packages/metro-config/package.json | 4 +- packages/react-native/package.json | 4 +- private/core-cli-utils/package.json | 2 +- yarn.lock | 186 ++++++++++----------- 6 files changed, 102 insertions(+), 102 deletions(-) diff --git a/package.json b/package.json index 213f870072eb..53e361add704 100644 --- a/package.json +++ b/package.json @@ -101,8 +101,8 @@ "markdownlint-cli2": "^0.17.2", "markdownlint-rule-relative-links": "^3.0.0", "memfs": "^4.38.2", - "metro-babel-register": "^0.86.0", - "metro-transform-plugins": "^0.86.0", + "metro-babel-register": "^0.87.0", + "metro-transform-plugins": "^0.87.0", "micromatch": "^4.0.4", "node-fetch": "^2.2.0", "nullthrows": "^1.1.1", diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index a4eb7e16cf93..1bf9ab2c8aa4 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -35,11 +35,11 @@ "@react-native/dev-middleware": "0.87.0-main", "debug": "^4.4.0", "invariant": "^2.2.4", - "metro": "^0.86.0", + "metro": "^0.87.0", "semver": "^7.1.3" }, "devDependencies": { - "metro-resolver": "^0.86.0" + "metro-resolver": "^0.87.0" }, "peerDependencies": { "@react-native-community/cli": "*", diff --git a/packages/metro-config/package.json b/packages/metro-config/package.json index c8d3ab3251b8..b8703bd178db 100644 --- a/packages/metro-config/package.json +++ b/packages/metro-config/package.json @@ -37,7 +37,7 @@ "dependencies": { "@react-native/js-polyfills": "0.87.0-main", "@react-native/metro-babel-transformer": "0.87.0-main", - "metro-config": "^0.86.0", - "metro-runtime": "^0.86.0" + "metro-config": "^0.87.0", + "metro-runtime": "^0.87.0" } } diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 8b761ac0ff60..b1abfed6201f 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -161,8 +161,8 @@ "hermes-compiler": "0.0.0", "invariant": "^2.2.4", "memoize-one": "^5.0.0", - "metro-runtime": "^0.86.0", - "metro-source-map": "^0.86.0", + "metro-runtime": "^0.87.0", + "metro-source-map": "^0.87.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", diff --git a/private/core-cli-utils/package.json b/private/core-cli-utils/package.json index 46d3d49a676e..00d5dc76338f 100644 --- a/private/core-cli-utils/package.json +++ b/private/core-cli-utils/package.json @@ -11,7 +11,7 @@ "./version.js": "./src/public/version.js" }, "dependencies": { - "metro-babel-register": "^0.86.0" + "metro-babel-register": "^0.87.0" }, "devDependencies": {}, "engines": { diff --git a/yarn.lock b/yarn.lock index 305197286a82..293089ace771 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6512,10 +6512,10 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -metro-babel-register@^0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-babel-register/-/metro-babel-register-0.86.0.tgz#dd0d3b528311bc8010aca3fa6e49e528240223f9" - integrity sha512-fW7D7Ldob9/pY+kvw3At7+K4h7R4yxwaU/UeIw8pmRFfbtPxHWp0aFVCFlPdJiU+70YMEl7k2uAm8vnITQ3Dvg== +metro-babel-register@^0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-babel-register/-/metro-babel-register-0.87.0.tgz#2f8ed5b08dd60128bd16ba3e2166074d4f84e608" + integrity sha512-rEzGQGCjkiLEruObdJRN9pLxTQHbbWcUdiM5GVGLNBx7ux5SnIhRyQb5xqXEwNL2fAmf1GjEI01SFhV0EB12AQ== dependencies: "@babel/core" "^7.25.2" "@babel/plugin-proposal-export-namespace-from" "^7.18.9" @@ -6529,60 +6529,60 @@ metro-babel-register@^0.86.0: escape-string-regexp "^1.0.5" flow-enums-runtime "^0.0.6" -metro-babel-transformer@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.86.0.tgz#bf8f27c6ba7f02ab36e01e3c47371ab47b96b4f3" - integrity sha512-gHOqsttTUPwLo54YemxsuB12WPptFBrLh1bTMwKIqmTUwOi3/EZw1sZSMVJFrrX7Md2Q72iBVvIIOWl6A+cLzA== +metro-babel-transformer@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.87.0.tgz#90f32240050185fcaecdc9949923922de48b6543" + integrity sha512-IEn1K1FyY4J1sA5y6zqDjf2OkfmpTEqhZOeP6MJX8HepSW0cuHGw1m8bYOdv2adkG3XUE9dtM0csUs0gP/Xa5w== dependencies: "@babel/core" "^7.25.2" flow-enums-runtime "^0.0.6" hermes-parser "0.36.1" - metro-cache-key "0.86.0" + metro-cache-key "0.87.0" nullthrows "^1.1.1" -metro-cache-key@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.86.0.tgz#b6761fe64302f0e66896e27218a5d2958310c77c" - integrity sha512-tjWJw9TtHFelF6CHY5JItRHF6O5SITjmj+Wc414ElH+1+xSFE5Bv8Fiy4UAI4cFnd2CFUpAhU77hR1Uku98nNg== +metro-cache-key@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.87.0.tgz#800534d5aa5dfadf5f6b0d78ea2dc8a03fbbbb24" + integrity sha512-Q+MPt6jl0zQogr4Q02WaJK6HY+GtE5A0nzj8kIV1Owgrx6OMNvm6scPTr1SM/R4LpCE8EH/Y5qfbXQ84GHTr0Q== dependencies: flow-enums-runtime "^0.0.6" -metro-cache@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.86.0.tgz#5997b26ec68dcfda3cbded24bd1da80dd39dd8d4" - integrity sha512-Ldytn7WMnlCMZ+yrbYgptfuV/FPiMkw7YxSWWi10y20AENatkeU8bLz0COvJ7j9EmlzpzRoTgN/+A78ckhT2Pg== +metro-cache@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.87.0.tgz#23eddf1e61c034e05e616256565d45d96520e673" + integrity sha512-146vS1BMSKcp99jddOhFBfHwzUEWN35NrsnSJDF2sQQ0ZT5OsBcOjd574PM233TWEZISRJ5DOK+vokD+1ubx+w== dependencies: exponential-backoff "^3.1.1" flow-enums-runtime "^0.0.6" https-proxy-agent "^7.0.5" - metro-core "0.86.0" + metro-core "0.87.0" -metro-config@0.86.0, metro-config@^0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.86.0.tgz#2d2cbf6542f8bf9cd120a41a021a6333df65bb05" - integrity sha512-DGV/Wubnks8RzYv+C9QmjS95D5Csn3/t03CARwnLl02iY14VIlom38DHbsGjuquJK9OM7xpQEOtLIJ0zgwGNFw== +metro-config@0.87.0, metro-config@^0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.87.0.tgz#5559e1ed872b709ba8cc66464d020ca996019d72" + integrity sha512-yZ9QAIzWH9MxwrzwRlX/CBGRWOT14l7klSDYg8hdtSdnoUs5A7MQRdHE2KB9iHVzGQW5wgWM5aXJswNeWbSQPA== dependencies: connect "^3.6.5" flow-enums-runtime "^0.0.6" jest-validate "^29.7.0" - metro "0.86.0" - metro-cache "0.86.0" - metro-core "0.86.0" - metro-runtime "0.86.0" + metro "0.87.0" + metro-cache "0.87.0" + metro-core "0.87.0" + metro-runtime "0.87.0" -metro-core@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.86.0.tgz#5db511f32d573f6806b1124194e5e23c357c08f6" - integrity sha512-0YUc36rV6NyecX3/1TU1I6aH1FG22X5ebTFUEddIqkImBJ29a4f1oVQEdo2bRXhkcn/rmBzHv5SAUoWyQrXqxA== +metro-core@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.87.0.tgz#97b3f4e4c3f5a0caf533a83a2000ac9fbbca1260" + integrity sha512-yW57+pCOHRC/CJZ99GA2PTd+30dORwDAjUPRCokj91IWW5In9Jwtt2FB5wACrGO8P0GHTyVHdTwDNyZsNndUbA== dependencies: flow-enums-runtime "^0.0.6" lodash.throttle "^4.1.1" - metro-resolver "0.86.0" + metro-resolver "0.87.0" -metro-file-map@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.86.0.tgz#a21901036f311a282b0c4dcb2e0201959244264e" - integrity sha512-p4WQUInIocDBqNyHoqAj66K9CBHv3BC2tXqiergkI4iot7TQjrJ7QSlxSwLNkUjhfV02RDhE3RnIxwpFhosDuw== +metro-file-map@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.87.0.tgz#7ff958b189ea6cc67325c611cd897c5dc1d92be9" + integrity sha512-Dc57t8jsINwA90bbVlqaeDlxf1rVGgj5SmOEnOMbaHNUM/HCYYTJxPV8SRdOBwh7qTz/biO9vaQvBTjRBgbbsg== dependencies: debug "^4.4.0" fb-watchman "^2.0.0" @@ -6594,60 +6594,60 @@ metro-file-map@0.86.0: nullthrows "^1.1.1" walker "^1.0.7" -metro-minify-terser@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.86.0.tgz#e691e5c95996d98576da73018c55d0f512e37f52" - integrity sha512-EaT3GlVcjSiS1004cdt+hirI6VDPRCxqT5fXHTQBWbNotrdAGSt8x6T2GIeS56qJhf3K+1YK6/kWXAVgAdSFbw== +metro-minify-terser@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.87.0.tgz#2573f9bb7270ffdf88827116b0c083ea79aa28e7" + integrity sha512-tPa0O983PDutFu3LXbArRH5NduogcKrvW6fs9VHhksTKUA1iqDyoD1ZSj/Me52xJ6T9/9pOwIVyj9ulKc/zMkg== dependencies: flow-enums-runtime "^0.0.6" terser "^5.15.0" -metro-resolver@0.86.0, metro-resolver@^0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.86.0.tgz#c628108c394e05a5e7acb82d7bef115a70509f3b" - integrity sha512-2enjwy/f/6RAw/GITQcmPecOvvUEEyNa0sEHTG/JbU8YMyDJyHldCy6cqTNhi32WR1+xcEfRf7cWP727mXEsQg== +metro-resolver@0.87.0, metro-resolver@^0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.87.0.tgz#0ee08fb3ab126fe75e563a0969b57e568b3d4ed1" + integrity sha512-Xl3M9R3KToaHJvXlI2lSOxtYHitzxite+195DSi00HL9PcS7Xik5+3xlRjfkKb3FA86SZxYPj+WJwlcfgaZoxg== dependencies: flow-enums-runtime "^0.0.6" -metro-runtime@0.86.0, metro-runtime@^0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.86.0.tgz#8e5b7fdb4db5c39918260f99532f979d199f8f73" - integrity sha512-TLblPoCdKDdZKy3pjejpFkGnDYZLvoO0tRiRYzKFr9Rpen2QJuEoSy3DoYfpYVB0H2aMp4pvIQHdkGYnb5bTXA== +metro-runtime@0.87.0, metro-runtime@^0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.87.0.tgz#859f36f34a9867a1d3c35f18a73280b23593f249" + integrity sha512-XsXZkgEwI0ZMYSBfvOMAbenzwa60XlObXJ27g6/Khgrz9ESbiBbAsd7hR62G2jRBYOhGAzG61Gk22dpRJj/mdw== dependencies: "@babel/runtime" "^7.25.0" flow-enums-runtime "^0.0.6" -metro-source-map@0.86.0, metro-source-map@^0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.86.0.tgz#dbfb0a49ebb6caa1ae95ca0dc3f4a94ca0d7f443" - integrity sha512-3WKW4hH31tVbfP4vafYJQvRRZCzbvWKmYsNdpSgRjvGfxj+XGGd78B0hTpLB4cNR00bo2rnwtxm9ftnjnqQgrQ== +metro-source-map@0.87.0, metro-source-map@^0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.87.0.tgz#d8b9b101502d0a3c3f792b64571952e7efb5791e" + integrity sha512-31BrYqu1c2co93rF1LN9Pw+7g+BrfDyxJkNQWrYm+pfA/+eVYVumF7tFMHbXLePLmHfhvSgVvbK6su1Oyiw1ng== dependencies: "@babel/traverse" "^7.29.0" "@babel/types" "^7.29.0" flow-enums-runtime "^0.0.6" invariant "^2.2.4" - metro-symbolicate "0.86.0" + metro-symbolicate "0.87.0" nullthrows "^1.1.1" - ob1 "0.86.0" + ob1 "0.87.0" source-map "^0.5.6" vlq "^1.0.0" -metro-symbolicate@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.86.0.tgz#65567932810435837e1eaa60d6c2b32b9afe8646" - integrity sha512-DqbDRQ0BPfjv4LNdJtJZvYQoaDbySlV7h1hVy6H4cQVp1rsl7uqkxDQjUgwCVFZmZRgvH6w1h0mS14qpSLHZYA== +metro-symbolicate@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.87.0.tgz#fc0ed2408f52eda855e47cc6d65f0f98ad4a31a4" + integrity sha512-uOpTxAXu74N+RSujUZ78L6gjI6bDdnz6XuW+AIUNuubZDEQPIpaX0StzIb0GMeQWP6zfHOHwFrWPP5Iquu1GXw== dependencies: flow-enums-runtime "^0.0.6" invariant "^2.2.4" - metro-source-map "0.86.0" + metro-source-map "0.87.0" nullthrows "^1.1.1" source-map "^0.5.6" vlq "^1.0.0" -metro-transform-plugins@0.86.0, metro-transform-plugins@^0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.86.0.tgz#7445262ce8e991c537c8a61bf7d8740cb631ac98" - integrity sha512-dB6aqpiZZrFKJ0y6qKo0K6zq1Wk1kLggnvXV9hvhFfn5uOsMu11k5FiFgrqQ7bhSSR1O6LqnbV4c1oDC6UwQZg== +metro-transform-plugins@0.87.0, metro-transform-plugins@^0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.87.0.tgz#79003ea1861fd8224ba3ac2c21f3f2700eb6c62e" + integrity sha512-i8keUe9+BaSwMuQM26DGheElCpTtflAKIrSwJAm8ZsgDb50RAUQus+e6zt2suaJXJ1OxZa7vqgtqoZxdniM6Fw== dependencies: "@babel/core" "^7.25.2" "@babel/generator" "^7.29.1" @@ -6656,29 +6656,29 @@ metro-transform-plugins@0.86.0, metro-transform-plugins@^0.86.0: flow-enums-runtime "^0.0.6" nullthrows "^1.1.1" -metro-transform-worker@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.86.0.tgz#bf849348b98add1bbd3b02d21bf8c39df0fce6d3" - integrity sha512-cvUFxTKwFph/RUf3Yu+y5dCdEPnb3m7rvRHQXWuZgXHoeAdtICTb3W1arSr1TRl/eQh6rozUVw9C18tzUKboUw== +metro-transform-worker@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.87.0.tgz#38e8d4cf110d881d52b99c70a49fde887e8e2ef7" + integrity sha512-YftLzNJxCTYxEN5k4AzR8KYwiENTEuz30L+4QeoMrtDd+U8mDThZg/ArR3JVRd8LaikwPOjVAS5SP3xPJN0AaA== dependencies: "@babel/core" "^7.25.2" "@babel/generator" "^7.29.1" "@babel/parser" "^7.29.0" "@babel/types" "^7.29.0" flow-enums-runtime "^0.0.6" - metro "0.86.0" - metro-babel-transformer "0.86.0" - metro-cache "0.86.0" - metro-cache-key "0.86.0" - metro-minify-terser "0.86.0" - metro-source-map "0.86.0" - metro-transform-plugins "0.86.0" + metro "0.87.0" + metro-babel-transformer "0.87.0" + metro-cache "0.87.0" + metro-cache-key "0.87.0" + metro-minify-terser "0.87.0" + metro-source-map "0.87.0" + metro-transform-plugins "0.87.0" nullthrows "^1.1.1" -metro@0.86.0, metro@^0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/metro/-/metro-0.86.0.tgz#3074e5a97972e6e1c2854ea16e998504fd18310a" - integrity sha512-fAK2lVuCvbxrE+gKqVb1P1PVSija2fcPFcrUAI3d2gWnlZU5AgSUe38DWn45nFfm241e3nmdjHFT5FPF1p9PeA== +metro@0.87.0, metro@^0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/metro/-/metro-0.87.0.tgz#ebdc81166213dc0efc894d816d38e16b5f82892f" + integrity sha512-fRqFhSzQhLNQSCvJFeuRzBRXAOOKXf1O8d2cvmMtG6yFR0jCllQ7vBsXoLP18yuqtf+N1XwWXTPF11eWy9q6dQ== dependencies: "@babel/code-frame" "^7.29.0" "@babel/core" "^7.25.2" @@ -6700,18 +6700,18 @@ metro@0.86.0, metro@^0.86.0: jest-worker "^29.7.0" jsc-safe-url "^0.2.2" lodash.throttle "^4.1.1" - metro-babel-transformer "0.86.0" - metro-cache "0.86.0" - metro-cache-key "0.86.0" - metro-config "0.86.0" - metro-core "0.86.0" - metro-file-map "0.86.0" - metro-resolver "0.86.0" - metro-runtime "0.86.0" - metro-source-map "0.86.0" - metro-symbolicate "0.86.0" - metro-transform-plugins "0.86.0" - metro-transform-worker "0.86.0" + metro-babel-transformer "0.87.0" + metro-cache "0.87.0" + metro-cache-key "0.87.0" + metro-config "0.87.0" + metro-core "0.87.0" + metro-file-map "0.87.0" + metro-resolver "0.87.0" + metro-runtime "0.87.0" + metro-source-map "0.87.0" + metro-symbolicate "0.87.0" + metro-transform-plugins "0.87.0" + metro-transform-worker "0.87.0" mime-types "^3.0.1" nullthrows "^1.1.1" serialize-error "^2.1.0" @@ -7164,10 +7164,10 @@ nullthrows@^1.1.1: resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== -ob1@0.86.0: - version "0.86.0" - resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.86.0.tgz#a86cc5eda8148e30c06ecb8cbf0d62fc2adcd5e0" - integrity sha512-MLv1UFB5CrCaP3IZwnMjpGsgzOQ29dZ8yqfswvyEb3b3Y3aZl8+q5dQQHxoSmuX/k514/E/Fe3QxjCTgEDW1dg== +ob1@0.87.0: + version "0.87.0" + resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.87.0.tgz#66a467031147389460011bdfbe203832189d4c56" + integrity sha512-8Q8sKCiUwsxgSmjDtVWyRgmxsgeJXXam3oQH6Id8ADfNaJMV6GZKyeAl8+pGVVdgwAZabfk5+aExle7AP/nZiA== dependencies: flow-enums-runtime "^0.0.6" From 2235b8ec3faf9d80c8f2a14ec77e04b2635b3c6c Mon Sep 17 00:00:00 2001 From: Sam Zhou Date: Mon, 13 Jul 2026 15:57:35 -0700 Subject: [PATCH 047/834] Stop using `prettier-plugin-hermes-parser` for formatting (#57505) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57505 Changelog: [Internal] Reviewed By: gkz Differential Revision: D111190630 fbshipit-source-id: e7ed7ee6f05701fbdaad1c8fad58c47f48344487 --- .prettierrc.js | 8 +- flow-typed/environment/node.js | 48 +++------- flow-typed/npm/babel-traverse_v7.x.x.js | 18 +--- flow-typed/npm/babel_v7.x.x.js | 3 +- flow-typed/npm/commander_v12.x.x.js | 8 +- flow-typed/npm/electron-packager_v20.x.x.js | 11 +-- flow-typed/npm/execa_v5.x.x.js | 10 +- flow-typed/npm/jest.js | 5 +- flow-typed/npm/jsonc-parser_v2.2.x.js | 8 +- flow-typed/npm/node-fetch_v2.x.x.js | 14 +-- flow-typed/npm/pretty-format_v29.x.x.js | 4 +- flow-typed/npm/rxjs_v6.x.x.js | 26 +++-- flow-typed/npm/shelljs_v0.x.x.js | 9 +- flow-typed/npm/tinybench_v4.1.x.js | 4 +- .../src/inspector-proxy/CdpDebugLogging.js | 5 +- .../src/inspector-proxy/types.js | 9 +- .../platform-colors.js | 14 ++- .../react-native-modules.js | 22 ++--- packages/jest-preset/jest-preset.js | 5 +- packages/metro-config/src/index.flow.js | 10 +- .../react-native-codegen/src/CodegenSchema.js | 15 +-- .../src/parsers/flow/components/commands.js | 14 ++- .../parsers/typescript/components/commands.js | 16 ++-- .../typescript/components/componentsUtils.js | 6 +- .../react-native-codegen/src/parsers/utils.js | 22 ++--- .../src/ComparisonResult.js | 3 +- .../src/DiffResults.js | 4 +- .../react-native/Libraries/Alert/Alert.js | 5 +- .../Libraries/Animated/AnimatedEvent.js | 4 +- .../Animated/createAnimatedComponent.js | 27 +++--- .../Animated/nodes/AnimatedInterpolation.js | 4 +- .../Libraries/AppState/AppState.js | 6 +- .../Libraries/BatchedBridge/MessageQueue.js | 3 +- .../AccessibilityInfo/AccessibilityInfo.js | 5 +- .../Libraries/Components/Button.js | 3 +- .../Libraries/Components/Keyboard/Keyboard.js | 6 +- .../Components/Pressable/Pressable.js | 3 +- .../Components/ScrollView/ScrollView.js | 16 ++-- .../ScrollViewNativeComponentType.js | 5 +- .../Components/TextInput/TextInput.flow.js | 44 ++------- .../Components/TextInput/TextInput.js | 95 ++++++++++--------- .../Components/Touchable/Touchable.js | 4 +- .../Touchable/TouchableNativeFeedback.js | 3 +- .../Touchable/TouchableWithoutFeedback.js | 5 +- .../EventEmitter/NativeEventEmitter.js | 3 +- .../__mocks__/NativeEventEmitter.js | 3 +- .../Libraries/Image/ImageSource.js | 4 +- .../react-native/Libraries/Modal/Modal.js | 5 +- .../Libraries/Network/XMLHttpRequest.js | 19 ++-- .../Libraries/Pressability/HoverState.js | 6 +- .../Libraries/StyleSheet/StyleSheetTypes.js | 40 ++------ .../StyleSheet/private/_TransformStyle.js | 6 +- .../StyleSheet/processBackgroundImage.js | 6 +- .../Libraries/Utilities/PlatformTypes.js | 13 +-- .../Utilities/useWindowDimensions.js | 3 +- packages/react-native/flow/bom.js.flow | 12 +-- packages/react-native/flow/cssom.js.flow | 12 +-- packages/react-native/flow/dom.js.flow | 71 ++++---------- .../scripts/compose-source-maps.js | 2 +- .../scripts/featureflags/types.js | 5 +- .../specs/NativeViewTransition.js | 2 +- .../webapis/dom/nodes/specs/NativeDOM.js | 24 +++-- .../__tests__/IntersectionObserver-itest.js | 14 ++- .../webapis/performance/PerformanceEntry.js | 6 +- .../PlatformTest/RNTesterPlatformTestTypes.js | 3 +- .../KeyboardAvoidingViewExample.js | 3 +- .../Lists/VirtualizeUtils.js | 20 ++-- .../Lists/VirtualizedList.js | 6 +- .../Lists/VirtualizedSectionList.js | 8 +- private/react-native-fantom/src/Benchmark.js | 3 +- 70 files changed, 279 insertions(+), 579 deletions(-) diff --git a/.prettierrc.js b/.prettierrc.js index dca775b07037..70a05cba5f46 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -7,11 +7,6 @@ * @format */ -let plugins = ['prettier-plugin-hermes-parser']; -try { - plugins = require('./.prettier-plugins.fb.js'); -} catch {} - module.exports = { arrowParens: 'avoid', bracketSameLine: true, @@ -20,7 +15,6 @@ module.exports = { singleQuote: true, trailingComma: 'all', endOfLine: 'lf', - plugins, overrides: [ { files: ['*.code-workspace'], @@ -31,7 +25,7 @@ module.exports = { { files: ['*.js', '*.js.flow'], options: { - parser: 'hermes', + parser: 'flow', }, }, { diff --git a/flow-typed/environment/node.js b/flow-typed/environment/node.js index 1a99ec0dcbd2..c92ff006df23 100644 --- a/flow-typed/environment/node.js +++ b/flow-typed/environment/node.js @@ -457,8 +457,7 @@ declare class child_process$ChildProcessTyped< TStdin extends stream$Writable | null, TStdout extends stream$Readable | null, TStderr extends stream$Readable | null, -> extends events$EventEmitter -{ +> extends events$EventEmitter { +stdin: TStdin; +stdout: TStdout; +stderr: TStderr; @@ -578,8 +577,7 @@ declare module 'child_process' { declare function execFile( file: string, argsOrCallback?: - | ReadonlyArray - | child_process$execFileCallback, + ReadonlyArray | child_process$execFileCallback, callback?: child_process$execFileCallback, ): child_process$ChildProcessTyped< stream$Writable, @@ -928,14 +926,7 @@ type crypto$key = declare class crypto$KeyObject { +asymmetricKeyType?: - | 'rsa' - | 'rsa-pss' - | 'dsa' - | 'ec' - | 'ed25519' - | 'ed448' - | 'x25519' - | 'x448'; + 'rsa' | 'rsa-pss' | 'dsa' | 'ec' | 'ed25519' | 'ed448' | 'x25519' | 'x448'; +asymmetricKeySize?: number; +symmetricKeySize?: number; +type: 'secret' | 'public' | 'private'; @@ -2560,9 +2551,9 @@ declare class http$Agent<+SocketT = net$Socket> { sockets: {[name: string]: ReadonlyArray, ...}; } -declare class http$IncomingMessage - extends stream$Readable -{ +declare class http$IncomingMessage< + SocketT = net$Socket, +> extends stream$Readable { headers: Object; rawHeaders: Array; httpVersion: string; @@ -2578,9 +2569,9 @@ declare class http$IncomingMessage rawTrailers: Array; } -declare class http$ClientRequest<+SocketT = net$Socket> - extends stream$Writable -{ +declare class http$ClientRequest< + +SocketT = net$Socket, +> extends stream$Writable { abort(): void; aborted: boolean; +connection: SocketT | null; @@ -2878,10 +2869,7 @@ declare class net$Socket extends stream$Duplex { destroyed: boolean; end( chunkOrEncodingOrCallback?: - | Buffer - | Uint8Array - | string - | ((data: any) => void), + Buffer | Uint8Array | string | ((data: any) => void), encodingOrCallback?: string | ((data: any) => void), callback?: (data: any) => void, ): this; @@ -3211,9 +3199,9 @@ declare module 'perf_hooks' { +detail?: T; } - declare export class PerformanceMeasure - extends PerformanceEntry - { + declare export class PerformanceMeasure< + T = unknown, + > extends PerformanceEntry { +entryType: 'measure'; +detail?: T; } @@ -3398,11 +3386,7 @@ declare module 'querystring' { */ declare module 'node:sqlite' { declare export type SupportedValueType = - | null - | number - | bigint - | string - | Uint8Array; + null | number | bigint | string | Uint8Array; declare export type DatabaseSyncOptions = Readonly<{ open?: boolean, @@ -5565,9 +5549,7 @@ declare module 'repl' { writer?: (object: any, options?: util$InspectOptions) => string, completer?: readline$InterfaceCompleter, replMode?: - | $SymbolReplModeMagic - | $SymbolReplModeSloppy - | $SymbolReplModeStrict, + $SymbolReplModeMagic | $SymbolReplModeSloppy | $SymbolReplModeStrict, breakEvalOnSigint?: boolean, ... }): REPLServer; diff --git a/flow-typed/npm/babel-traverse_v7.x.x.js b/flow-typed/npm/babel-traverse_v7.x.x.js index e28bb4921fed..d3b6cd034c47 100644 --- a/flow-typed/npm/babel-traverse_v7.x.x.js +++ b/flow-typed/npm/babel-traverse_v7.x.x.js @@ -246,12 +246,7 @@ declare module '@babel/traverse' { } declare export type BindingKind = - | 'var' - | 'let' - | 'const' - | 'module' - | 'hoisted' - | 'unknown'; + 'var' | 'let' | 'const' | 'module' | 'hoisted' | 'unknown'; declare export class Binding { constructor(opts: { @@ -435,13 +430,7 @@ declare module '@babel/traverse' { isBaseType(baseName: string, soft?: boolean): boolean; couldBeBaseType( name: - | 'string' - | 'number' - | 'boolean' - | 'any' - | 'mixed' - | 'empty' - | 'void', + 'string' | 'number' | 'boolean' | 'any' | 'mixed' | 'empty' | 'void', ): boolean; baseTypeStrictlyMatches(right: NodePath<>): ?boolean; isGenericType(genericName: string): boolean; @@ -1446,8 +1435,7 @@ declare module '@babel/traverse' { }>; declare export type VisitNode<-TNode extends BabelNode, TState> = - | VisitNodeFunction - | VisitNodeObject; + VisitNodeFunction | VisitNodeObject; declare export type Visitor = Readonly<{ enter?: VisitNodeFunction, diff --git a/flow-typed/npm/babel_v7.x.x.js b/flow-typed/npm/babel_v7.x.x.js index abba5ac2cb69..f58af46605e8 100644 --- a/flow-typed/npm/babel_v7.x.x.js +++ b/flow-typed/npm/babel_v7.x.x.js @@ -1293,8 +1293,7 @@ declare module '@babel/template' { }; declare export type PublicReplacements = - | {[string]: ?BabelNode} - | Array; + {[string]: ?BabelNode} | Array; declare export type TemplateBuilder = { // Build a new builder, merging the given options with the previous ones. diff --git a/flow-typed/npm/commander_v12.x.x.js b/flow-typed/npm/commander_v12.x.x.js index 1f18ac9da056..d98bd1ed5335 100644 --- a/flow-typed/npm/commander_v12.x.x.js +++ b/flow-typed/npm/commander_v12.x.x.js @@ -15,8 +15,7 @@ declare module 'commander' { declare type LiteralUnion = - | LiteralType - | {...BaseType, ...{[key: empty]: empty, ...}}; + LiteralType | {...BaseType, ...{[key: empty]: empty, ...}}; declare export class CommanderError mixins Error { code: string; @@ -335,10 +334,7 @@ declare module 'commander' { outputError?: (str: string, write: (str: string) => void) => void; } export type AddHelpTextPosition = - | 'beforeAll' - | 'before' - | 'after' - | 'afterAll'; + 'beforeAll' | 'before' | 'after' | 'afterAll'; export type HookEvent = 'preSubcommand' | 'preAction' | 'postAction'; export type OptionValueSource = LiteralUnion< 'default' | 'config' | 'env' | 'cli' | 'implied', diff --git a/flow-typed/npm/electron-packager_v20.x.x.js b/flow-typed/npm/electron-packager_v20.x.x.js index 38c859aca693..86e3cfc25f1d 100644 --- a/flow-typed/npm/electron-packager_v20.x.x.js +++ b/flow-typed/npm/electron-packager_v20.x.x.js @@ -49,21 +49,14 @@ declare module '@electron/packager' { ProductName?: string, InternalName?: string, 'requested-execution-level'?: - | 'asInvoker' - | 'highestAvailable' - | 'requireAdministrator', + 'asInvoker' | 'highestAvailable' | 'requireAdministrator', 'application-manifest'?: string, }>; declare export type WindowsSignOptions = $FlowFixMe; declare export type OfficialArch = - | 'ia32' - | 'x64' - | 'armv7l' - | 'arm64' - | 'mips64el' - | 'universal'; + 'ia32' | 'x64' | 'armv7l' | 'arm64' | 'mips64el' | 'universal'; declare export type OfficialPlatform = 'linux' | 'win32' | 'darwin' | 'mas'; diff --git a/flow-typed/npm/execa_v5.x.x.js b/flow-typed/npm/execa_v5.x.x.js index c54a07291e94..a3a8dae8b4c3 100644 --- a/flow-typed/npm/execa_v5.x.x.js +++ b/flow-typed/npm/execa_v5.x.x.js @@ -13,12 +13,7 @@ declare module 'execa' { declare type StdIoOption = - | 'pipe' - | 'ipc' - | 'ignore' - | 'inherit' - | stream$Stream - | number; + 'pipe' | 'ipc' | 'ignore' | 'inherit' | stream$Stream | number; declare type CommonOptions = { argv0?: string, @@ -74,8 +69,7 @@ declare module 'execa' { }; declare interface ExecaPromise - extends Promise, - child_process$ChildProcess {} + extends Promise, child_process$ChildProcess {} declare interface ExecaError extends ErrnoError { stdout: string; diff --git a/flow-typed/npm/jest.js b/flow-typed/npm/jest.js index 00767deaa1c8..70597548c96c 100644 --- a/flow-typed/npm/jest.js +++ b/flow-typed/npm/jest.js @@ -214,10 +214,7 @@ type FakeTimersConfig = { */ type JestStyledComponentsMatcherValue = - | string - | JestAsymmetricEqualityType - | RegExp - | void; + string | JestAsymmetricEqualityType | RegExp | void; type JestStyledComponentsMatcherOptions = { media?: string, diff --git a/flow-typed/npm/jsonc-parser_v2.2.x.js b/flow-typed/npm/jsonc-parser_v2.2.x.js index 51eadf91b48a..c8b47b6404ef 100644 --- a/flow-typed/npm/jsonc-parser_v2.2.x.js +++ b/flow-typed/npm/jsonc-parser_v2.2.x.js @@ -155,13 +155,7 @@ declare module 'jsonc-parser' { | 'InvalidCharacter' | ''; export type NodeType = - | 'object' - | 'array' - | 'property' - | 'string' - | 'number' - | 'boolean' - | 'null'; + 'object' | 'array' | 'property' | 'string' | 'number' | 'boolean' | 'null'; export type Node = { type: NodeType, value?: any, diff --git a/flow-typed/npm/node-fetch_v2.x.x.js b/flow-typed/npm/node-fetch_v2.x.x.js index 5b6b9cc51b46..22108efa6b74 100644 --- a/flow-typed/npm/node-fetch_v2.x.x.js +++ b/flow-typed/npm/node-fetch_v2.x.x.js @@ -157,12 +157,7 @@ declare module 'node-fetch' { } declare type ResponseType = - | 'basic' - | 'cors' - | 'default' - | 'error' - | 'opaque' - | 'opaqueredirect'; + 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; declare interface ResponseInit { headers?: HeaderInit; @@ -172,12 +167,7 @@ declare module 'node-fetch' { declare type HeaderInit = Headers | Array; declare type BodyInit = - | string - | null - | Buffer - | Blob - | Readable - | URLSearchParams; + string | null | Buffer | Blob | Readable | URLSearchParams; declare function fetch( url: string | URL | Request, diff --git a/flow-typed/npm/pretty-format_v29.x.x.js b/flow-typed/npm/pretty-format_v29.x.x.js index a13951717f0f..fbcdfda1f5e2 100644 --- a/flow-typed/npm/pretty-format_v29.x.x.js +++ b/flow-typed/npm/pretty-format_v29.x.x.js @@ -37,9 +37,7 @@ declare type PrettyFormatPlugin = declare module 'pretty-format' { declare export type CompareKeys = - | ((a: string, b: string) => number) - | null - | void; + ((a: string, b: string) => number) | null | void; declare export function format( value: unknown, options?: ?{ diff --git a/flow-typed/npm/rxjs_v6.x.x.js b/flow-typed/npm/rxjs_v6.x.x.js index a4326724e3ee..dfb0557cd726 100644 --- a/flow-typed/npm/rxjs_v6.x.x.js +++ b/flow-typed/npm/rxjs_v6.x.x.js @@ -9,11 +9,14 @@ declare interface rxjs$UnaryFunction { (source: T): R; } -declare interface rxjs$OperatorFunction - extends rxjs$UnaryFunction, rxjs$Observable> {} +declare interface rxjs$OperatorFunction extends rxjs$UnaryFunction< + rxjs$Observable, + rxjs$Observable, +> {} declare type rxjs$FactoryOrValue = T | (() => T); -declare interface rxjs$MonoTypeOperatorFunction - extends rxjs$OperatorFunction {} +declare interface rxjs$MonoTypeOperatorFunction< + T, +> extends rxjs$OperatorFunction {} declare interface rxjs$Timestamp { value: T; timestamp: number; @@ -47,9 +50,7 @@ declare interface rxjs$Subscribable { ): rxjs$Unsubscribable; } declare type rxjs$ObservableInput = - | rxjs$SubscribableOrPromise - | Array - | Iterable; + rxjs$SubscribableOrPromise | Array | Iterable; declare type rxjs$InteropObservable = { [string | unknown]: () => rxjs$Subscribable, @@ -588,8 +589,7 @@ declare module 'rxjs' { ((...sources: rxjs$ObservableInput[]) => rxjs$Observable), from( input: - | rxjs$ObservableInput - | rxjs$ObservableInput>, + rxjs$ObservableInput | rxjs$ObservableInput>, scheduler?: rxjs$SchedulerLike, ): rxjs$Observable, ArgumentOutOfRangeError: ArgumentOutOfRangeError, @@ -630,9 +630,7 @@ declare module 'rxjs' { ) => rxjs$Observable) & (( ...observables: ( - | rxjs$ObservableInput - | rxjs$SchedulerLike - | number + rxjs$ObservableInput | rxjs$SchedulerLike | number )[] ) => rxjs$Observable) & (( @@ -3349,9 +3347,7 @@ declare module 'rxjs/webSocket' { _output: rxjs$Subject; constructor( urlConfigOrSource: - | string - | WebSocketSubjectConfig - | rxjs$Observable, + string | WebSocketSubjectConfig | rxjs$Observable, destination?: rxjs$Observer, ): void; lift(operator: rxjs$Operator): WebSocketSubject; diff --git a/flow-typed/npm/shelljs_v0.x.x.js b/flow-typed/npm/shelljs_v0.x.x.js index c5ffdee80618..22eb29608ca5 100644 --- a/flow-typed/npm/shelljs_v0.x.x.js +++ b/flow-typed/npm/shelljs_v0.x.x.js @@ -48,14 +48,7 @@ declare type $npm$shelljs$GrepOpts = $npm$shelljs$OptionsPoly<'-l' | '-v'>; declare type $npm$shelljs$SedOpts = $npm$shelljs$OptionsPoly<'-i'>; declare type $npm$shelljs$SortOpts = $npm$shelljs$OptionsPoly<'-n' | '-r'>; declare type $npm$shelljs$TestOpts = - | '-b' - | '-c' - | '-d' - | '-e' - | '-f' - | '-L' - | '-p' - | '-S'; + '-b' | '-c' | '-d' | '-e' | '-f' | '-L' | '-p' | '-S'; declare type $npm$shelljs$TouchOpts = { [key: '-a' | '-c' | '-m']: boolean, '-d'?: string, diff --git a/flow-typed/npm/tinybench_v4.1.x.js b/flow-typed/npm/tinybench_v4.1.x.js index 2b6bbba9bafa..821e79e0a969 100644 --- a/flow-typed/npm/tinybench_v4.1.x.js +++ b/flow-typed/npm/tinybench_v4.1.x.js @@ -101,9 +101,7 @@ declare module 'tinybench' { // but we type it this way to avoid mistakes (we can make breaking changes // in our definition that they can't). export type Fn = () => - | Promise - | void - | FnReturnedObject; + Promise | void | FnReturnedObject; declare export class Bench extends EventTarget { concurrency: null | 'task' | 'bench'; diff --git a/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js b/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js index 056c07f76f0a..b2a054a622c3 100644 --- a/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js +++ b/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js @@ -21,10 +21,7 @@ const debugCDPMessages = require('debug')('Metro:InspectorProxyCDPMessages'); const CDP_MESSAGES_BATCH_DEBUGGING_THROTTLE_MS = 5000; export type CDPMessageDestination = - | 'DebuggerToProxy' - | 'ProxyToDebugger' - | 'DeviceToProxy' - | 'ProxyToDevice'; + 'DebuggerToProxy' | 'ProxyToDebugger' | 'DeviceToProxy' | 'ProxyToDevice'; function getCDPLogPrefix(destination: CDPMessageDestination): string { return util.format( diff --git a/packages/dev-middleware/src/inspector-proxy/types.js b/packages/dev-middleware/src/inspector-proxy/types.js index 89838f2d11d0..707d19ed3bbe 100644 --- a/packages/dev-middleware/src/inspector-proxy/types.js +++ b/packages/dev-middleware/src/inspector-proxy/types.js @@ -107,16 +107,11 @@ export type GetPagesResponse = { // Union type for all possible messages sent from device to Inspector Proxy. export type MessageFromDevice = - | GetPagesResponse - | WrappedEventFromDevice - | DisconnectRequest; + GetPagesResponse | WrappedEventFromDevice | DisconnectRequest; // Union type for all possible messages sent from Inspector Proxy to device. export type MessageToDevice = - | GetPagesRequest - | WrappedEventToDevice - | ConnectRequest - | DisconnectRequest; + GetPagesRequest | WrappedEventToDevice | ConnectRequest | DisconnectRequest; // Page description object that is sent in response to /json HTTP request from debugger. export type PageDescription = Readonly<{ diff --git a/packages/eslint-plugin-react-native/platform-colors.js b/packages/eslint-plugin-react-native/platform-colors.js index b154aabcbe53..2de452899cd6 100644 --- a/packages/eslint-plugin-react-native/platform-colors.js +++ b/packages/eslint-plugin-react-native/platform-colors.js @@ -58,14 +58,12 @@ module.exports = { } const properties = args[0].properties; properties.forEach(property => { - if ( - !( - property.type === 'Property' && - (property.value.type === 'Literal' || - (property.value.type === 'CallExpression' && - property.value.callee.name === 'PlatformColor')) - ) - ) { + if (!( + property.type === 'Property' && + (property.value.type === 'Literal' || + (property.value.type === 'CallExpression' && + property.value.callee.name === 'PlatformColor')) + )) { context.report({ node, messageId: 'dynamicColorIOSValue', diff --git a/packages/eslint-plugin-specs/react-native-modules.js b/packages/eslint-plugin-specs/react-native-modules.js index 78e6e11559ca..5983f10dc832 100644 --- a/packages/eslint-plugin-specs/react-native-modules.js +++ b/packages/eslint-plugin-specs/react-native-modules.js @@ -91,22 +91,18 @@ function isModuleRequire(node) { } const memberExpression = callExpression.callee; - if ( - !( - memberExpression.object.type === 'Identifier' && - memberExpression.object.name === 'TurboModuleRegistry' - ) - ) { + if (!( + memberExpression.object.type === 'Identifier' && + memberExpression.object.name === 'TurboModuleRegistry' + )) { return false; } - if ( - !( - memberExpression.property.type === 'Identifier' && - (memberExpression.property.name === 'get' || - memberExpression.property.name === 'getEnforcing') - ) - ) { + if (!( + memberExpression.property.type === 'Identifier' && + (memberExpression.property.name === 'get' || + memberExpression.property.name === 'getEnforcing') + )) { return false; } return true; diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index bfe131904a21..fc99494c37d8 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -28,9 +28,8 @@ module.exports = { resolver: require.resolve('./jest/resolver.js'), transform: { '^.+\\.(js|ts|tsx)$': 'babel-jest', - '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': require.resolve( - './jest/assetFileTransformer.js', - ), + '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': + require.resolve('./jest/assetFileTransformer.js'), }, transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)', diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index 74fc4053c2b7..2f4e44c187bc 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -85,12 +85,10 @@ export function getDefaultConfig(projectRoot: string): ConfigT { transformer: { allowOptionalDependencies: true, assetRegistryPath: 'react-native/asset-registry', - asyncRequireModulePath: require.resolve( - 'metro-runtime/src/modules/asyncRequire', - ), - babelTransformerPath: require.resolve( - '@react-native/metro-babel-transformer', - ), + asyncRequireModulePath: + require.resolve('metro-runtime/src/modules/asyncRequire'), + babelTransformerPath: + require.resolve('@react-native/metro-babel-transformer'), getTransformOptions: async () => ({ transform: { experimentalImportSupport: false, diff --git a/packages/react-native-codegen/src/CodegenSchema.js b/packages/react-native-codegen/src/CodegenSchema.js index 41b8edabf542..1c383704fd72 100644 --- a/packages/react-native-codegen/src/CodegenSchema.js +++ b/packages/react-native-codegen/src/CodegenSchema.js @@ -270,8 +270,7 @@ export type ReservedTypeAnnotation = Readonly<{ * NativeModule Types */ export type Nullable = - | NullableTypeAnnotation - | T; + NullableTypeAnnotation | T; export type NullableTypeAnnotation = Readonly<{ @@ -345,8 +344,7 @@ export type NativeModuleEnumMember = { }; export type NativeModuleEnumMemberType = - | 'NumberTypeAnnotation' - | 'StringTypeAnnotation'; + 'NumberTypeAnnotation' | 'StringTypeAnnotation'; export type NativeModuleEnumDeclaration = Readonly<{ name: string, @@ -436,12 +434,10 @@ export type NativeModuleBaseTypeAnnotation = | NativeModuleMixedTypeAnnotation; export type NativeModuleParamTypeAnnotation = - | NativeModuleBaseTypeAnnotation - | NativeModuleParamOnlyTypeAnnotation; + NativeModuleBaseTypeAnnotation | NativeModuleParamOnlyTypeAnnotation; export type NativeModuleReturnTypeAnnotation = - | NativeModuleBaseTypeAnnotation - | NativeModuleReturnOnlyTypeAnnotation; + NativeModuleBaseTypeAnnotation | NativeModuleReturnOnlyTypeAnnotation; export type NativeModuleTypeAnnotation = | NativeModuleBaseTypeAnnotation @@ -452,8 +448,7 @@ export type NativeModuleTypeAnnotation = type NativeModuleParamOnlyTypeAnnotation = NativeModuleFunctionTypeAnnotation; type NativeModuleReturnOnlyTypeAnnotation = - | NativeModulePromiseTypeAnnotation - | VoidTypeAnnotation; + NativeModulePromiseTypeAnnotation | VoidTypeAnnotation; // Add the allowed component reserved types to the native module union export type CompleteReservedTypeAnnotation = diff --git a/packages/react-native-codegen/src/parsers/flow/components/commands.js b/packages/react-native-codegen/src/parsers/flow/components/commands.js index 8800427a6157..381fef3c88dd 100644 --- a/packages/react-native-codegen/src/parsers/flow/components/commands.js +++ b/packages/react-native-codegen/src/parsers/flow/components/commands.js @@ -49,14 +49,12 @@ function buildCommandSchema( const firstParam = value.params[0].typeAnnotation; - if ( - !( - firstParam.id != null && - firstParam.id.type === 'QualifiedTypeIdentifier' && - firstParam.id.qualification.name === 'React' && - firstParam.id.id.name === 'ElementRef' - ) - ) { + if (!( + firstParam.id != null && + firstParam.id.type === 'QualifiedTypeIdentifier' && + firstParam.id.qualification.name === 'React' && + firstParam.id.id.name === 'ElementRef' + )) { throw new Error( `The first argument of method ${name} must be of type React.ElementRef<>`, ); diff --git a/packages/react-native-codegen/src/parsers/typescript/components/commands.js b/packages/react-native-codegen/src/parsers/typescript/components/commands.js index f51d7ccede32..f097b2b9314b 100644 --- a/packages/react-native-codegen/src/parsers/typescript/components/commands.js +++ b/packages/react-native-codegen/src/parsers/typescript/components/commands.js @@ -33,15 +33,13 @@ function buildCommandSchemaInternal( parser: Parser, ): NamedShape { const firstParam = parameters[0].typeAnnotation; - if ( - !( - firstParam.typeAnnotation != null && - firstParam.typeAnnotation.type === 'TSTypeReference' && - firstParam.typeAnnotation.typeName.left?.name === 'React' && - (firstParam.typeAnnotation.typeName.right?.name === 'ElementRef' || - firstParam.typeAnnotation.typeName.right?.name === 'ComponentRef') - ) - ) { + if (!( + firstParam.typeAnnotation != null && + firstParam.typeAnnotation.type === 'TSTypeReference' && + firstParam.typeAnnotation.typeName.left?.name === 'React' && + (firstParam.typeAnnotation.typeName.right?.name === 'ElementRef' || + firstParam.typeAnnotation.typeName.right?.name === 'ComponentRef') + )) { throw new Error( `The first argument of method ${name} must be of type React.ElementRef<> or React.ComponentRef<>`, ); diff --git a/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js b/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js index 23d16906883b..407327d24c17 100644 --- a/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js +++ b/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js @@ -354,8 +354,7 @@ function setDefaultValue( /* $FlowFixMe[invalid-compare] Error discovered during Constant Condition * roll out. See https://fburl.com/workplace/5whu3i34. */ (defaultValue === null ? null : defaultValue ? defaultValue : 0) as - | number - | null; + number | null; break; case 'BooleanTypeAnnotation': /* $FlowFixMe[invalid-compare] Error discovered during Constant Condition @@ -364,8 +363,7 @@ function setDefaultValue( break; case 'StringTypeAnnotation': common.default = (defaultValue === undefined ? null : defaultValue) as - | string - | null; + string | null; break; } } diff --git a/packages/react-native-codegen/src/parsers/utils.js b/packages/react-native-codegen/src/parsers/utils.js index 851450b0b181..3ddb518fbc38 100644 --- a/packages/react-native-codegen/src/parsers/utils.js +++ b/packages/react-native-codegen/src/parsers/utils.js @@ -177,22 +177,18 @@ function isModuleRegistryCall(node: $FlowFixMe): boolean { } const memberExpression = callExpression.callee; - if ( - !( - memberExpression.object.type === 'Identifier' && - memberExpression.object.name === 'TurboModuleRegistry' - ) - ) { + if (!( + memberExpression.object.type === 'Identifier' && + memberExpression.object.name === 'TurboModuleRegistry' + )) { return false; } - if ( - !( - memberExpression.property.type === 'Identifier' && - (memberExpression.property.name === 'get' || - memberExpression.property.name === 'getEnforcing') - ) - ) { + if (!( + memberExpression.property.type === 'Identifier' && + (memberExpression.property.name === 'get' || + memberExpression.property.name === 'getEnforcing') + )) { return false; } diff --git a/packages/react-native-compatibility-check/src/ComparisonResult.js b/packages/react-native-compatibility-check/src/ComparisonResult.js index 40b4d9343953..967206eaef61 100644 --- a/packages/react-native-compatibility-check/src/ComparisonResult.js +++ b/packages/react-native-compatibility-check/src/ComparisonResult.js @@ -120,8 +120,7 @@ export type UnionMembersComparisonResult = { }>, }; export type MembersComparisonResult = - | EnumMembersComparisonResult - | UnionMembersComparisonResult; + EnumMembersComparisonResult | UnionMembersComparisonResult; export type NullableComparisonResult = { /* Four possible cases of change: void goes to T? :: typeRefined !optionsReduced diff --git a/packages/react-native-compatibility-check/src/DiffResults.js b/packages/react-native-compatibility-check/src/DiffResults.js index 22f75d196cdf..1257c6b0c5b0 100644 --- a/packages/react-native-compatibility-check/src/DiffResults.js +++ b/packages/react-native-compatibility-check/src/DiffResults.js @@ -87,9 +87,7 @@ type ExportableSchemaDiffers = { }; export type SchemaDiffCategory = 'new' | 'deprecated' | SchemaDiffers; type ExportableSchemaDiffCategory = - | 'new' - | 'deprecated' - | ExportableSchemaDiffers; + 'new' | 'deprecated' | ExportableSchemaDiffers; export type SchemaDiff = { name: string, framework: Framework, diff --git a/packages/react-native/Libraries/Alert/Alert.js b/packages/react-native/Libraries/Alert/Alert.js index 27108b7f14d3..4130ec511438 100644 --- a/packages/react-native/Libraries/Alert/Alert.js +++ b/packages/react-native/Libraries/Alert/Alert.js @@ -17,10 +17,7 @@ import {alertWithArgs} from './RCTAlertManager'; * @platform ios */ export type AlertType = - | 'default' - | 'plain-text' - | 'secure-text' - | 'login-password'; + 'default' | 'plain-text' | 'secure-text' | 'login-password'; /** * @platform ios diff --git a/packages/react-native/Libraries/Animated/AnimatedEvent.js b/packages/react-native/Libraries/Animated/AnimatedEvent.js index 3f1245e3fd27..866e9bdb20d7 100644 --- a/packages/react-native/Libraries/Animated/AnimatedEvent.js +++ b/packages/react-native/Libraries/Animated/AnimatedEvent.js @@ -21,9 +21,7 @@ import AnimatedValueXY from './nodes/AnimatedValueXY'; import invariant from 'invariant'; export type Mapping = - | {[key: string]: Mapping, ...} - | AnimatedValue - | AnimatedValueXY; + {[key: string]: Mapping, ...} | AnimatedValue | AnimatedValueXY; export type EventConfig = { listener?: ?(NativeSyntheticEvent) => unknown, useNativeDriver: boolean, diff --git a/packages/react-native/Libraries/Animated/createAnimatedComponent.js b/packages/react-native/Libraries/Animated/createAnimatedComponent.js index b6b3953b429d..922e95ec9f6c 100644 --- a/packages/react-native/Libraries/Animated/createAnimatedComponent.js +++ b/packages/react-native/Libraries/Animated/createAnimatedComponent.js @@ -34,20 +34,19 @@ type Builtin = (...ReadonlyArray) => unknown | Date | Error | RegExp; export type WithAnimatedValue = T extends Builtin | Nullable ? T : T extends Primitive - ? - | T - | AnimatedNode - | AnimatedAddition - | AnimatedSubtraction - | AnimatedDivision - | AnimatedMultiplication - | AnimatedModulo - | AnimatedDiffClamp - | AnimatedValue - | AnimatedInterpolation - | AnimatedInterpolation - | AnimatedInterpolation - | AnimatedInterpolation + ? | T + | AnimatedNode + | AnimatedAddition + | AnimatedSubtraction + | AnimatedDivision + | AnimatedMultiplication + | AnimatedModulo + | AnimatedDiffClamp + | AnimatedValue + | AnimatedInterpolation + | AnimatedInterpolation + | AnimatedInterpolation + | AnimatedInterpolation : T extends ReadonlyArray ? ReadonlyArray> : T extends {...} diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js index 480b3217d04b..b6a0d4d3b5f5 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js @@ -28,9 +28,7 @@ import invariant from 'invariant'; type ExtrapolateType = 'extend' | 'identity' | 'clamp'; export type InterpolationConfigSupportedOutputType = - | number - | string - | NativeColorValue; + number | string | NativeColorValue; export type InterpolationConfigType< OutputT extends InterpolationConfigSupportedOutputType, diff --git a/packages/react-native/Libraries/AppState/AppState.js b/packages/react-native/Libraries/AppState/AppState.js index c8a1506fb674..b46ea05b82e0 100644 --- a/packages/react-native/Libraries/AppState/AppState.js +++ b/packages/react-native/Libraries/AppState/AppState.js @@ -29,11 +29,7 @@ import NativeAppState from './NativeAppState'; * @platform ios `inactive` */ export type AppStateStatus = - | 'inactive' - | 'background' - | 'active' - | 'extension' - | 'unknown'; + 'inactive' | 'background' | 'active' | 'extension' | 'unknown'; /** * Events emitted by `AppState`. diff --git a/packages/react-native/Libraries/BatchedBridge/MessageQueue.js b/packages/react-native/Libraries/BatchedBridge/MessageQueue.js index 137233be1dfa..2d0a98f26010 100644 --- a/packages/react-native/Libraries/BatchedBridge/MessageQueue.js +++ b/packages/react-native/Libraries/BatchedBridge/MessageQueue.js @@ -130,8 +130,7 @@ class MessageQueue { } flushedQueue(): - | null - | [Array, Array, Array, number] { + null | [Array, Array, Array, number] { this.__guard(() => { this.__callReactNativeMicrotasks(); }); diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 92e5376cb5d1..b922c77f0626 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -43,10 +43,7 @@ type AccessibilityEventDefinitions = { }; type AccessibilityEventTypes = - | 'click' - | 'focus' - | 'viewHoverEnter' - | 'windowStateChange'; + 'click' | 'focus' | 'viewHoverEnter' | 'windowStateChange'; // Mapping of public event names to platform-specific event names. const EventNames: Map = diff --git a/packages/react-native/Libraries/Components/Button.js b/packages/react-native/Libraries/Components/Button.js index 383f769a4018..c1bec4b65edf 100644 --- a/packages/react-native/Libraries/Components/Button.js +++ b/packages/react-native/Libraries/Components/Button.js @@ -168,8 +168,7 @@ export type ButtonProps = Readonly<{ }>; const NativeTouchable: - | typeof TouchableNativeFeedback - | typeof TouchableOpacity = + typeof TouchableNativeFeedback | typeof TouchableOpacity = Platform.OS === 'android' ? TouchableNativeFeedback : TouchableOpacity; export type ButtonInstance = React.ElementRef; diff --git a/packages/react-native/Libraries/Components/Keyboard/Keyboard.js b/packages/react-native/Libraries/Components/Keyboard/Keyboard.js index da318b738124..40bfa1e121ac 100644 --- a/packages/react-native/Libraries/Components/Keyboard/Keyboard.js +++ b/packages/react-native/Libraries/Components/Keyboard/Keyboard.js @@ -19,11 +19,7 @@ import NativeKeyboardObserver from './NativeKeyboardObserver'; export type KeyboardEventName = keyof KeyboardEventDefinitions; export type KeyboardEventEasing = - | 'easeIn' - | 'easeInEaseOut' - | 'easeOut' - | 'linear' - | 'keyboard'; + 'easeIn' | 'easeInEaseOut' | 'easeOut' | 'linear' | 'keyboard'; export type KeyboardMetrics = Readonly<{ screenX: number, diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index 97e72fcdce0e..c0d1a6d62ab3 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -142,8 +142,7 @@ type PressableBaseProps = Readonly<{ * the component is currently pressed and returns view styles. */ style?: - | ViewStyleProp - | ((state: PressableStateCallbackType) => ViewStyleProp), + ViewStyleProp | ((state: PressableStateCallbackType) => ViewStyleProp), /** * Identifier used to find this view in tests. diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 8f218c6e5120..26b01e15f3eb 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -199,8 +199,7 @@ export type DecelerationRateType = 'fast' | 'normal' | number; export type ScrollResponderType = ScrollViewImperativeMethods; export interface ScrollViewInstance - extends HostInstance, - ScrollViewImperativeMethods {} + extends HostInstance, ScrollViewImperativeMethods {} type InnerViewInstance = React.ElementRef; @@ -394,10 +393,7 @@ export type ScrollViewPropsIOS = Readonly<{ * @platform ios */ contentInsetAdjustmentBehavior?: ?( - | 'automatic' - | 'scrollableAxes' - | 'never' - | 'always' + 'automatic' | 'scrollableAxes' | 'never' | 'always' ), }>; @@ -569,9 +565,11 @@ type ScrollViewBaseProps = Readonly<{ * * @default `'none'` */ - keyboardDismissMode?: ?// default - // cross-platform - ('none' | 'on-drag' | 'interactive'), // ios only + keyboardDismissMode?: ?( + // default + // cross-platform + 'none' | 'on-drag' | 'interactive' + ), // ios only /** * Determines when the keyboard should stay visible after a tap. * diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js index 9cd18a3d2bac..6762a52b3345 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js @@ -32,10 +32,7 @@ export type ScrollViewNativeProps = Readonly<{ centerContent?: ?boolean, contentInset?: ?EdgeInsetsProp, contentInsetAdjustmentBehavior?: ?( - | 'automatic' - | 'scrollableAxes' - | 'never' - | 'always' + 'automatic' | 'scrollableAxes' | 'never' | 'always' ), contentOffset?: ?PointProp, decelerationRate?: ?('fast' | 'normal' | number), diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index cb3b241f8089..eaf81c88d84b 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -171,36 +171,20 @@ export type KeyboardTypeIOS = export type KeyboardTypeAndroid = 'visible-password'; export type KeyboardTypeOptions = - | KeyboardType - | KeyboardTypeIOS - | KeyboardTypeAndroid; + KeyboardType | KeyboardTypeIOS | KeyboardTypeAndroid; export type InputModeOptions = - | 'none' - | 'text' - | 'decimal' - | 'numeric' - | 'tel' - | 'search' - | 'email' - | 'url'; + 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; export type ReturnKeyType = 'done' | 'go' | 'next' | 'search' | 'send'; export type ReturnKeyTypeIOS = - | 'default' - | 'emergency-call' - | 'google' - | 'join' - | 'route' - | 'yahoo'; + 'default' | 'emergency-call' | 'google' | 'join' | 'route' | 'yahoo'; export type ReturnKeyTypeAndroid = 'none' | 'previous'; export type ReturnKeyTypeOptions = - | ReturnKeyType - | ReturnKeyTypeIOS - | ReturnKeyTypeAndroid; + ReturnKeyType | ReturnKeyTypeIOS | ReturnKeyTypeAndroid; export type SubmitBehavior = 'submit' | 'blurAndSubmit' | 'newline'; @@ -261,9 +245,7 @@ export type EnterKeyHintTypeIOS = 'enter'; export type EnterKeyHintType = 'done' | 'go' | 'next' | 'search' | 'send'; export type EnterKeyHintTypeOptions = - | EnterKeyHintType - | EnterKeyHintTypeAndroid - | EnterKeyHintTypeIOS; + EnterKeyHintType | EnterKeyHintTypeAndroid | EnterKeyHintTypeIOS; type PasswordRules = string; @@ -306,8 +288,7 @@ export type TextInputIOSProps = Readonly<{ * @platform ios */ dataDetectorTypes?: - | ?DataDetectorTypesType - | ReadonlyArray, + ?DataDetectorTypesType | ReadonlyArray, /** * If `true`, the keyboard disables the return key when there is no text and @@ -385,12 +366,7 @@ export type TextInputIOSProps = Readonly<{ * @platform ios */ lineBreakModeIOS?: ?( - | 'wordWrapping' - | 'char' - | 'clip' - | 'head' - | 'middle' - | 'tail' + 'wordWrapping' | 'char' | 'clip' | 'head' | 'middle' | 'tail' ), /** @@ -447,11 +423,7 @@ export type TextInputAndroidProps = Readonly<{ * - `yesExcludeDescendants` - this view is important for autofill but its children aren't */ importantForAutofill?: ?( - | 'auto' - | 'no' - | 'noExcludeDescendants' - | 'yes' - | 'yesExcludeDescendants' + 'auto' | 'no' | 'noExcludeDescendants' | 'yes' | 'yesExcludeDescendants' ), /** diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 2854f5e53cc0..5203cf36e873 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -641,53 +641,54 @@ function InternalTextInput(props: TextInputProps): React.Node { ? selectionColor : selectionHandleColor, }; - textInput = ( - /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match up - * exactly with the props for TextInput. This will need to get fixed */ - /* $FlowFixMe[incompatible-type] the types for AndroidTextInput don't - * match up exactly with the props for TextInput. This will need to get - * fixed */ - /* $FlowFixMe[incompatible-type-arg] the types for AndroidTextInput don't - * match up exactly with the props for TextInput. This will need to get - * fixed */ - - ); + textInput = + ( + /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match up + * exactly with the props for TextInput. This will need to get fixed */ + /* $FlowFixMe[incompatible-type] the types for AndroidTextInput don't + * match up exactly with the props for TextInput. This will need to get + * fixed */ + /* $FlowFixMe[incompatible-type-arg] the types for AndroidTextInput don't + * match up exactly with the props for TextInput. This will need to get + * fixed */ + + ); } return {textInput}; } diff --git a/packages/react-native/Libraries/Components/Touchable/Touchable.js b/packages/react-native/Libraries/Components/Touchable/Touchable.js index 5804082fd26b..827477f98c5b 100644 --- a/packages/react-native/Libraries/Components/Touchable/Touchable.js +++ b/packages/react-native/Libraries/Components/Touchable/Touchable.js @@ -694,7 +694,7 @@ const TouchableMixinImpl = { curState + '` for Touchable responder `' + typeof this.state.touchable.responderID === - 'number' + 'number' ? this.state.touchable.responderID : 'host component' + '`', ); @@ -707,7 +707,7 @@ const TouchableMixinImpl = { signal + '` for responder `' + typeof this.state.touchable.responderID === - 'number' + 'number' ? this.state.touchable.responderID : '<>' + '`', ); diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js index 73f140fa79d9..e975a0bd3858 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js @@ -84,8 +84,7 @@ export type TouchableNativeFeedbackProps = Readonly<{ | Readonly<{ type: 'ThemeAttrAndroid', attribute: - | 'selectableItemBackground' - | 'selectableItemBackgroundBorderless', + 'selectableItemBackground' | 'selectableItemBackgroundBorderless', rippleRadius: ?number, }> | Readonly<{ diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js index b82a035874ec..3e9b92ad06b1 100755 --- a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -74,10 +74,7 @@ export type TouchableWithoutFeedbackProps = Readonly< */ id?: string, importantForAccessibility?: ?( - | 'auto' - | 'yes' - | 'no' - | 'no-hide-descendants' + 'auto' | 'yes' | 'no' | 'no-hide-descendants' ), nativeID?: ?string, onAccessibilityAction?: ?(event: AccessibilityActionEvent) => unknown, diff --git a/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js b/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js index 32d452656eb7..ed018ab6ad63 100644 --- a/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js +++ b/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js @@ -62,8 +62,7 @@ export default class NativeEventEmitter< TEventToArgsMap extends Readonly< Record>, > = Readonly>>, -> implements IEventEmitter -{ +> implements IEventEmitter { _nativeModule: ?NativeModule; /** diff --git a/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js b/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js index 9670253ebb44..c6584d87446c 100644 --- a/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js +++ b/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js @@ -20,8 +20,7 @@ import RCTDeviceEventEmitter from '../RCTDeviceEventEmitter'; */ export default class NativeEventEmitter< TEventToArgsMap extends Readonly>>, -> implements IEventEmitter -{ +> implements IEventEmitter { addListener( eventType: TEvent, listener: (...args: TEventToArgsMap[TEvent]) => unknown, diff --git a/packages/react-native/Libraries/Image/ImageSource.js b/packages/react-native/Libraries/Image/ImageSource.js index de099b77c4b3..48c08d25ab0b 100644 --- a/packages/react-native/Libraries/Image/ImageSource.js +++ b/packages/react-native/Libraries/Image/ImageSource.js @@ -85,9 +85,7 @@ export interface ImageURISource { export type ImageRequireSource = number; export type ImageSource = - | ImageRequireSource - | ImageURISource - | ReadonlyArray; + ImageRequireSource | ImageURISource | ReadonlyArray; type ImageSourceProperties = { body?: ?string, diff --git a/packages/react-native/Libraries/Modal/Modal.js b/packages/react-native/Libraries/Modal/Modal.js index e9201f02ff3f..5f3c943e9776 100644 --- a/packages/react-native/Libraries/Modal/Modal.js +++ b/packages/react-native/Libraries/Modal/Modal.js @@ -117,10 +117,7 @@ export type ModalPropsIOS = { * @platform ios */ presentationStyle?: ?( - | 'fullScreen' - | 'pageSheet' - | 'formSheet' - | 'overFullScreen' + 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen' ), /** diff --git a/packages/react-native/Libraries/Network/XMLHttpRequest.js b/packages/react-native/Libraries/Network/XMLHttpRequest.js index b125c9e9e7fa..7edb4c2a24c5 100644 --- a/packages/react-native/Libraries/Network/XMLHttpRequest.js +++ b/packages/react-native/Libraries/Network/XMLHttpRequest.js @@ -34,12 +34,7 @@ const DEBUG_NETWORK_SEND_DELAY: false = false; // Set to a number of millisecond export type NativeResponseType = 'base64' | 'blob' | 'text'; export type ResponseType = - | '' - | 'arraybuffer' - | 'blob' - | 'document' - | 'json' - | 'text'; + '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; export type Response = ?Object | string; type XHRInterceptor = interface { @@ -658,13 +653,11 @@ class XMLHttpRequest extends EventTarget { } // only call onreadystatechange if there is something to abort, // below logic is per spec - if ( - !( - this.readyState === this.UNSENT || - (this.readyState === this.OPENED && !this._sent) || - this.readyState === this.DONE - ) - ) { + if (!( + this.readyState === this.UNSENT || + (this.readyState === this.OPENED && !this._sent) || + this.readyState === this.DONE + )) { this._reset(); this.setReadyState(this.DONE); } diff --git a/packages/react-native/Libraries/Pressability/HoverState.js b/packages/react-native/Libraries/Pressability/HoverState.js index cfa3f2fd59a8..7f971a29e872 100644 --- a/packages/react-native/Libraries/Pressability/HoverState.js +++ b/packages/react-native/Libraries/Pressability/HoverState.js @@ -19,9 +19,9 @@ let isEnabled = false; if (Platform.OS === 'web') { const canUseDOM = Boolean( typeof window !== 'undefined' && - window.document && - // $FlowFixMe[method-unbinding] - window.document.createElement, + window.document && + // $FlowFixMe[method-unbinding] + window.document.createElement, ); if (canUseDOM) { diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index dec894ab9129..c04cfe1c4b20 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -540,12 +540,7 @@ type ____LayoutStyle_Internal = Readonly<{ * for more details. */ alignSelf?: - | 'auto' - | 'flex-start' - | 'flex-end' - | 'center' - | 'stretch' - | 'baseline', + 'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline', /** `alignContent` controls how rows align in the cross direction, * overriding the `alignContent` of the parent. @@ -736,10 +731,7 @@ type LinearGradientValue = { }; type RadialExtent = - | 'closest-corner' - | 'closest-side' - | 'farthest-corner' - | 'farthest-side'; + 'closest-corner' | 'closest-side' | 'farthest-corner' | 'farthest-side'; export type RadialGradientPosition = | { top: number | string, @@ -785,10 +777,7 @@ export type BackgroundSizeValue = { }; export type BackgroundRepeatKeyword = - | 'repeat' - | 'space' - | 'round' - | 'no-repeat'; + 'repeat' | 'space' | 'round' | 'no-repeat'; export type BackgroundPositionValue = | { @@ -906,8 +895,7 @@ export type ____ViewStyle_InternalBase = Readonly<{ experimental_backgroundImage?: ReadonlyArray | string, experimental_backgroundSize?: ReadonlyArray | string, experimental_backgroundPosition?: - | ReadonlyArray - | string, + ReadonlyArray | string, experimental_backgroundRepeat?: ReadonlyArray | string, isolation?: 'auto' | 'isolate', }>; @@ -1032,20 +1020,11 @@ type ____TextStyle_InternalBase = Readonly<{ letterSpacing?: number, lineHeight?: number, textAlign?: - | 'auto' - | 'left' - | 'right' - | 'center' - | 'justify' - | 'start' - | 'end', + 'auto' | 'left' | 'right' | 'center' | 'justify' | 'start' | 'end', textAlignVertical?: 'auto' | 'top' | 'bottom' | 'center', includeFontPadding?: boolean, textDecorationLine?: - | 'none' - | 'underline' - | 'line-through' - | 'underline line-through', + 'none' | 'underline' | 'line-through' | 'underline line-through', textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy', textDecorationColor?: ____ColorValue_Internal, textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase', @@ -1093,12 +1072,7 @@ export type ____DangerouslyImpreciseStyle_Internal = Readonly<{ }>; export type StyleProp = - | null - | void - | T - | false - | '' - | ReadonlyArray>; + null | void | T | false | '' | ReadonlyArray>; export type ____DangerouslyImpreciseStyleProp_Internal = StyleProp< Partial<____DangerouslyImpreciseStyle_Internal>, diff --git a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js index b46421463c3a..0c2abd7faa0a 100644 --- a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js +++ b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js @@ -74,8 +74,7 @@ export type ____TransformStyle_Internal = Readonly<{ // TODO: what is the actual type it expects? | { readonly matrix: - | ReadonlyArray - | AnimatedNode, + ReadonlyArray | AnimatedNode, }, >, >, @@ -94,6 +93,5 @@ export type ____TransformStyle_Internal = Readonly<{ * E.g. transformOrigin: '30% 80% 15px' */ transformOrigin?: - | [string | number, string | number, string | number] - | string, + [string | number, string | number, string | number] | string, }>; diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 36f67b2217c2..f733c96c9645 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -39,8 +39,7 @@ const LINEAR_GRADIENT_DEFAULT_DIRECTION: LinearGradientDirection = { }; type LinearGradientDirection = - | {type: 'angle', value: number} - | {type: 'keyword', value: string}; + {type: 'angle', value: number} | {type: 'keyword', value: string}; type LinearGradientBackgroundImage = { type: 'linear-gradient', @@ -77,8 +76,7 @@ type ColorStopColor = ProcessedColorValue | null; type ColorStopPosition = number | string | null; type ParsedBackgroundImageValue = - | LinearGradientBackgroundImage - | RadialGradientBackgroundImage; + LinearGradientBackgroundImage | RadialGradientBackgroundImage; export default function processBackgroundImage( backgroundImage: ?(ReadonlyArray | string), diff --git a/packages/react-native/Libraries/Utilities/PlatformTypes.js b/packages/react-native/Libraries/Utilities/PlatformTypes.js index 5a65087c0794..24c9aac73023 100644 --- a/packages/react-native/Libraries/Utilities/PlatformTypes.js +++ b/packages/react-native/Libraries/Utilities/PlatformTypes.js @@ -9,12 +9,7 @@ */ export type PlatformOSType = - | 'ios' - | 'android' - | 'macos' - | 'windows' - | 'web' - | 'native'; + 'ios' | 'android' | 'macos' | 'windows' | 'web' | 'native'; type OptionalPlatformSelectSpec = { [key in PlatformOSType]?: T, // eslint-disable-line no-unused-vars @@ -259,8 +254,4 @@ type WebPlatform = { }; export type PlatformType = - | IOSPlatform - | AndroidPlatform - | WindowsPlatform - | MacOSPlatform - | WebPlatform; + IOSPlatform | AndroidPlatform | WindowsPlatform | MacOSPlatform | WebPlatform; diff --git a/packages/react-native/Libraries/Utilities/useWindowDimensions.js b/packages/react-native/Libraries/Utilities/useWindowDimensions.js index 1b04aeb0ad4c..48c689116154 100644 --- a/packages/react-native/Libraries/Utilities/useWindowDimensions.js +++ b/packages/react-native/Libraries/Utilities/useWindowDimensions.js @@ -22,8 +22,7 @@ import {useEffect, useState} from 'react'; * @see https://reactnative.dev/docs/usewindowdimensions */ export default function useWindowDimensions(): - | DisplayMetrics - | DisplayMetricsAndroid { + DisplayMetrics | DisplayMetricsAndroid { const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); useEffect(() => { function handleChange({ diff --git a/packages/react-native/flow/bom.js.flow b/packages/react-native/flow/bom.js.flow index 64fd2d2b1a64..a7045edfb507 100644 --- a/packages/react-native/flow/bom.js.flow +++ b/packages/react-native/flow/bom.js.flow @@ -81,7 +81,6 @@ declare var console: { // Stack tagging createTask(label: string): ConsoleTask, - ... }; @@ -515,9 +514,7 @@ declare class XMLHttpRequestEventTarget extends EventTarget { // this part of spec is not finished yet, apparently // https://stackoverflow.com/questions/35296664/can-fetch-get-object-as-headers type HeadersInit = - | Headers - | Array<[string, string]> - | {[key: string]: string, ...}; + Headers | Array<[string, string]> | {[key: string]: string, ...}; // TODO Heades and URLSearchParams are almost the same thing. // Could it somehow be abstracted away? @@ -599,12 +596,7 @@ type ReferrerPolicyType = | 'unsafe-url'; type ResponseType = - | 'basic' - | 'cors' - | 'default' - | 'error' - | 'opaque' - | 'opaqueredirect'; + 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; type BodyInit = | string diff --git a/packages/react-native/flow/cssom.js.flow b/packages/react-native/flow/cssom.js.flow index 50bd5c15deac..6633fbecf443 100644 --- a/packages/react-native/flow/cssom.js.flow +++ b/packages/react-native/flow/cssom.js.flow @@ -409,10 +409,7 @@ type AnimationPlayState = 'idle' | 'running' | 'paused' | 'finished'; type AnimationReplaceState = 'active' | 'removed' | 'persisted'; type FillMode = 'none' | 'forwards' | 'backwards' | 'both' | 'auto'; type PlaybackDirection = - | 'normal' - | 'reverse' - | 'alternate' - | 'alternate-reverse'; + 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'; type IterationCompositeOperation = 'replace' | 'accumulate'; type CompositeOperation = 'replace' | 'add' | 'accumulate'; type CompositeOperationOrAuto = CompositeOperation | 'auto'; @@ -481,12 +478,7 @@ type PropertyIndexedKeyframes = { easing?: string | string[], offset?: number | (number | null)[], [property: string]: - | string - | string[] - | number - | null - | (number | null)[] - | void, + string | string[] | number | null | (number | null)[] | void, ... }; diff --git a/packages/react-native/flow/dom.js.flow b/packages/react-native/flow/dom.js.flow index 55e96b385ebd..038a6f01493b 100644 --- a/packages/react-native/flow/dom.js.flow +++ b/packages/react-native/flow/dom.js.flow @@ -172,68 +172,52 @@ type EventHandler = (event: Event) => unknown; type EventListener = {handleEvent: EventHandler, ...} | EventHandler; type MouseEventHandler = (event: MouseEvent) => unknown; type MouseEventListener = - | {handleEvent: MouseEventHandler, ...} - | MouseEventHandler; + {handleEvent: MouseEventHandler, ...} | MouseEventHandler; type FocusEventHandler = (event: FocusEvent) => unknown; type FocusEventListener = - | {handleEvent: FocusEventHandler, ...} - | FocusEventHandler; + {handleEvent: FocusEventHandler, ...} | FocusEventHandler; type KeyboardEventHandler = (event: KeyboardEvent) => unknown; type KeyboardEventListener = - | {handleEvent: KeyboardEventHandler, ...} - | KeyboardEventHandler; + {handleEvent: KeyboardEventHandler, ...} | KeyboardEventHandler; type InputEventHandler = (event: InputEvent) => unknown; type InputEventListener = - | {handleEvent: InputEventHandler, ...} - | InputEventHandler; + {handleEvent: InputEventHandler, ...} | InputEventHandler; type TouchEventHandler = (event: TouchEvent) => unknown; type TouchEventListener = - | {handleEvent: TouchEventHandler, ...} - | TouchEventHandler; + {handleEvent: TouchEventHandler, ...} | TouchEventHandler; type WheelEventHandler = (event: WheelEvent) => unknown; type WheelEventListener = - | {handleEvent: WheelEventHandler, ...} - | WheelEventHandler; + {handleEvent: WheelEventHandler, ...} | WheelEventHandler; type AbortProgressEventHandler = (event: ProgressEvent) => unknown; type AbortProgressEventListener = - | {handleEvent: AbortProgressEventHandler, ...} - | AbortProgressEventHandler; + {handleEvent: AbortProgressEventHandler, ...} | AbortProgressEventHandler; type ProgressEventHandler = (event: ProgressEvent) => unknown; type ProgressEventListener = - | {handleEvent: ProgressEventHandler, ...} - | ProgressEventHandler; + {handleEvent: ProgressEventHandler, ...} | ProgressEventHandler; type DragEventHandler = (event: DragEvent) => unknown; type DragEventListener = - | {handleEvent: DragEventHandler, ...} - | DragEventHandler; + {handleEvent: DragEventHandler, ...} | DragEventHandler; type PointerEventHandler = (event: PointerEvent) => unknown; type PointerEventListener = - | {handleEvent: PointerEventHandler, ...} - | PointerEventHandler; + {handleEvent: PointerEventHandler, ...} | PointerEventHandler; type AnimationEventHandler = (event: AnimationEvent) => unknown; type AnimationEventListener = - | {handleEvent: AnimationEventHandler, ...} - | AnimationEventHandler; + {handleEvent: AnimationEventHandler, ...} | AnimationEventHandler; type ClipboardEventHandler = (event: ClipboardEvent) => unknown; type ClipboardEventListener = - | {handleEvent: ClipboardEventHandler, ...} - | ClipboardEventHandler; + {handleEvent: ClipboardEventHandler, ...} | ClipboardEventHandler; type TransitionEventHandler = (event: TransitionEvent) => unknown; type TransitionEventListener = - | {handleEvent: TransitionEventHandler, ...} - | TransitionEventHandler; + {handleEvent: TransitionEventHandler, ...} | TransitionEventHandler; type MessageEventHandler = (event: MessageEvent) => unknown; type MessageEventListener = - | {handleEvent: MessageEventHandler, ...} - | MessageEventHandler; + {handleEvent: MessageEventHandler, ...} | MessageEventHandler; type BeforeUnloadEventHandler = (event: BeforeUnloadEvent) => unknown; type BeforeUnloadEventListener = - | {handleEvent: BeforeUnloadEventHandler, ...} - | BeforeUnloadEventHandler; + {handleEvent: BeforeUnloadEventHandler, ...} | BeforeUnloadEventHandler; type StorageEventHandler = (event: StorageEvent) => unknown; type StorageEventListener = - | {handleEvent: StorageEventHandler, ...} - | StorageEventHandler; + {handleEvent: StorageEventHandler, ...} | StorageEventHandler; type SecurityPolicyViolationEventHandler = ( event: SecurityPolicyViolationEvent, ) => unknown; @@ -242,8 +226,7 @@ type SecurityPolicyViolationEventListener = | SecurityPolicyViolationEventHandler; type USBConnectionEventHandler = (event: USBConnectionEvent) => unknown; type USBConnectionEventListener = - | {handleEvent: USBConnectionEventHandler, ...} - | USBConnectionEventHandler; + {handleEvent: USBConnectionEventHandler, ...} | USBConnectionEventHandler; type MediaKeySessionType = 'temporary' | 'persistent-license'; type MediaKeyStatus = @@ -272,13 +255,7 @@ type TouchEventTypes = 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel'; type WheelEventTypes = 'wheel'; type AbortProgressEventTypes = 'abort'; type ProgressEventTypes = - | 'abort' - | 'error' - | 'load' - | 'loadend' - | 'loadstart' - | 'progress' - | 'timeout'; + 'abort' | 'error' | 'load' | 'loadend' | 'loadstart' | 'progress' | 'timeout'; type DragEventTypes = | 'drag' | 'dragend' @@ -300,15 +277,10 @@ type PointerEventTypes = | 'gotpointercapture' | 'lostpointercapture'; type AnimationEventTypes = - | 'animationstart' - | 'animationend' - | 'animationiteration'; + 'animationstart' | 'animationend' | 'animationiteration'; type ClipboardEventTypes = 'clipboardchange' | 'cut' | 'copy' | 'paste'; type TransitionEventTypes = - | 'transitionrun' - | 'transitionstart' - | 'transitionend' - | 'transitioncancel'; + 'transitionrun' | 'transitionstart' | 'transitionend' | 'transitioncancel'; type MessageEventTypes = string; type BeforeUnloadEventTypes = 'beforeunload'; type StorageEventTypes = 'storage'; @@ -6051,8 +6023,7 @@ type NodeFilterCallback = ( | typeof NodeFilter.FILTER_SKIP; type NodeFilterInterface = - | NodeFilterCallback - | {acceptNode: NodeFilterCallback, ...}; + NodeFilterCallback | {acceptNode: NodeFilterCallback, ...}; // TODO: window.NodeFilter exists at runtime and behaves as a constructor // as far as `instanceof` is concerned, but it is not callable. diff --git a/packages/react-native/scripts/compose-source-maps.js b/packages/react-native/scripts/compose-source-maps.js index dd1c39999b1f..42a67b762c32 100755 --- a/packages/react-native/scripts/compose-source-maps.js +++ b/packages/react-native/scripts/compose-source-maps.js @@ -16,7 +16,7 @@ const {composeSourceMaps} = require('metro-source-map'); const argv = process.argv.slice(2); let outputPath; -for (let i = 0; i < argv.length; ) { +for (let i = 0; i < argv.length;) { if (argv[i] === '-o') { outputPath = argv[i + 1]; argv.splice(i, 2); diff --git a/packages/react-native/scripts/featureflags/types.js b/packages/react-native/scripts/featureflags/types.js index 086d3f9bbe05..acec7193a8ae 100644 --- a/packages/react-native/scripts/featureflags/types.js +++ b/packages/react-native/scripts/featureflags/types.js @@ -23,10 +23,7 @@ export type FeatureFlagDefinitions = Readonly<{ * - stable: the value of the feature flag will be `expectedReleaseValue` on all releases. */ export type OSSReleaseStageValue = - | 'none' - | 'experimental' - | 'canary' - | 'stable'; + 'none' | 'experimental' | 'canary' | 'stable'; export type CommonFeatureFlagConfig< TValue extends FeatureFlagValue = FeatureFlagValue, diff --git a/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js b/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js index 52817be7732e..76b13411730f 100644 --- a/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js +++ b/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js @@ -25,7 +25,7 @@ export interface Spec extends TurboModule { }; readonly findPseudoElementShadowNodeByTag: ( reactTag: number, - ) => ?unknown /* Node */; + ) => ?unknown; /* Node */ readonly waitForTransitionAnimation: (animationId: number) => void; readonly transitionAnimationFinished: (animationId: number) => void; } diff --git a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js index 12a34399221c..5253243ef670 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js +++ b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js @@ -19,9 +19,7 @@ export opaque type NativeElementReference = ShadowNode; export opaque type NativeTextReference = ShadowNode; export type NativeNodeReference = - | NativeElementReference - | NativeTextReference - | RootTag; + NativeElementReference | NativeTextReference | RootTag; export type MeasureInWindowOnSuccessCallback = ( x: number, @@ -58,16 +56,16 @@ export interface Spec extends TurboModule { readonly getChildNodes: ( nativeNodeReference: unknown /* NativeNodeReference */, - ) => ReadonlyArray /* $ReadOnlyArray */; + ) => ReadonlyArray; /* $ReadOnlyArray */ readonly getElementById?: ( nativeNodeReference: unknown /* NativeNodeReference */, id: string, - ) => unknown /* ?InstanceHandle */; + ) => unknown; /* ?InstanceHandle */ readonly getParentNode: ( nativeNodeReference: unknown /* NativeNodeReference */, - ) => unknown /* ?InstanceHandle */; + ) => unknown; /* ?InstanceHandle */ readonly isConnected: ( nativeNodeReference: unknown /* NativeNodeReference */, @@ -79,24 +77,24 @@ export interface Spec extends TurboModule { readonly getBorderWidth: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [topWidth: number, rightWidth: number, bottomWidth: number, leftWidth: number] */; + ) => ReadonlyArray; /* [topWidth: number, rightWidth: number, bottomWidth: number, leftWidth: number] */ readonly getBoundingClientRect: ( nativeElementReference: unknown /* NativeElementReference */, includeTransform: boolean, - ) => ReadonlyArray /* [x: number, y: number, width: number, height: number] */; + ) => ReadonlyArray; /* [x: number, y: number, width: number, height: number] */ readonly getInnerSize: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [width: number, height: number] */; + ) => ReadonlyArray; /* [width: number, height: number] */ readonly getScrollPosition: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [scrollLeft: number, scrollTop: number] */; + ) => ReadonlyArray; /* [scrollLeft: number, scrollTop: number] */ readonly getScrollSize: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [scrollWidth: number, scrollHeight: number] */; + ) => ReadonlyArray; /* [scrollWidth: number, scrollHeight: number] */ readonly getTagName: ( nativeElementReference: unknown /* NativeElementReference */, @@ -127,7 +125,7 @@ export interface Spec extends TurboModule { readonly getOffset: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [offsetParent: ?InstanceHandle, top: number, left: number] */; + ) => ReadonlyArray; /* [offsetParent: ?InstanceHandle, top: number, left: number] */ /* * Special methods to handle the root node. @@ -136,7 +134,7 @@ export interface Spec extends TurboModule { readonly linkRootNode?: ( rootTag: number /* RootTag */, instanceHandle: unknown /* InstanceHandle */, - ) => unknown /* ?NativeElementReference */; + ) => unknown; /* ?NativeElementReference */ /** * Legacy layout APIs (for `ReactNativeElement`). diff --git a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js index 7440f670a96a..ac6eda8c618a 100644 --- a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js +++ b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js @@ -38,14 +38,12 @@ export function expectRectEquals( expected: {x: number, y: number, width: number, height: number}, ): boolean { const {x, y, width, height} = expected; - if ( - !( - rect.x === x && - rect.y === y && - rect.width === width && - rect.height === height - ) - ) { + if (!( + rect.x === x && + rect.y === y && + rect.width === width && + rect.height === height + )) { const received = { x: rect.x, y: rect.y, diff --git a/packages/react-native/src/private/webapis/performance/PerformanceEntry.js b/packages/react-native/src/private/webapis/performance/PerformanceEntry.js index 9e4fa32fe455..aef916f4e7f1 100644 --- a/packages/react-native/src/private/webapis/performance/PerformanceEntry.js +++ b/packages/react-native/src/private/webapis/performance/PerformanceEntry.js @@ -14,11 +14,7 @@ import {setPlatformObject} from '../webidl/PlatformObjects'; export type DOMHighResTimeStamp = number; export type PerformanceEntryType = - | 'mark' - | 'measure' - | 'event' - | 'longtask' - | 'resource'; + 'mark' | 'measure' | 'event' | 'longtask' | 'resource'; export type PerformanceEntryJSON = { name: string, diff --git a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js index de5ee16441f4..45af579fcd7f 100644 --- a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js +++ b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js @@ -25,8 +25,7 @@ export type FailingPlatformTestAssertionResult = Readonly<{ }>; export type PlatformTestAssertionResult = - | PassingPlatformTestAssertionResult - | FailingPlatformTestAssertionResult; + PassingPlatformTestAssertionResult | FailingPlatformTestAssertionResult; export type PlatformTestResultStatus = 'PASS' | 'FAIL' | 'ERROR' | 'SKIPPED'; diff --git a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js index 4ee3377ff31f..9bf56b7f6a16 100644 --- a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js +++ b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js @@ -49,8 +49,7 @@ const TextInputForm = () => { const CloseButton = ( props: - | {behavior: any, setModalOpen: any} - | {behavior: string, setModalOpen: any}, + {behavior: any, setModalOpen: any} | {behavior: string, setModalOpen: any}, ) => { return ( = first && - first >= 0 && - last < itemCount && - first >= overscanFirst && - last <= overscanLast && - first <= visible.first && - last >= visible.last - ) - ) { + if (!( + last >= first && + first >= 0 && + last < itemCount && + first >= overscanFirst && + last <= overscanLast && + first <= visible.first && + last >= visible.last + )) { throw new Error( 'Bad window calculation ' + JSON.stringify({ diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 298331a0b839..a8593403e4cd 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -1973,9 +1973,9 @@ class VirtualizedList extends StateSafePureComponent< // Keep a viewport's worth of content around the last focused cell to allow // random navigation around it without any blanking. E.g. tabbing from one // focused item out of viewport to another. - if ( - !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey]) - ) { + if (!( + this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey] + )) { return []; } diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js index 55a63f8866ff..b9551b655421 100644 --- a/packages/virtualized-lists/Lists/VirtualizedSectionList.js +++ b/packages/virtualized-lists/Lists/VirtualizedSectionList.js @@ -131,10 +131,8 @@ type State = {childProps: VirtualizedListProps, ...}; */ class VirtualizedSectionList< ItemT, - SectionT extends SectionBase< - ItemT, + SectionT extends SectionBase = DefaultVirtualizedSectionT, - > = DefaultVirtualizedSectionT, > extends React.PureComponent< VirtualizedSectionListProps, State, @@ -643,10 +641,8 @@ function ItemWithSeparator( const VirtualizedSectionListComponent = VirtualizedSectionList as component< ItemT, - SectionT extends SectionBase< - ItemT, + SectionT extends SectionBase = DefaultVirtualizedSectionT, - > = DefaultVirtualizedSectionT, >( ref?: React.RefSetter< interface { diff --git a/private/react-native-fantom/src/Benchmark.js b/private/react-native-fantom/src/Benchmark.js index 37a8b297ff73..f564d7f8e8ec 100644 --- a/private/react-native-fantom/src/Benchmark.js +++ b/private/react-native-fantom/src/Benchmark.js @@ -61,8 +61,7 @@ type SuiteResults = Array>; type TestWithArgName = string | ((testArg: TestArgType) => string); type TestWithArgOptions = - | FnOptions - | ((testArg: TestArgType) => FnOptions); + FnOptions | ((testArg: TestArgType) => FnOptions); interface ParameterizedTestFunction { ( From 65af886291309f1fbdb84d3fe68f06c5bbf60ce8 Mon Sep 17 00:00:00 2001 From: generatedunixname89002005232357 Date: Mon, 13 Jul 2026 16:42:37 -0700 Subject: [PATCH 048/834] Revert D111190630: Stop using `prettier-plugin-hermes-parser` for formatting Differential Revision: D111190630 Original commit changeset: e7ed7ee6f057 Original Phabricator Diff: D111190630 fbshipit-source-id: 49e33587fa084c181d64d35ac6bcf6ab73edfa5f --- .prettierrc.js | 8 +- flow-typed/environment/node.js | 48 +++++++--- flow-typed/npm/babel-traverse_v7.x.x.js | 18 +++- flow-typed/npm/babel_v7.x.x.js | 3 +- flow-typed/npm/commander_v12.x.x.js | 8 +- flow-typed/npm/electron-packager_v20.x.x.js | 11 ++- flow-typed/npm/execa_v5.x.x.js | 10 +- flow-typed/npm/jest.js | 5 +- flow-typed/npm/jsonc-parser_v2.2.x.js | 8 +- flow-typed/npm/node-fetch_v2.x.x.js | 14 ++- flow-typed/npm/pretty-format_v29.x.x.js | 4 +- flow-typed/npm/rxjs_v6.x.x.js | 26 ++--- flow-typed/npm/shelljs_v0.x.x.js | 9 +- flow-typed/npm/tinybench_v4.1.x.js | 4 +- .../src/inspector-proxy/CdpDebugLogging.js | 5 +- .../src/inspector-proxy/types.js | 9 +- .../platform-colors.js | 14 +-- .../react-native-modules.js | 22 +++-- packages/jest-preset/jest-preset.js | 5 +- packages/metro-config/src/index.flow.js | 10 +- .../react-native-codegen/src/CodegenSchema.js | 15 ++- .../src/parsers/flow/components/commands.js | 14 +-- .../parsers/typescript/components/commands.js | 16 ++-- .../typescript/components/componentsUtils.js | 6 +- .../react-native-codegen/src/parsers/utils.js | 22 +++-- .../src/ComparisonResult.js | 3 +- .../src/DiffResults.js | 4 +- .../react-native/Libraries/Alert/Alert.js | 5 +- .../Libraries/Animated/AnimatedEvent.js | 4 +- .../Animated/createAnimatedComponent.js | 27 +++--- .../Animated/nodes/AnimatedInterpolation.js | 4 +- .../Libraries/AppState/AppState.js | 6 +- .../Libraries/BatchedBridge/MessageQueue.js | 3 +- .../AccessibilityInfo/AccessibilityInfo.js | 5 +- .../Libraries/Components/Button.js | 3 +- .../Libraries/Components/Keyboard/Keyboard.js | 6 +- .../Components/Pressable/Pressable.js | 3 +- .../Components/ScrollView/ScrollView.js | 16 ++-- .../ScrollViewNativeComponentType.js | 5 +- .../Components/TextInput/TextInput.flow.js | 44 +++++++-- .../Components/TextInput/TextInput.js | 95 +++++++++---------- .../Components/Touchable/Touchable.js | 4 +- .../Touchable/TouchableNativeFeedback.js | 3 +- .../Touchable/TouchableWithoutFeedback.js | 5 +- .../EventEmitter/NativeEventEmitter.js | 3 +- .../__mocks__/NativeEventEmitter.js | 3 +- .../Libraries/Image/ImageSource.js | 4 +- .../react-native/Libraries/Modal/Modal.js | 5 +- .../Libraries/Network/XMLHttpRequest.js | 19 ++-- .../Libraries/Pressability/HoverState.js | 6 +- .../Libraries/StyleSheet/StyleSheetTypes.js | 40 ++++++-- .../StyleSheet/private/_TransformStyle.js | 6 +- .../StyleSheet/processBackgroundImage.js | 6 +- .../Libraries/Utilities/PlatformTypes.js | 13 ++- .../Utilities/useWindowDimensions.js | 3 +- packages/react-native/flow/bom.js.flow | 12 ++- packages/react-native/flow/cssom.js.flow | 12 ++- packages/react-native/flow/dom.js.flow | 71 ++++++++++---- .../scripts/compose-source-maps.js | 2 +- .../scripts/featureflags/types.js | 5 +- .../specs/NativeViewTransition.js | 2 +- .../webapis/dom/nodes/specs/NativeDOM.js | 24 ++--- .../__tests__/IntersectionObserver-itest.js | 14 +-- .../webapis/performance/PerformanceEntry.js | 6 +- .../PlatformTest/RNTesterPlatformTestTypes.js | 3 +- .../KeyboardAvoidingViewExample.js | 3 +- .../Lists/VirtualizeUtils.js | 20 ++-- .../Lists/VirtualizedList.js | 6 +- .../Lists/VirtualizedSectionList.js | 8 +- private/react-native-fantom/src/Benchmark.js | 3 +- 70 files changed, 579 insertions(+), 279 deletions(-) diff --git a/.prettierrc.js b/.prettierrc.js index 70a05cba5f46..dca775b07037 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -7,6 +7,11 @@ * @format */ +let plugins = ['prettier-plugin-hermes-parser']; +try { + plugins = require('./.prettier-plugins.fb.js'); +} catch {} + module.exports = { arrowParens: 'avoid', bracketSameLine: true, @@ -15,6 +20,7 @@ module.exports = { singleQuote: true, trailingComma: 'all', endOfLine: 'lf', + plugins, overrides: [ { files: ['*.code-workspace'], @@ -25,7 +31,7 @@ module.exports = { { files: ['*.js', '*.js.flow'], options: { - parser: 'flow', + parser: 'hermes', }, }, { diff --git a/flow-typed/environment/node.js b/flow-typed/environment/node.js index c92ff006df23..1a99ec0dcbd2 100644 --- a/flow-typed/environment/node.js +++ b/flow-typed/environment/node.js @@ -457,7 +457,8 @@ declare class child_process$ChildProcessTyped< TStdin extends stream$Writable | null, TStdout extends stream$Readable | null, TStderr extends stream$Readable | null, -> extends events$EventEmitter { +> extends events$EventEmitter +{ +stdin: TStdin; +stdout: TStdout; +stderr: TStderr; @@ -577,7 +578,8 @@ declare module 'child_process' { declare function execFile( file: string, argsOrCallback?: - ReadonlyArray | child_process$execFileCallback, + | ReadonlyArray + | child_process$execFileCallback, callback?: child_process$execFileCallback, ): child_process$ChildProcessTyped< stream$Writable, @@ -926,7 +928,14 @@ type crypto$key = declare class crypto$KeyObject { +asymmetricKeyType?: - 'rsa' | 'rsa-pss' | 'dsa' | 'ec' | 'ed25519' | 'ed448' | 'x25519' | 'x448'; + | 'rsa' + | 'rsa-pss' + | 'dsa' + | 'ec' + | 'ed25519' + | 'ed448' + | 'x25519' + | 'x448'; +asymmetricKeySize?: number; +symmetricKeySize?: number; +type: 'secret' | 'public' | 'private'; @@ -2551,9 +2560,9 @@ declare class http$Agent<+SocketT = net$Socket> { sockets: {[name: string]: ReadonlyArray, ...}; } -declare class http$IncomingMessage< - SocketT = net$Socket, -> extends stream$Readable { +declare class http$IncomingMessage + extends stream$Readable +{ headers: Object; rawHeaders: Array; httpVersion: string; @@ -2569,9 +2578,9 @@ declare class http$IncomingMessage< rawTrailers: Array; } -declare class http$ClientRequest< - +SocketT = net$Socket, -> extends stream$Writable { +declare class http$ClientRequest<+SocketT = net$Socket> + extends stream$Writable +{ abort(): void; aborted: boolean; +connection: SocketT | null; @@ -2869,7 +2878,10 @@ declare class net$Socket extends stream$Duplex { destroyed: boolean; end( chunkOrEncodingOrCallback?: - Buffer | Uint8Array | string | ((data: any) => void), + | Buffer + | Uint8Array + | string + | ((data: any) => void), encodingOrCallback?: string | ((data: any) => void), callback?: (data: any) => void, ): this; @@ -3199,9 +3211,9 @@ declare module 'perf_hooks' { +detail?: T; } - declare export class PerformanceMeasure< - T = unknown, - > extends PerformanceEntry { + declare export class PerformanceMeasure + extends PerformanceEntry + { +entryType: 'measure'; +detail?: T; } @@ -3386,7 +3398,11 @@ declare module 'querystring' { */ declare module 'node:sqlite' { declare export type SupportedValueType = - null | number | bigint | string | Uint8Array; + | null + | number + | bigint + | string + | Uint8Array; declare export type DatabaseSyncOptions = Readonly<{ open?: boolean, @@ -5549,7 +5565,9 @@ declare module 'repl' { writer?: (object: any, options?: util$InspectOptions) => string, completer?: readline$InterfaceCompleter, replMode?: - $SymbolReplModeMagic | $SymbolReplModeSloppy | $SymbolReplModeStrict, + | $SymbolReplModeMagic + | $SymbolReplModeSloppy + | $SymbolReplModeStrict, breakEvalOnSigint?: boolean, ... }): REPLServer; diff --git a/flow-typed/npm/babel-traverse_v7.x.x.js b/flow-typed/npm/babel-traverse_v7.x.x.js index d3b6cd034c47..e28bb4921fed 100644 --- a/flow-typed/npm/babel-traverse_v7.x.x.js +++ b/flow-typed/npm/babel-traverse_v7.x.x.js @@ -246,7 +246,12 @@ declare module '@babel/traverse' { } declare export type BindingKind = - 'var' | 'let' | 'const' | 'module' | 'hoisted' | 'unknown'; + | 'var' + | 'let' + | 'const' + | 'module' + | 'hoisted' + | 'unknown'; declare export class Binding { constructor(opts: { @@ -430,7 +435,13 @@ declare module '@babel/traverse' { isBaseType(baseName: string, soft?: boolean): boolean; couldBeBaseType( name: - 'string' | 'number' | 'boolean' | 'any' | 'mixed' | 'empty' | 'void', + | 'string' + | 'number' + | 'boolean' + | 'any' + | 'mixed' + | 'empty' + | 'void', ): boolean; baseTypeStrictlyMatches(right: NodePath<>): ?boolean; isGenericType(genericName: string): boolean; @@ -1435,7 +1446,8 @@ declare module '@babel/traverse' { }>; declare export type VisitNode<-TNode extends BabelNode, TState> = - VisitNodeFunction | VisitNodeObject; + | VisitNodeFunction + | VisitNodeObject; declare export type Visitor = Readonly<{ enter?: VisitNodeFunction, diff --git a/flow-typed/npm/babel_v7.x.x.js b/flow-typed/npm/babel_v7.x.x.js index f58af46605e8..abba5ac2cb69 100644 --- a/flow-typed/npm/babel_v7.x.x.js +++ b/flow-typed/npm/babel_v7.x.x.js @@ -1293,7 +1293,8 @@ declare module '@babel/template' { }; declare export type PublicReplacements = - {[string]: ?BabelNode} | Array; + | {[string]: ?BabelNode} + | Array; declare export type TemplateBuilder = { // Build a new builder, merging the given options with the previous ones. diff --git a/flow-typed/npm/commander_v12.x.x.js b/flow-typed/npm/commander_v12.x.x.js index d98bd1ed5335..1f18ac9da056 100644 --- a/flow-typed/npm/commander_v12.x.x.js +++ b/flow-typed/npm/commander_v12.x.x.js @@ -15,7 +15,8 @@ declare module 'commander' { declare type LiteralUnion = - LiteralType | {...BaseType, ...{[key: empty]: empty, ...}}; + | LiteralType + | {...BaseType, ...{[key: empty]: empty, ...}}; declare export class CommanderError mixins Error { code: string; @@ -334,7 +335,10 @@ declare module 'commander' { outputError?: (str: string, write: (str: string) => void) => void; } export type AddHelpTextPosition = - 'beforeAll' | 'before' | 'after' | 'afterAll'; + | 'beforeAll' + | 'before' + | 'after' + | 'afterAll'; export type HookEvent = 'preSubcommand' | 'preAction' | 'postAction'; export type OptionValueSource = LiteralUnion< 'default' | 'config' | 'env' | 'cli' | 'implied', diff --git a/flow-typed/npm/electron-packager_v20.x.x.js b/flow-typed/npm/electron-packager_v20.x.x.js index 86e3cfc25f1d..38c859aca693 100644 --- a/flow-typed/npm/electron-packager_v20.x.x.js +++ b/flow-typed/npm/electron-packager_v20.x.x.js @@ -49,14 +49,21 @@ declare module '@electron/packager' { ProductName?: string, InternalName?: string, 'requested-execution-level'?: - 'asInvoker' | 'highestAvailable' | 'requireAdministrator', + | 'asInvoker' + | 'highestAvailable' + | 'requireAdministrator', 'application-manifest'?: string, }>; declare export type WindowsSignOptions = $FlowFixMe; declare export type OfficialArch = - 'ia32' | 'x64' | 'armv7l' | 'arm64' | 'mips64el' | 'universal'; + | 'ia32' + | 'x64' + | 'armv7l' + | 'arm64' + | 'mips64el' + | 'universal'; declare export type OfficialPlatform = 'linux' | 'win32' | 'darwin' | 'mas'; diff --git a/flow-typed/npm/execa_v5.x.x.js b/flow-typed/npm/execa_v5.x.x.js index a3a8dae8b4c3..c54a07291e94 100644 --- a/flow-typed/npm/execa_v5.x.x.js +++ b/flow-typed/npm/execa_v5.x.x.js @@ -13,7 +13,12 @@ declare module 'execa' { declare type StdIoOption = - 'pipe' | 'ipc' | 'ignore' | 'inherit' | stream$Stream | number; + | 'pipe' + | 'ipc' + | 'ignore' + | 'inherit' + | stream$Stream + | number; declare type CommonOptions = { argv0?: string, @@ -69,7 +74,8 @@ declare module 'execa' { }; declare interface ExecaPromise - extends Promise, child_process$ChildProcess {} + extends Promise, + child_process$ChildProcess {} declare interface ExecaError extends ErrnoError { stdout: string; diff --git a/flow-typed/npm/jest.js b/flow-typed/npm/jest.js index 70597548c96c..00767deaa1c8 100644 --- a/flow-typed/npm/jest.js +++ b/flow-typed/npm/jest.js @@ -214,7 +214,10 @@ type FakeTimersConfig = { */ type JestStyledComponentsMatcherValue = - string | JestAsymmetricEqualityType | RegExp | void; + | string + | JestAsymmetricEqualityType + | RegExp + | void; type JestStyledComponentsMatcherOptions = { media?: string, diff --git a/flow-typed/npm/jsonc-parser_v2.2.x.js b/flow-typed/npm/jsonc-parser_v2.2.x.js index c8b47b6404ef..51eadf91b48a 100644 --- a/flow-typed/npm/jsonc-parser_v2.2.x.js +++ b/flow-typed/npm/jsonc-parser_v2.2.x.js @@ -155,7 +155,13 @@ declare module 'jsonc-parser' { | 'InvalidCharacter' | ''; export type NodeType = - 'object' | 'array' | 'property' | 'string' | 'number' | 'boolean' | 'null'; + | 'object' + | 'array' + | 'property' + | 'string' + | 'number' + | 'boolean' + | 'null'; export type Node = { type: NodeType, value?: any, diff --git a/flow-typed/npm/node-fetch_v2.x.x.js b/flow-typed/npm/node-fetch_v2.x.x.js index 22108efa6b74..5b6b9cc51b46 100644 --- a/flow-typed/npm/node-fetch_v2.x.x.js +++ b/flow-typed/npm/node-fetch_v2.x.x.js @@ -157,7 +157,12 @@ declare module 'node-fetch' { } declare type ResponseType = - 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; + | 'basic' + | 'cors' + | 'default' + | 'error' + | 'opaque' + | 'opaqueredirect'; declare interface ResponseInit { headers?: HeaderInit; @@ -167,7 +172,12 @@ declare module 'node-fetch' { declare type HeaderInit = Headers | Array; declare type BodyInit = - string | null | Buffer | Blob | Readable | URLSearchParams; + | string + | null + | Buffer + | Blob + | Readable + | URLSearchParams; declare function fetch( url: string | URL | Request, diff --git a/flow-typed/npm/pretty-format_v29.x.x.js b/flow-typed/npm/pretty-format_v29.x.x.js index fbcdfda1f5e2..a13951717f0f 100644 --- a/flow-typed/npm/pretty-format_v29.x.x.js +++ b/flow-typed/npm/pretty-format_v29.x.x.js @@ -37,7 +37,9 @@ declare type PrettyFormatPlugin = declare module 'pretty-format' { declare export type CompareKeys = - ((a: string, b: string) => number) | null | void; + | ((a: string, b: string) => number) + | null + | void; declare export function format( value: unknown, options?: ?{ diff --git a/flow-typed/npm/rxjs_v6.x.x.js b/flow-typed/npm/rxjs_v6.x.x.js index dfb0557cd726..a4326724e3ee 100644 --- a/flow-typed/npm/rxjs_v6.x.x.js +++ b/flow-typed/npm/rxjs_v6.x.x.js @@ -9,14 +9,11 @@ declare interface rxjs$UnaryFunction { (source: T): R; } -declare interface rxjs$OperatorFunction extends rxjs$UnaryFunction< - rxjs$Observable, - rxjs$Observable, -> {} +declare interface rxjs$OperatorFunction + extends rxjs$UnaryFunction, rxjs$Observable> {} declare type rxjs$FactoryOrValue = T | (() => T); -declare interface rxjs$MonoTypeOperatorFunction< - T, -> extends rxjs$OperatorFunction {} +declare interface rxjs$MonoTypeOperatorFunction + extends rxjs$OperatorFunction {} declare interface rxjs$Timestamp { value: T; timestamp: number; @@ -50,7 +47,9 @@ declare interface rxjs$Subscribable { ): rxjs$Unsubscribable; } declare type rxjs$ObservableInput = - rxjs$SubscribableOrPromise | Array | Iterable; + | rxjs$SubscribableOrPromise + | Array + | Iterable; declare type rxjs$InteropObservable = { [string | unknown]: () => rxjs$Subscribable, @@ -589,7 +588,8 @@ declare module 'rxjs' { ((...sources: rxjs$ObservableInput[]) => rxjs$Observable), from( input: - rxjs$ObservableInput | rxjs$ObservableInput>, + | rxjs$ObservableInput + | rxjs$ObservableInput>, scheduler?: rxjs$SchedulerLike, ): rxjs$Observable, ArgumentOutOfRangeError: ArgumentOutOfRangeError, @@ -630,7 +630,9 @@ declare module 'rxjs' { ) => rxjs$Observable) & (( ...observables: ( - rxjs$ObservableInput | rxjs$SchedulerLike | number + | rxjs$ObservableInput + | rxjs$SchedulerLike + | number )[] ) => rxjs$Observable) & (( @@ -3347,7 +3349,9 @@ declare module 'rxjs/webSocket' { _output: rxjs$Subject; constructor( urlConfigOrSource: - string | WebSocketSubjectConfig | rxjs$Observable, + | string + | WebSocketSubjectConfig + | rxjs$Observable, destination?: rxjs$Observer, ): void; lift(operator: rxjs$Operator): WebSocketSubject; diff --git a/flow-typed/npm/shelljs_v0.x.x.js b/flow-typed/npm/shelljs_v0.x.x.js index 22eb29608ca5..c5ffdee80618 100644 --- a/flow-typed/npm/shelljs_v0.x.x.js +++ b/flow-typed/npm/shelljs_v0.x.x.js @@ -48,7 +48,14 @@ declare type $npm$shelljs$GrepOpts = $npm$shelljs$OptionsPoly<'-l' | '-v'>; declare type $npm$shelljs$SedOpts = $npm$shelljs$OptionsPoly<'-i'>; declare type $npm$shelljs$SortOpts = $npm$shelljs$OptionsPoly<'-n' | '-r'>; declare type $npm$shelljs$TestOpts = - '-b' | '-c' | '-d' | '-e' | '-f' | '-L' | '-p' | '-S'; + | '-b' + | '-c' + | '-d' + | '-e' + | '-f' + | '-L' + | '-p' + | '-S'; declare type $npm$shelljs$TouchOpts = { [key: '-a' | '-c' | '-m']: boolean, '-d'?: string, diff --git a/flow-typed/npm/tinybench_v4.1.x.js b/flow-typed/npm/tinybench_v4.1.x.js index 821e79e0a969..2b6bbba9bafa 100644 --- a/flow-typed/npm/tinybench_v4.1.x.js +++ b/flow-typed/npm/tinybench_v4.1.x.js @@ -101,7 +101,9 @@ declare module 'tinybench' { // but we type it this way to avoid mistakes (we can make breaking changes // in our definition that they can't). export type Fn = () => - Promise | void | FnReturnedObject; + | Promise + | void + | FnReturnedObject; declare export class Bench extends EventTarget { concurrency: null | 'task' | 'bench'; diff --git a/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js b/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js index b2a054a622c3..056c07f76f0a 100644 --- a/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js +++ b/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js @@ -21,7 +21,10 @@ const debugCDPMessages = require('debug')('Metro:InspectorProxyCDPMessages'); const CDP_MESSAGES_BATCH_DEBUGGING_THROTTLE_MS = 5000; export type CDPMessageDestination = - 'DebuggerToProxy' | 'ProxyToDebugger' | 'DeviceToProxy' | 'ProxyToDevice'; + | 'DebuggerToProxy' + | 'ProxyToDebugger' + | 'DeviceToProxy' + | 'ProxyToDevice'; function getCDPLogPrefix(destination: CDPMessageDestination): string { return util.format( diff --git a/packages/dev-middleware/src/inspector-proxy/types.js b/packages/dev-middleware/src/inspector-proxy/types.js index 707d19ed3bbe..89838f2d11d0 100644 --- a/packages/dev-middleware/src/inspector-proxy/types.js +++ b/packages/dev-middleware/src/inspector-proxy/types.js @@ -107,11 +107,16 @@ export type GetPagesResponse = { // Union type for all possible messages sent from device to Inspector Proxy. export type MessageFromDevice = - GetPagesResponse | WrappedEventFromDevice | DisconnectRequest; + | GetPagesResponse + | WrappedEventFromDevice + | DisconnectRequest; // Union type for all possible messages sent from Inspector Proxy to device. export type MessageToDevice = - GetPagesRequest | WrappedEventToDevice | ConnectRequest | DisconnectRequest; + | GetPagesRequest + | WrappedEventToDevice + | ConnectRequest + | DisconnectRequest; // Page description object that is sent in response to /json HTTP request from debugger. export type PageDescription = Readonly<{ diff --git a/packages/eslint-plugin-react-native/platform-colors.js b/packages/eslint-plugin-react-native/platform-colors.js index 2de452899cd6..b154aabcbe53 100644 --- a/packages/eslint-plugin-react-native/platform-colors.js +++ b/packages/eslint-plugin-react-native/platform-colors.js @@ -58,12 +58,14 @@ module.exports = { } const properties = args[0].properties; properties.forEach(property => { - if (!( - property.type === 'Property' && - (property.value.type === 'Literal' || - (property.value.type === 'CallExpression' && - property.value.callee.name === 'PlatformColor')) - )) { + if ( + !( + property.type === 'Property' && + (property.value.type === 'Literal' || + (property.value.type === 'CallExpression' && + property.value.callee.name === 'PlatformColor')) + ) + ) { context.report({ node, messageId: 'dynamicColorIOSValue', diff --git a/packages/eslint-plugin-specs/react-native-modules.js b/packages/eslint-plugin-specs/react-native-modules.js index 5983f10dc832..78e6e11559ca 100644 --- a/packages/eslint-plugin-specs/react-native-modules.js +++ b/packages/eslint-plugin-specs/react-native-modules.js @@ -91,18 +91,22 @@ function isModuleRequire(node) { } const memberExpression = callExpression.callee; - if (!( - memberExpression.object.type === 'Identifier' && - memberExpression.object.name === 'TurboModuleRegistry' - )) { + if ( + !( + memberExpression.object.type === 'Identifier' && + memberExpression.object.name === 'TurboModuleRegistry' + ) + ) { return false; } - if (!( - memberExpression.property.type === 'Identifier' && - (memberExpression.property.name === 'get' || - memberExpression.property.name === 'getEnforcing') - )) { + if ( + !( + memberExpression.property.type === 'Identifier' && + (memberExpression.property.name === 'get' || + memberExpression.property.name === 'getEnforcing') + ) + ) { return false; } return true; diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index fc99494c37d8..bfe131904a21 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -28,8 +28,9 @@ module.exports = { resolver: require.resolve('./jest/resolver.js'), transform: { '^.+\\.(js|ts|tsx)$': 'babel-jest', - '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': - require.resolve('./jest/assetFileTransformer.js'), + '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': require.resolve( + './jest/assetFileTransformer.js', + ), }, transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)', diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index 2f4e44c187bc..74fc4053c2b7 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -85,10 +85,12 @@ export function getDefaultConfig(projectRoot: string): ConfigT { transformer: { allowOptionalDependencies: true, assetRegistryPath: 'react-native/asset-registry', - asyncRequireModulePath: - require.resolve('metro-runtime/src/modules/asyncRequire'), - babelTransformerPath: - require.resolve('@react-native/metro-babel-transformer'), + asyncRequireModulePath: require.resolve( + 'metro-runtime/src/modules/asyncRequire', + ), + babelTransformerPath: require.resolve( + '@react-native/metro-babel-transformer', + ), getTransformOptions: async () => ({ transform: { experimentalImportSupport: false, diff --git a/packages/react-native-codegen/src/CodegenSchema.js b/packages/react-native-codegen/src/CodegenSchema.js index 1c383704fd72..41b8edabf542 100644 --- a/packages/react-native-codegen/src/CodegenSchema.js +++ b/packages/react-native-codegen/src/CodegenSchema.js @@ -270,7 +270,8 @@ export type ReservedTypeAnnotation = Readonly<{ * NativeModule Types */ export type Nullable = - NullableTypeAnnotation | T; + | NullableTypeAnnotation + | T; export type NullableTypeAnnotation = Readonly<{ @@ -344,7 +345,8 @@ export type NativeModuleEnumMember = { }; export type NativeModuleEnumMemberType = - 'NumberTypeAnnotation' | 'StringTypeAnnotation'; + | 'NumberTypeAnnotation' + | 'StringTypeAnnotation'; export type NativeModuleEnumDeclaration = Readonly<{ name: string, @@ -434,10 +436,12 @@ export type NativeModuleBaseTypeAnnotation = | NativeModuleMixedTypeAnnotation; export type NativeModuleParamTypeAnnotation = - NativeModuleBaseTypeAnnotation | NativeModuleParamOnlyTypeAnnotation; + | NativeModuleBaseTypeAnnotation + | NativeModuleParamOnlyTypeAnnotation; export type NativeModuleReturnTypeAnnotation = - NativeModuleBaseTypeAnnotation | NativeModuleReturnOnlyTypeAnnotation; + | NativeModuleBaseTypeAnnotation + | NativeModuleReturnOnlyTypeAnnotation; export type NativeModuleTypeAnnotation = | NativeModuleBaseTypeAnnotation @@ -448,7 +452,8 @@ export type NativeModuleTypeAnnotation = type NativeModuleParamOnlyTypeAnnotation = NativeModuleFunctionTypeAnnotation; type NativeModuleReturnOnlyTypeAnnotation = - NativeModulePromiseTypeAnnotation | VoidTypeAnnotation; + | NativeModulePromiseTypeAnnotation + | VoidTypeAnnotation; // Add the allowed component reserved types to the native module union export type CompleteReservedTypeAnnotation = diff --git a/packages/react-native-codegen/src/parsers/flow/components/commands.js b/packages/react-native-codegen/src/parsers/flow/components/commands.js index 381fef3c88dd..8800427a6157 100644 --- a/packages/react-native-codegen/src/parsers/flow/components/commands.js +++ b/packages/react-native-codegen/src/parsers/flow/components/commands.js @@ -49,12 +49,14 @@ function buildCommandSchema( const firstParam = value.params[0].typeAnnotation; - if (!( - firstParam.id != null && - firstParam.id.type === 'QualifiedTypeIdentifier' && - firstParam.id.qualification.name === 'React' && - firstParam.id.id.name === 'ElementRef' - )) { + if ( + !( + firstParam.id != null && + firstParam.id.type === 'QualifiedTypeIdentifier' && + firstParam.id.qualification.name === 'React' && + firstParam.id.id.name === 'ElementRef' + ) + ) { throw new Error( `The first argument of method ${name} must be of type React.ElementRef<>`, ); diff --git a/packages/react-native-codegen/src/parsers/typescript/components/commands.js b/packages/react-native-codegen/src/parsers/typescript/components/commands.js index f097b2b9314b..f51d7ccede32 100644 --- a/packages/react-native-codegen/src/parsers/typescript/components/commands.js +++ b/packages/react-native-codegen/src/parsers/typescript/components/commands.js @@ -33,13 +33,15 @@ function buildCommandSchemaInternal( parser: Parser, ): NamedShape { const firstParam = parameters[0].typeAnnotation; - if (!( - firstParam.typeAnnotation != null && - firstParam.typeAnnotation.type === 'TSTypeReference' && - firstParam.typeAnnotation.typeName.left?.name === 'React' && - (firstParam.typeAnnotation.typeName.right?.name === 'ElementRef' || - firstParam.typeAnnotation.typeName.right?.name === 'ComponentRef') - )) { + if ( + !( + firstParam.typeAnnotation != null && + firstParam.typeAnnotation.type === 'TSTypeReference' && + firstParam.typeAnnotation.typeName.left?.name === 'React' && + (firstParam.typeAnnotation.typeName.right?.name === 'ElementRef' || + firstParam.typeAnnotation.typeName.right?.name === 'ComponentRef') + ) + ) { throw new Error( `The first argument of method ${name} must be of type React.ElementRef<> or React.ComponentRef<>`, ); diff --git a/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js b/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js index 407327d24c17..23d16906883b 100644 --- a/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js +++ b/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js @@ -354,7 +354,8 @@ function setDefaultValue( /* $FlowFixMe[invalid-compare] Error discovered during Constant Condition * roll out. See https://fburl.com/workplace/5whu3i34. */ (defaultValue === null ? null : defaultValue ? defaultValue : 0) as - number | null; + | number + | null; break; case 'BooleanTypeAnnotation': /* $FlowFixMe[invalid-compare] Error discovered during Constant Condition @@ -363,7 +364,8 @@ function setDefaultValue( break; case 'StringTypeAnnotation': common.default = (defaultValue === undefined ? null : defaultValue) as - string | null; + | string + | null; break; } } diff --git a/packages/react-native-codegen/src/parsers/utils.js b/packages/react-native-codegen/src/parsers/utils.js index 3ddb518fbc38..851450b0b181 100644 --- a/packages/react-native-codegen/src/parsers/utils.js +++ b/packages/react-native-codegen/src/parsers/utils.js @@ -177,18 +177,22 @@ function isModuleRegistryCall(node: $FlowFixMe): boolean { } const memberExpression = callExpression.callee; - if (!( - memberExpression.object.type === 'Identifier' && - memberExpression.object.name === 'TurboModuleRegistry' - )) { + if ( + !( + memberExpression.object.type === 'Identifier' && + memberExpression.object.name === 'TurboModuleRegistry' + ) + ) { return false; } - if (!( - memberExpression.property.type === 'Identifier' && - (memberExpression.property.name === 'get' || - memberExpression.property.name === 'getEnforcing') - )) { + if ( + !( + memberExpression.property.type === 'Identifier' && + (memberExpression.property.name === 'get' || + memberExpression.property.name === 'getEnforcing') + ) + ) { return false; } diff --git a/packages/react-native-compatibility-check/src/ComparisonResult.js b/packages/react-native-compatibility-check/src/ComparisonResult.js index 967206eaef61..40b4d9343953 100644 --- a/packages/react-native-compatibility-check/src/ComparisonResult.js +++ b/packages/react-native-compatibility-check/src/ComparisonResult.js @@ -120,7 +120,8 @@ export type UnionMembersComparisonResult = { }>, }; export type MembersComparisonResult = - EnumMembersComparisonResult | UnionMembersComparisonResult; + | EnumMembersComparisonResult + | UnionMembersComparisonResult; export type NullableComparisonResult = { /* Four possible cases of change: void goes to T? :: typeRefined !optionsReduced diff --git a/packages/react-native-compatibility-check/src/DiffResults.js b/packages/react-native-compatibility-check/src/DiffResults.js index 1257c6b0c5b0..22f75d196cdf 100644 --- a/packages/react-native-compatibility-check/src/DiffResults.js +++ b/packages/react-native-compatibility-check/src/DiffResults.js @@ -87,7 +87,9 @@ type ExportableSchemaDiffers = { }; export type SchemaDiffCategory = 'new' | 'deprecated' | SchemaDiffers; type ExportableSchemaDiffCategory = - 'new' | 'deprecated' | ExportableSchemaDiffers; + | 'new' + | 'deprecated' + | ExportableSchemaDiffers; export type SchemaDiff = { name: string, framework: Framework, diff --git a/packages/react-native/Libraries/Alert/Alert.js b/packages/react-native/Libraries/Alert/Alert.js index 4130ec511438..27108b7f14d3 100644 --- a/packages/react-native/Libraries/Alert/Alert.js +++ b/packages/react-native/Libraries/Alert/Alert.js @@ -17,7 +17,10 @@ import {alertWithArgs} from './RCTAlertManager'; * @platform ios */ export type AlertType = - 'default' | 'plain-text' | 'secure-text' | 'login-password'; + | 'default' + | 'plain-text' + | 'secure-text' + | 'login-password'; /** * @platform ios diff --git a/packages/react-native/Libraries/Animated/AnimatedEvent.js b/packages/react-native/Libraries/Animated/AnimatedEvent.js index 866e9bdb20d7..3f1245e3fd27 100644 --- a/packages/react-native/Libraries/Animated/AnimatedEvent.js +++ b/packages/react-native/Libraries/Animated/AnimatedEvent.js @@ -21,7 +21,9 @@ import AnimatedValueXY from './nodes/AnimatedValueXY'; import invariant from 'invariant'; export type Mapping = - {[key: string]: Mapping, ...} | AnimatedValue | AnimatedValueXY; + | {[key: string]: Mapping, ...} + | AnimatedValue + | AnimatedValueXY; export type EventConfig = { listener?: ?(NativeSyntheticEvent) => unknown, useNativeDriver: boolean, diff --git a/packages/react-native/Libraries/Animated/createAnimatedComponent.js b/packages/react-native/Libraries/Animated/createAnimatedComponent.js index 922e95ec9f6c..b6b3953b429d 100644 --- a/packages/react-native/Libraries/Animated/createAnimatedComponent.js +++ b/packages/react-native/Libraries/Animated/createAnimatedComponent.js @@ -34,19 +34,20 @@ type Builtin = (...ReadonlyArray) => unknown | Date | Error | RegExp; export type WithAnimatedValue = T extends Builtin | Nullable ? T : T extends Primitive - ? | T - | AnimatedNode - | AnimatedAddition - | AnimatedSubtraction - | AnimatedDivision - | AnimatedMultiplication - | AnimatedModulo - | AnimatedDiffClamp - | AnimatedValue - | AnimatedInterpolation - | AnimatedInterpolation - | AnimatedInterpolation - | AnimatedInterpolation + ? + | T + | AnimatedNode + | AnimatedAddition + | AnimatedSubtraction + | AnimatedDivision + | AnimatedMultiplication + | AnimatedModulo + | AnimatedDiffClamp + | AnimatedValue + | AnimatedInterpolation + | AnimatedInterpolation + | AnimatedInterpolation + | AnimatedInterpolation : T extends ReadonlyArray ? ReadonlyArray> : T extends {...} diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js index b6a0d4d3b5f5..480b3217d04b 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js @@ -28,7 +28,9 @@ import invariant from 'invariant'; type ExtrapolateType = 'extend' | 'identity' | 'clamp'; export type InterpolationConfigSupportedOutputType = - number | string | NativeColorValue; + | number + | string + | NativeColorValue; export type InterpolationConfigType< OutputT extends InterpolationConfigSupportedOutputType, diff --git a/packages/react-native/Libraries/AppState/AppState.js b/packages/react-native/Libraries/AppState/AppState.js index b46ea05b82e0..c8a1506fb674 100644 --- a/packages/react-native/Libraries/AppState/AppState.js +++ b/packages/react-native/Libraries/AppState/AppState.js @@ -29,7 +29,11 @@ import NativeAppState from './NativeAppState'; * @platform ios `inactive` */ export type AppStateStatus = - 'inactive' | 'background' | 'active' | 'extension' | 'unknown'; + | 'inactive' + | 'background' + | 'active' + | 'extension' + | 'unknown'; /** * Events emitted by `AppState`. diff --git a/packages/react-native/Libraries/BatchedBridge/MessageQueue.js b/packages/react-native/Libraries/BatchedBridge/MessageQueue.js index 2d0a98f26010..137233be1dfa 100644 --- a/packages/react-native/Libraries/BatchedBridge/MessageQueue.js +++ b/packages/react-native/Libraries/BatchedBridge/MessageQueue.js @@ -130,7 +130,8 @@ class MessageQueue { } flushedQueue(): - null | [Array, Array, Array, number] { + | null + | [Array, Array, Array, number] { this.__guard(() => { this.__callReactNativeMicrotasks(); }); diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index b922c77f0626..92e5376cb5d1 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -43,7 +43,10 @@ type AccessibilityEventDefinitions = { }; type AccessibilityEventTypes = - 'click' | 'focus' | 'viewHoverEnter' | 'windowStateChange'; + | 'click' + | 'focus' + | 'viewHoverEnter' + | 'windowStateChange'; // Mapping of public event names to platform-specific event names. const EventNames: Map = diff --git a/packages/react-native/Libraries/Components/Button.js b/packages/react-native/Libraries/Components/Button.js index c1bec4b65edf..383f769a4018 100644 --- a/packages/react-native/Libraries/Components/Button.js +++ b/packages/react-native/Libraries/Components/Button.js @@ -168,7 +168,8 @@ export type ButtonProps = Readonly<{ }>; const NativeTouchable: - typeof TouchableNativeFeedback | typeof TouchableOpacity = + | typeof TouchableNativeFeedback + | typeof TouchableOpacity = Platform.OS === 'android' ? TouchableNativeFeedback : TouchableOpacity; export type ButtonInstance = React.ElementRef; diff --git a/packages/react-native/Libraries/Components/Keyboard/Keyboard.js b/packages/react-native/Libraries/Components/Keyboard/Keyboard.js index 40bfa1e121ac..da318b738124 100644 --- a/packages/react-native/Libraries/Components/Keyboard/Keyboard.js +++ b/packages/react-native/Libraries/Components/Keyboard/Keyboard.js @@ -19,7 +19,11 @@ import NativeKeyboardObserver from './NativeKeyboardObserver'; export type KeyboardEventName = keyof KeyboardEventDefinitions; export type KeyboardEventEasing = - 'easeIn' | 'easeInEaseOut' | 'easeOut' | 'linear' | 'keyboard'; + | 'easeIn' + | 'easeInEaseOut' + | 'easeOut' + | 'linear' + | 'keyboard'; export type KeyboardMetrics = Readonly<{ screenX: number, diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index c0d1a6d62ab3..97e72fcdce0e 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -142,7 +142,8 @@ type PressableBaseProps = Readonly<{ * the component is currently pressed and returns view styles. */ style?: - ViewStyleProp | ((state: PressableStateCallbackType) => ViewStyleProp), + | ViewStyleProp + | ((state: PressableStateCallbackType) => ViewStyleProp), /** * Identifier used to find this view in tests. diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 26b01e15f3eb..8f218c6e5120 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -199,7 +199,8 @@ export type DecelerationRateType = 'fast' | 'normal' | number; export type ScrollResponderType = ScrollViewImperativeMethods; export interface ScrollViewInstance - extends HostInstance, ScrollViewImperativeMethods {} + extends HostInstance, + ScrollViewImperativeMethods {} type InnerViewInstance = React.ElementRef; @@ -393,7 +394,10 @@ export type ScrollViewPropsIOS = Readonly<{ * @platform ios */ contentInsetAdjustmentBehavior?: ?( - 'automatic' | 'scrollableAxes' | 'never' | 'always' + | 'automatic' + | 'scrollableAxes' + | 'never' + | 'always' ), }>; @@ -565,11 +569,9 @@ type ScrollViewBaseProps = Readonly<{ * * @default `'none'` */ - keyboardDismissMode?: ?( - // default - // cross-platform - 'none' | 'on-drag' | 'interactive' - ), // ios only + keyboardDismissMode?: ?// default + // cross-platform + ('none' | 'on-drag' | 'interactive'), // ios only /** * Determines when the keyboard should stay visible after a tap. * diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js index 6762a52b3345..9cd18a3d2bac 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js @@ -32,7 +32,10 @@ export type ScrollViewNativeProps = Readonly<{ centerContent?: ?boolean, contentInset?: ?EdgeInsetsProp, contentInsetAdjustmentBehavior?: ?( - 'automatic' | 'scrollableAxes' | 'never' | 'always' + | 'automatic' + | 'scrollableAxes' + | 'never' + | 'always' ), contentOffset?: ?PointProp, decelerationRate?: ?('fast' | 'normal' | number), diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index eaf81c88d84b..cb3b241f8089 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -171,20 +171,36 @@ export type KeyboardTypeIOS = export type KeyboardTypeAndroid = 'visible-password'; export type KeyboardTypeOptions = - KeyboardType | KeyboardTypeIOS | KeyboardTypeAndroid; + | KeyboardType + | KeyboardTypeIOS + | KeyboardTypeAndroid; export type InputModeOptions = - 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; + | 'none' + | 'text' + | 'decimal' + | 'numeric' + | 'tel' + | 'search' + | 'email' + | 'url'; export type ReturnKeyType = 'done' | 'go' | 'next' | 'search' | 'send'; export type ReturnKeyTypeIOS = - 'default' | 'emergency-call' | 'google' | 'join' | 'route' | 'yahoo'; + | 'default' + | 'emergency-call' + | 'google' + | 'join' + | 'route' + | 'yahoo'; export type ReturnKeyTypeAndroid = 'none' | 'previous'; export type ReturnKeyTypeOptions = - ReturnKeyType | ReturnKeyTypeIOS | ReturnKeyTypeAndroid; + | ReturnKeyType + | ReturnKeyTypeIOS + | ReturnKeyTypeAndroid; export type SubmitBehavior = 'submit' | 'blurAndSubmit' | 'newline'; @@ -245,7 +261,9 @@ export type EnterKeyHintTypeIOS = 'enter'; export type EnterKeyHintType = 'done' | 'go' | 'next' | 'search' | 'send'; export type EnterKeyHintTypeOptions = - EnterKeyHintType | EnterKeyHintTypeAndroid | EnterKeyHintTypeIOS; + | EnterKeyHintType + | EnterKeyHintTypeAndroid + | EnterKeyHintTypeIOS; type PasswordRules = string; @@ -288,7 +306,8 @@ export type TextInputIOSProps = Readonly<{ * @platform ios */ dataDetectorTypes?: - ?DataDetectorTypesType | ReadonlyArray, + | ?DataDetectorTypesType + | ReadonlyArray, /** * If `true`, the keyboard disables the return key when there is no text and @@ -366,7 +385,12 @@ export type TextInputIOSProps = Readonly<{ * @platform ios */ lineBreakModeIOS?: ?( - 'wordWrapping' | 'char' | 'clip' | 'head' | 'middle' | 'tail' + | 'wordWrapping' + | 'char' + | 'clip' + | 'head' + | 'middle' + | 'tail' ), /** @@ -423,7 +447,11 @@ export type TextInputAndroidProps = Readonly<{ * - `yesExcludeDescendants` - this view is important for autofill but its children aren't */ importantForAutofill?: ?( - 'auto' | 'no' | 'noExcludeDescendants' | 'yes' | 'yesExcludeDescendants' + | 'auto' + | 'no' + | 'noExcludeDescendants' + | 'yes' + | 'yesExcludeDescendants' ), /** diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 5203cf36e873..2854f5e53cc0 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -641,54 +641,53 @@ function InternalTextInput(props: TextInputProps): React.Node { ? selectionColor : selectionHandleColor, }; - textInput = - ( - /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match up - * exactly with the props for TextInput. This will need to get fixed */ - /* $FlowFixMe[incompatible-type] the types for AndroidTextInput don't - * match up exactly with the props for TextInput. This will need to get - * fixed */ - /* $FlowFixMe[incompatible-type-arg] the types for AndroidTextInput don't - * match up exactly with the props for TextInput. This will need to get - * fixed */ - - ); + textInput = ( + /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match up + * exactly with the props for TextInput. This will need to get fixed */ + /* $FlowFixMe[incompatible-type] the types for AndroidTextInput don't + * match up exactly with the props for TextInput. This will need to get + * fixed */ + /* $FlowFixMe[incompatible-type-arg] the types for AndroidTextInput don't + * match up exactly with the props for TextInput. This will need to get + * fixed */ + + ); } return {textInput}; } diff --git a/packages/react-native/Libraries/Components/Touchable/Touchable.js b/packages/react-native/Libraries/Components/Touchable/Touchable.js index 827477f98c5b..5804082fd26b 100644 --- a/packages/react-native/Libraries/Components/Touchable/Touchable.js +++ b/packages/react-native/Libraries/Components/Touchable/Touchable.js @@ -694,7 +694,7 @@ const TouchableMixinImpl = { curState + '` for Touchable responder `' + typeof this.state.touchable.responderID === - 'number' + 'number' ? this.state.touchable.responderID : 'host component' + '`', ); @@ -707,7 +707,7 @@ const TouchableMixinImpl = { signal + '` for responder `' + typeof this.state.touchable.responderID === - 'number' + 'number' ? this.state.touchable.responderID : '<>' + '`', ); diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js index e975a0bd3858..73f140fa79d9 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js @@ -84,7 +84,8 @@ export type TouchableNativeFeedbackProps = Readonly<{ | Readonly<{ type: 'ThemeAttrAndroid', attribute: - 'selectableItemBackground' | 'selectableItemBackgroundBorderless', + | 'selectableItemBackground' + | 'selectableItemBackgroundBorderless', rippleRadius: ?number, }> | Readonly<{ diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js index 3e9b92ad06b1..b82a035874ec 100755 --- a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -74,7 +74,10 @@ export type TouchableWithoutFeedbackProps = Readonly< */ id?: string, importantForAccessibility?: ?( - 'auto' | 'yes' | 'no' | 'no-hide-descendants' + | 'auto' + | 'yes' + | 'no' + | 'no-hide-descendants' ), nativeID?: ?string, onAccessibilityAction?: ?(event: AccessibilityActionEvent) => unknown, diff --git a/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js b/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js index ed018ab6ad63..32d452656eb7 100644 --- a/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js +++ b/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js @@ -62,7 +62,8 @@ export default class NativeEventEmitter< TEventToArgsMap extends Readonly< Record>, > = Readonly>>, -> implements IEventEmitter { +> implements IEventEmitter +{ _nativeModule: ?NativeModule; /** diff --git a/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js b/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js index c6584d87446c..9670253ebb44 100644 --- a/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js +++ b/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js @@ -20,7 +20,8 @@ import RCTDeviceEventEmitter from '../RCTDeviceEventEmitter'; */ export default class NativeEventEmitter< TEventToArgsMap extends Readonly>>, -> implements IEventEmitter { +> implements IEventEmitter +{ addListener( eventType: TEvent, listener: (...args: TEventToArgsMap[TEvent]) => unknown, diff --git a/packages/react-native/Libraries/Image/ImageSource.js b/packages/react-native/Libraries/Image/ImageSource.js index 48c08d25ab0b..de099b77c4b3 100644 --- a/packages/react-native/Libraries/Image/ImageSource.js +++ b/packages/react-native/Libraries/Image/ImageSource.js @@ -85,7 +85,9 @@ export interface ImageURISource { export type ImageRequireSource = number; export type ImageSource = - ImageRequireSource | ImageURISource | ReadonlyArray; + | ImageRequireSource + | ImageURISource + | ReadonlyArray; type ImageSourceProperties = { body?: ?string, diff --git a/packages/react-native/Libraries/Modal/Modal.js b/packages/react-native/Libraries/Modal/Modal.js index 5f3c943e9776..e9201f02ff3f 100644 --- a/packages/react-native/Libraries/Modal/Modal.js +++ b/packages/react-native/Libraries/Modal/Modal.js @@ -117,7 +117,10 @@ export type ModalPropsIOS = { * @platform ios */ presentationStyle?: ?( - 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen' + | 'fullScreen' + | 'pageSheet' + | 'formSheet' + | 'overFullScreen' ), /** diff --git a/packages/react-native/Libraries/Network/XMLHttpRequest.js b/packages/react-native/Libraries/Network/XMLHttpRequest.js index 7edb4c2a24c5..b125c9e9e7fa 100644 --- a/packages/react-native/Libraries/Network/XMLHttpRequest.js +++ b/packages/react-native/Libraries/Network/XMLHttpRequest.js @@ -34,7 +34,12 @@ const DEBUG_NETWORK_SEND_DELAY: false = false; // Set to a number of millisecond export type NativeResponseType = 'base64' | 'blob' | 'text'; export type ResponseType = - '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; + | '' + | 'arraybuffer' + | 'blob' + | 'document' + | 'json' + | 'text'; export type Response = ?Object | string; type XHRInterceptor = interface { @@ -653,11 +658,13 @@ class XMLHttpRequest extends EventTarget { } // only call onreadystatechange if there is something to abort, // below logic is per spec - if (!( - this.readyState === this.UNSENT || - (this.readyState === this.OPENED && !this._sent) || - this.readyState === this.DONE - )) { + if ( + !( + this.readyState === this.UNSENT || + (this.readyState === this.OPENED && !this._sent) || + this.readyState === this.DONE + ) + ) { this._reset(); this.setReadyState(this.DONE); } diff --git a/packages/react-native/Libraries/Pressability/HoverState.js b/packages/react-native/Libraries/Pressability/HoverState.js index 7f971a29e872..cfa3f2fd59a8 100644 --- a/packages/react-native/Libraries/Pressability/HoverState.js +++ b/packages/react-native/Libraries/Pressability/HoverState.js @@ -19,9 +19,9 @@ let isEnabled = false; if (Platform.OS === 'web') { const canUseDOM = Boolean( typeof window !== 'undefined' && - window.document && - // $FlowFixMe[method-unbinding] - window.document.createElement, + window.document && + // $FlowFixMe[method-unbinding] + window.document.createElement, ); if (canUseDOM) { diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index c04cfe1c4b20..dec894ab9129 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -540,7 +540,12 @@ type ____LayoutStyle_Internal = Readonly<{ * for more details. */ alignSelf?: - 'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline', + | 'auto' + | 'flex-start' + | 'flex-end' + | 'center' + | 'stretch' + | 'baseline', /** `alignContent` controls how rows align in the cross direction, * overriding the `alignContent` of the parent. @@ -731,7 +736,10 @@ type LinearGradientValue = { }; type RadialExtent = - 'closest-corner' | 'closest-side' | 'farthest-corner' | 'farthest-side'; + | 'closest-corner' + | 'closest-side' + | 'farthest-corner' + | 'farthest-side'; export type RadialGradientPosition = | { top: number | string, @@ -777,7 +785,10 @@ export type BackgroundSizeValue = { }; export type BackgroundRepeatKeyword = - 'repeat' | 'space' | 'round' | 'no-repeat'; + | 'repeat' + | 'space' + | 'round' + | 'no-repeat'; export type BackgroundPositionValue = | { @@ -895,7 +906,8 @@ export type ____ViewStyle_InternalBase = Readonly<{ experimental_backgroundImage?: ReadonlyArray | string, experimental_backgroundSize?: ReadonlyArray | string, experimental_backgroundPosition?: - ReadonlyArray | string, + | ReadonlyArray + | string, experimental_backgroundRepeat?: ReadonlyArray | string, isolation?: 'auto' | 'isolate', }>; @@ -1020,11 +1032,20 @@ type ____TextStyle_InternalBase = Readonly<{ letterSpacing?: number, lineHeight?: number, textAlign?: - 'auto' | 'left' | 'right' | 'center' | 'justify' | 'start' | 'end', + | 'auto' + | 'left' + | 'right' + | 'center' + | 'justify' + | 'start' + | 'end', textAlignVertical?: 'auto' | 'top' | 'bottom' | 'center', includeFontPadding?: boolean, textDecorationLine?: - 'none' | 'underline' | 'line-through' | 'underline line-through', + | 'none' + | 'underline' + | 'line-through' + | 'underline line-through', textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy', textDecorationColor?: ____ColorValue_Internal, textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase', @@ -1072,7 +1093,12 @@ export type ____DangerouslyImpreciseStyle_Internal = Readonly<{ }>; export type StyleProp = - null | void | T | false | '' | ReadonlyArray>; + | null + | void + | T + | false + | '' + | ReadonlyArray>; export type ____DangerouslyImpreciseStyleProp_Internal = StyleProp< Partial<____DangerouslyImpreciseStyle_Internal>, diff --git a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js index 0c2abd7faa0a..b46421463c3a 100644 --- a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js +++ b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js @@ -74,7 +74,8 @@ export type ____TransformStyle_Internal = Readonly<{ // TODO: what is the actual type it expects? | { readonly matrix: - ReadonlyArray | AnimatedNode, + | ReadonlyArray + | AnimatedNode, }, >, >, @@ -93,5 +94,6 @@ export type ____TransformStyle_Internal = Readonly<{ * E.g. transformOrigin: '30% 80% 15px' */ transformOrigin?: - [string | number, string | number, string | number] | string, + | [string | number, string | number, string | number] + | string, }>; diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index f733c96c9645..36f67b2217c2 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -39,7 +39,8 @@ const LINEAR_GRADIENT_DEFAULT_DIRECTION: LinearGradientDirection = { }; type LinearGradientDirection = - {type: 'angle', value: number} | {type: 'keyword', value: string}; + | {type: 'angle', value: number} + | {type: 'keyword', value: string}; type LinearGradientBackgroundImage = { type: 'linear-gradient', @@ -76,7 +77,8 @@ type ColorStopColor = ProcessedColorValue | null; type ColorStopPosition = number | string | null; type ParsedBackgroundImageValue = - LinearGradientBackgroundImage | RadialGradientBackgroundImage; + | LinearGradientBackgroundImage + | RadialGradientBackgroundImage; export default function processBackgroundImage( backgroundImage: ?(ReadonlyArray | string), diff --git a/packages/react-native/Libraries/Utilities/PlatformTypes.js b/packages/react-native/Libraries/Utilities/PlatformTypes.js index 24c9aac73023..5a65087c0794 100644 --- a/packages/react-native/Libraries/Utilities/PlatformTypes.js +++ b/packages/react-native/Libraries/Utilities/PlatformTypes.js @@ -9,7 +9,12 @@ */ export type PlatformOSType = - 'ios' | 'android' | 'macos' | 'windows' | 'web' | 'native'; + | 'ios' + | 'android' + | 'macos' + | 'windows' + | 'web' + | 'native'; type OptionalPlatformSelectSpec = { [key in PlatformOSType]?: T, // eslint-disable-line no-unused-vars @@ -254,4 +259,8 @@ type WebPlatform = { }; export type PlatformType = - IOSPlatform | AndroidPlatform | WindowsPlatform | MacOSPlatform | WebPlatform; + | IOSPlatform + | AndroidPlatform + | WindowsPlatform + | MacOSPlatform + | WebPlatform; diff --git a/packages/react-native/Libraries/Utilities/useWindowDimensions.js b/packages/react-native/Libraries/Utilities/useWindowDimensions.js index 48c689116154..1b04aeb0ad4c 100644 --- a/packages/react-native/Libraries/Utilities/useWindowDimensions.js +++ b/packages/react-native/Libraries/Utilities/useWindowDimensions.js @@ -22,7 +22,8 @@ import {useEffect, useState} from 'react'; * @see https://reactnative.dev/docs/usewindowdimensions */ export default function useWindowDimensions(): - DisplayMetrics | DisplayMetricsAndroid { + | DisplayMetrics + | DisplayMetricsAndroid { const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); useEffect(() => { function handleChange({ diff --git a/packages/react-native/flow/bom.js.flow b/packages/react-native/flow/bom.js.flow index a7045edfb507..64fd2d2b1a64 100644 --- a/packages/react-native/flow/bom.js.flow +++ b/packages/react-native/flow/bom.js.flow @@ -81,6 +81,7 @@ declare var console: { // Stack tagging createTask(label: string): ConsoleTask, + ... }; @@ -514,7 +515,9 @@ declare class XMLHttpRequestEventTarget extends EventTarget { // this part of spec is not finished yet, apparently // https://stackoverflow.com/questions/35296664/can-fetch-get-object-as-headers type HeadersInit = - Headers | Array<[string, string]> | {[key: string]: string, ...}; + | Headers + | Array<[string, string]> + | {[key: string]: string, ...}; // TODO Heades and URLSearchParams are almost the same thing. // Could it somehow be abstracted away? @@ -596,7 +599,12 @@ type ReferrerPolicyType = | 'unsafe-url'; type ResponseType = - 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; + | 'basic' + | 'cors' + | 'default' + | 'error' + | 'opaque' + | 'opaqueredirect'; type BodyInit = | string diff --git a/packages/react-native/flow/cssom.js.flow b/packages/react-native/flow/cssom.js.flow index 6633fbecf443..50bd5c15deac 100644 --- a/packages/react-native/flow/cssom.js.flow +++ b/packages/react-native/flow/cssom.js.flow @@ -409,7 +409,10 @@ type AnimationPlayState = 'idle' | 'running' | 'paused' | 'finished'; type AnimationReplaceState = 'active' | 'removed' | 'persisted'; type FillMode = 'none' | 'forwards' | 'backwards' | 'both' | 'auto'; type PlaybackDirection = - 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'; + | 'normal' + | 'reverse' + | 'alternate' + | 'alternate-reverse'; type IterationCompositeOperation = 'replace' | 'accumulate'; type CompositeOperation = 'replace' | 'add' | 'accumulate'; type CompositeOperationOrAuto = CompositeOperation | 'auto'; @@ -478,7 +481,12 @@ type PropertyIndexedKeyframes = { easing?: string | string[], offset?: number | (number | null)[], [property: string]: - string | string[] | number | null | (number | null)[] | void, + | string + | string[] + | number + | null + | (number | null)[] + | void, ... }; diff --git a/packages/react-native/flow/dom.js.flow b/packages/react-native/flow/dom.js.flow index 038a6f01493b..55e96b385ebd 100644 --- a/packages/react-native/flow/dom.js.flow +++ b/packages/react-native/flow/dom.js.flow @@ -172,52 +172,68 @@ type EventHandler = (event: Event) => unknown; type EventListener = {handleEvent: EventHandler, ...} | EventHandler; type MouseEventHandler = (event: MouseEvent) => unknown; type MouseEventListener = - {handleEvent: MouseEventHandler, ...} | MouseEventHandler; + | {handleEvent: MouseEventHandler, ...} + | MouseEventHandler; type FocusEventHandler = (event: FocusEvent) => unknown; type FocusEventListener = - {handleEvent: FocusEventHandler, ...} | FocusEventHandler; + | {handleEvent: FocusEventHandler, ...} + | FocusEventHandler; type KeyboardEventHandler = (event: KeyboardEvent) => unknown; type KeyboardEventListener = - {handleEvent: KeyboardEventHandler, ...} | KeyboardEventHandler; + | {handleEvent: KeyboardEventHandler, ...} + | KeyboardEventHandler; type InputEventHandler = (event: InputEvent) => unknown; type InputEventListener = - {handleEvent: InputEventHandler, ...} | InputEventHandler; + | {handleEvent: InputEventHandler, ...} + | InputEventHandler; type TouchEventHandler = (event: TouchEvent) => unknown; type TouchEventListener = - {handleEvent: TouchEventHandler, ...} | TouchEventHandler; + | {handleEvent: TouchEventHandler, ...} + | TouchEventHandler; type WheelEventHandler = (event: WheelEvent) => unknown; type WheelEventListener = - {handleEvent: WheelEventHandler, ...} | WheelEventHandler; + | {handleEvent: WheelEventHandler, ...} + | WheelEventHandler; type AbortProgressEventHandler = (event: ProgressEvent) => unknown; type AbortProgressEventListener = - {handleEvent: AbortProgressEventHandler, ...} | AbortProgressEventHandler; + | {handleEvent: AbortProgressEventHandler, ...} + | AbortProgressEventHandler; type ProgressEventHandler = (event: ProgressEvent) => unknown; type ProgressEventListener = - {handleEvent: ProgressEventHandler, ...} | ProgressEventHandler; + | {handleEvent: ProgressEventHandler, ...} + | ProgressEventHandler; type DragEventHandler = (event: DragEvent) => unknown; type DragEventListener = - {handleEvent: DragEventHandler, ...} | DragEventHandler; + | {handleEvent: DragEventHandler, ...} + | DragEventHandler; type PointerEventHandler = (event: PointerEvent) => unknown; type PointerEventListener = - {handleEvent: PointerEventHandler, ...} | PointerEventHandler; + | {handleEvent: PointerEventHandler, ...} + | PointerEventHandler; type AnimationEventHandler = (event: AnimationEvent) => unknown; type AnimationEventListener = - {handleEvent: AnimationEventHandler, ...} | AnimationEventHandler; + | {handleEvent: AnimationEventHandler, ...} + | AnimationEventHandler; type ClipboardEventHandler = (event: ClipboardEvent) => unknown; type ClipboardEventListener = - {handleEvent: ClipboardEventHandler, ...} | ClipboardEventHandler; + | {handleEvent: ClipboardEventHandler, ...} + | ClipboardEventHandler; type TransitionEventHandler = (event: TransitionEvent) => unknown; type TransitionEventListener = - {handleEvent: TransitionEventHandler, ...} | TransitionEventHandler; + | {handleEvent: TransitionEventHandler, ...} + | TransitionEventHandler; type MessageEventHandler = (event: MessageEvent) => unknown; type MessageEventListener = - {handleEvent: MessageEventHandler, ...} | MessageEventHandler; + | {handleEvent: MessageEventHandler, ...} + | MessageEventHandler; type BeforeUnloadEventHandler = (event: BeforeUnloadEvent) => unknown; type BeforeUnloadEventListener = - {handleEvent: BeforeUnloadEventHandler, ...} | BeforeUnloadEventHandler; + | {handleEvent: BeforeUnloadEventHandler, ...} + | BeforeUnloadEventHandler; type StorageEventHandler = (event: StorageEvent) => unknown; type StorageEventListener = - {handleEvent: StorageEventHandler, ...} | StorageEventHandler; + | {handleEvent: StorageEventHandler, ...} + | StorageEventHandler; type SecurityPolicyViolationEventHandler = ( event: SecurityPolicyViolationEvent, ) => unknown; @@ -226,7 +242,8 @@ type SecurityPolicyViolationEventListener = | SecurityPolicyViolationEventHandler; type USBConnectionEventHandler = (event: USBConnectionEvent) => unknown; type USBConnectionEventListener = - {handleEvent: USBConnectionEventHandler, ...} | USBConnectionEventHandler; + | {handleEvent: USBConnectionEventHandler, ...} + | USBConnectionEventHandler; type MediaKeySessionType = 'temporary' | 'persistent-license'; type MediaKeyStatus = @@ -255,7 +272,13 @@ type TouchEventTypes = 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel'; type WheelEventTypes = 'wheel'; type AbortProgressEventTypes = 'abort'; type ProgressEventTypes = - 'abort' | 'error' | 'load' | 'loadend' | 'loadstart' | 'progress' | 'timeout'; + | 'abort' + | 'error' + | 'load' + | 'loadend' + | 'loadstart' + | 'progress' + | 'timeout'; type DragEventTypes = | 'drag' | 'dragend' @@ -277,10 +300,15 @@ type PointerEventTypes = | 'gotpointercapture' | 'lostpointercapture'; type AnimationEventTypes = - 'animationstart' | 'animationend' | 'animationiteration'; + | 'animationstart' + | 'animationend' + | 'animationiteration'; type ClipboardEventTypes = 'clipboardchange' | 'cut' | 'copy' | 'paste'; type TransitionEventTypes = - 'transitionrun' | 'transitionstart' | 'transitionend' | 'transitioncancel'; + | 'transitionrun' + | 'transitionstart' + | 'transitionend' + | 'transitioncancel'; type MessageEventTypes = string; type BeforeUnloadEventTypes = 'beforeunload'; type StorageEventTypes = 'storage'; @@ -6023,7 +6051,8 @@ type NodeFilterCallback = ( | typeof NodeFilter.FILTER_SKIP; type NodeFilterInterface = - NodeFilterCallback | {acceptNode: NodeFilterCallback, ...}; + | NodeFilterCallback + | {acceptNode: NodeFilterCallback, ...}; // TODO: window.NodeFilter exists at runtime and behaves as a constructor // as far as `instanceof` is concerned, but it is not callable. diff --git a/packages/react-native/scripts/compose-source-maps.js b/packages/react-native/scripts/compose-source-maps.js index 42a67b762c32..dd1c39999b1f 100755 --- a/packages/react-native/scripts/compose-source-maps.js +++ b/packages/react-native/scripts/compose-source-maps.js @@ -16,7 +16,7 @@ const {composeSourceMaps} = require('metro-source-map'); const argv = process.argv.slice(2); let outputPath; -for (let i = 0; i < argv.length;) { +for (let i = 0; i < argv.length; ) { if (argv[i] === '-o') { outputPath = argv[i + 1]; argv.splice(i, 2); diff --git a/packages/react-native/scripts/featureflags/types.js b/packages/react-native/scripts/featureflags/types.js index acec7193a8ae..086d3f9bbe05 100644 --- a/packages/react-native/scripts/featureflags/types.js +++ b/packages/react-native/scripts/featureflags/types.js @@ -23,7 +23,10 @@ export type FeatureFlagDefinitions = Readonly<{ * - stable: the value of the feature flag will be `expectedReleaseValue` on all releases. */ export type OSSReleaseStageValue = - 'none' | 'experimental' | 'canary' | 'stable'; + | 'none' + | 'experimental' + | 'canary' + | 'stable'; export type CommonFeatureFlagConfig< TValue extends FeatureFlagValue = FeatureFlagValue, diff --git a/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js b/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js index 76b13411730f..52817be7732e 100644 --- a/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js +++ b/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js @@ -25,7 +25,7 @@ export interface Spec extends TurboModule { }; readonly findPseudoElementShadowNodeByTag: ( reactTag: number, - ) => ?unknown; /* Node */ + ) => ?unknown /* Node */; readonly waitForTransitionAnimation: (animationId: number) => void; readonly transitionAnimationFinished: (animationId: number) => void; } diff --git a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js index 5253243ef670..12a34399221c 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js +++ b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js @@ -19,7 +19,9 @@ export opaque type NativeElementReference = ShadowNode; export opaque type NativeTextReference = ShadowNode; export type NativeNodeReference = - NativeElementReference | NativeTextReference | RootTag; + | NativeElementReference + | NativeTextReference + | RootTag; export type MeasureInWindowOnSuccessCallback = ( x: number, @@ -56,16 +58,16 @@ export interface Spec extends TurboModule { readonly getChildNodes: ( nativeNodeReference: unknown /* NativeNodeReference */, - ) => ReadonlyArray; /* $ReadOnlyArray */ + ) => ReadonlyArray /* $ReadOnlyArray */; readonly getElementById?: ( nativeNodeReference: unknown /* NativeNodeReference */, id: string, - ) => unknown; /* ?InstanceHandle */ + ) => unknown /* ?InstanceHandle */; readonly getParentNode: ( nativeNodeReference: unknown /* NativeNodeReference */, - ) => unknown; /* ?InstanceHandle */ + ) => unknown /* ?InstanceHandle */; readonly isConnected: ( nativeNodeReference: unknown /* NativeNodeReference */, @@ -77,24 +79,24 @@ export interface Spec extends TurboModule { readonly getBorderWidth: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray; /* [topWidth: number, rightWidth: number, bottomWidth: number, leftWidth: number] */ + ) => ReadonlyArray /* [topWidth: number, rightWidth: number, bottomWidth: number, leftWidth: number] */; readonly getBoundingClientRect: ( nativeElementReference: unknown /* NativeElementReference */, includeTransform: boolean, - ) => ReadonlyArray; /* [x: number, y: number, width: number, height: number] */ + ) => ReadonlyArray /* [x: number, y: number, width: number, height: number] */; readonly getInnerSize: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray; /* [width: number, height: number] */ + ) => ReadonlyArray /* [width: number, height: number] */; readonly getScrollPosition: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray; /* [scrollLeft: number, scrollTop: number] */ + ) => ReadonlyArray /* [scrollLeft: number, scrollTop: number] */; readonly getScrollSize: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray; /* [scrollWidth: number, scrollHeight: number] */ + ) => ReadonlyArray /* [scrollWidth: number, scrollHeight: number] */; readonly getTagName: ( nativeElementReference: unknown /* NativeElementReference */, @@ -125,7 +127,7 @@ export interface Spec extends TurboModule { readonly getOffset: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray; /* [offsetParent: ?InstanceHandle, top: number, left: number] */ + ) => ReadonlyArray /* [offsetParent: ?InstanceHandle, top: number, left: number] */; /* * Special methods to handle the root node. @@ -134,7 +136,7 @@ export interface Spec extends TurboModule { readonly linkRootNode?: ( rootTag: number /* RootTag */, instanceHandle: unknown /* InstanceHandle */, - ) => unknown; /* ?NativeElementReference */ + ) => unknown /* ?NativeElementReference */; /** * Legacy layout APIs (for `ReactNativeElement`). diff --git a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js index ac6eda8c618a..7440f670a96a 100644 --- a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js +++ b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js @@ -38,12 +38,14 @@ export function expectRectEquals( expected: {x: number, y: number, width: number, height: number}, ): boolean { const {x, y, width, height} = expected; - if (!( - rect.x === x && - rect.y === y && - rect.width === width && - rect.height === height - )) { + if ( + !( + rect.x === x && + rect.y === y && + rect.width === width && + rect.height === height + ) + ) { const received = { x: rect.x, y: rect.y, diff --git a/packages/react-native/src/private/webapis/performance/PerformanceEntry.js b/packages/react-native/src/private/webapis/performance/PerformanceEntry.js index aef916f4e7f1..9e4fa32fe455 100644 --- a/packages/react-native/src/private/webapis/performance/PerformanceEntry.js +++ b/packages/react-native/src/private/webapis/performance/PerformanceEntry.js @@ -14,7 +14,11 @@ import {setPlatformObject} from '../webidl/PlatformObjects'; export type DOMHighResTimeStamp = number; export type PerformanceEntryType = - 'mark' | 'measure' | 'event' | 'longtask' | 'resource'; + | 'mark' + | 'measure' + | 'event' + | 'longtask' + | 'resource'; export type PerformanceEntryJSON = { name: string, diff --git a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js index 45af579fcd7f..de5ee16441f4 100644 --- a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js +++ b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js @@ -25,7 +25,8 @@ export type FailingPlatformTestAssertionResult = Readonly<{ }>; export type PlatformTestAssertionResult = - PassingPlatformTestAssertionResult | FailingPlatformTestAssertionResult; + | PassingPlatformTestAssertionResult + | FailingPlatformTestAssertionResult; export type PlatformTestResultStatus = 'PASS' | 'FAIL' | 'ERROR' | 'SKIPPED'; diff --git a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js index 9bf56b7f6a16..4ee3377ff31f 100644 --- a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js +++ b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js @@ -49,7 +49,8 @@ const TextInputForm = () => { const CloseButton = ( props: - {behavior: any, setModalOpen: any} | {behavior: string, setModalOpen: any}, + | {behavior: any, setModalOpen: any} + | {behavior: string, setModalOpen: any}, ) => { return ( = first && - first >= 0 && - last < itemCount && - first >= overscanFirst && - last <= overscanLast && - first <= visible.first && - last >= visible.last - )) { + if ( + !( + last >= first && + first >= 0 && + last < itemCount && + first >= overscanFirst && + last <= overscanLast && + first <= visible.first && + last >= visible.last + ) + ) { throw new Error( 'Bad window calculation ' + JSON.stringify({ diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index a8593403e4cd..298331a0b839 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -1973,9 +1973,9 @@ class VirtualizedList extends StateSafePureComponent< // Keep a viewport's worth of content around the last focused cell to allow // random navigation around it without any blanking. E.g. tabbing from one // focused item out of viewport to another. - if (!( - this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey] - )) { + if ( + !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey]) + ) { return []; } diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js index b9551b655421..55a63f8866ff 100644 --- a/packages/virtualized-lists/Lists/VirtualizedSectionList.js +++ b/packages/virtualized-lists/Lists/VirtualizedSectionList.js @@ -131,8 +131,10 @@ type State = {childProps: VirtualizedListProps, ...}; */ class VirtualizedSectionList< ItemT, - SectionT extends SectionBase = + SectionT extends SectionBase< + ItemT, DefaultVirtualizedSectionT, + > = DefaultVirtualizedSectionT, > extends React.PureComponent< VirtualizedSectionListProps, State, @@ -641,8 +643,10 @@ function ItemWithSeparator( const VirtualizedSectionListComponent = VirtualizedSectionList as component< ItemT, - SectionT extends SectionBase = + SectionT extends SectionBase< + ItemT, DefaultVirtualizedSectionT, + > = DefaultVirtualizedSectionT, >( ref?: React.RefSetter< interface { diff --git a/private/react-native-fantom/src/Benchmark.js b/private/react-native-fantom/src/Benchmark.js index f564d7f8e8ec..37a8b297ff73 100644 --- a/private/react-native-fantom/src/Benchmark.js +++ b/private/react-native-fantom/src/Benchmark.js @@ -61,7 +61,8 @@ type SuiteResults = Array>; type TestWithArgName = string | ((testArg: TestArgType) => string); type TestWithArgOptions = - FnOptions | ((testArg: TestArgType) => FnOptions); + | FnOptions + | ((testArg: TestArgType) => FnOptions); interface ParameterizedTestFunction { ( From a016e94b590b625577509c6d5919e2d144f69e0e Mon Sep 17 00:00:00 2001 From: Sam Zhou Date: Mon, 13 Jul 2026 17:48:27 -0700 Subject: [PATCH 049/834] Redo D111190630: Stop using `prettier-plugin-hermes-parser` for formatting (#57537) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57537 Changelog: [Internal] Reviewed By: gkz Differential Revision: D111829105 fbshipit-source-id: 3309a22e737f68aca2d8d19f49e56bad955e5377 --- .prettierrc.js | 8 +- flow-typed/environment/node.js | 48 +++------- flow-typed/npm/babel-traverse_v7.x.x.js | 18 +--- flow-typed/npm/babel_v7.x.x.js | 3 +- flow-typed/npm/commander_v12.x.x.js | 8 +- flow-typed/npm/electron-packager_v20.x.x.js | 11 +-- flow-typed/npm/execa_v5.x.x.js | 10 +- flow-typed/npm/jest.js | 5 +- flow-typed/npm/jsonc-parser_v2.2.x.js | 8 +- flow-typed/npm/node-fetch_v2.x.x.js | 14 +-- flow-typed/npm/pretty-format_v29.x.x.js | 4 +- flow-typed/npm/rxjs_v6.x.x.js | 26 +++-- flow-typed/npm/shelljs_v0.x.x.js | 9 +- flow-typed/npm/tinybench_v4.1.x.js | 4 +- .../src/inspector-proxy/CdpDebugLogging.js | 5 +- .../src/inspector-proxy/types.js | 9 +- .../platform-colors.js | 14 ++- .../react-native-modules.js | 22 ++--- packages/jest-preset/jest-preset.js | 5 +- packages/metro-config/src/index.flow.js | 10 +- .../react-native-codegen/src/CodegenSchema.js | 15 +-- .../src/parsers/flow/components/commands.js | 14 ++- .../parsers/typescript/components/commands.js | 16 ++-- .../typescript/components/componentsUtils.js | 6 +- .../react-native-codegen/src/parsers/utils.js | 22 ++--- .../src/ComparisonResult.js | 3 +- .../src/DiffResults.js | 4 +- .../react-native/Libraries/Alert/Alert.js | 5 +- .../Libraries/Animated/AnimatedEvent.js | 4 +- .../Animated/createAnimatedComponent.js | 27 +++--- .../Animated/nodes/AnimatedInterpolation.js | 4 +- .../Libraries/AppState/AppState.js | 6 +- .../Libraries/BatchedBridge/MessageQueue.js | 3 +- .../AccessibilityInfo/AccessibilityInfo.js | 5 +- .../Libraries/Components/Button.js | 3 +- .../Libraries/Components/Keyboard/Keyboard.js | 6 +- .../Components/Pressable/Pressable.js | 3 +- .../Components/ScrollView/ScrollView.js | 16 ++-- .../ScrollViewNativeComponentType.js | 5 +- .../Components/TextInput/TextInput.flow.js | 44 ++------- .../Components/TextInput/TextInput.js | 95 ++++++++++--------- .../Components/Touchable/Touchable.js | 4 +- .../Touchable/TouchableNativeFeedback.js | 3 +- .../Touchable/TouchableWithoutFeedback.js | 5 +- .../EventEmitter/NativeEventEmitter.js | 3 +- .../__mocks__/NativeEventEmitter.js | 3 +- .../Libraries/Image/ImageSource.js | 4 +- .../react-native/Libraries/Modal/Modal.js | 5 +- .../Libraries/Network/XMLHttpRequest.js | 19 ++-- .../Libraries/Pressability/HoverState.js | 6 +- .../Libraries/StyleSheet/StyleSheetTypes.js | 40 ++------ .../StyleSheet/private/_TransformStyle.js | 6 +- .../StyleSheet/processBackgroundImage.js | 6 +- .../Libraries/Utilities/PlatformTypes.js | 13 +-- .../Utilities/useWindowDimensions.js | 3 +- packages/react-native/flow/bom.js.flow | 12 +-- packages/react-native/flow/cssom.js.flow | 12 +-- packages/react-native/flow/dom.js.flow | 71 ++++---------- .../scripts/compose-source-maps.js | 2 +- .../scripts/featureflags/types.js | 5 +- .../specs/NativeViewTransition.js | 2 +- .../webapis/dom/nodes/specs/NativeDOM.js | 24 +++-- .../__tests__/IntersectionObserver-itest.js | 14 ++- .../webapis/performance/PerformanceEntry.js | 6 +- .../PlatformTest/RNTesterPlatformTestTypes.js | 3 +- .../KeyboardAvoidingViewExample.js | 3 +- .../Lists/VirtualizeUtils.js | 20 ++-- .../Lists/VirtualizedList.js | 6 +- .../Lists/VirtualizedSectionList.js | 8 +- private/react-native-fantom/src/Benchmark.js | 3 +- 70 files changed, 279 insertions(+), 579 deletions(-) diff --git a/.prettierrc.js b/.prettierrc.js index dca775b07037..70a05cba5f46 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -7,11 +7,6 @@ * @format */ -let plugins = ['prettier-plugin-hermes-parser']; -try { - plugins = require('./.prettier-plugins.fb.js'); -} catch {} - module.exports = { arrowParens: 'avoid', bracketSameLine: true, @@ -20,7 +15,6 @@ module.exports = { singleQuote: true, trailingComma: 'all', endOfLine: 'lf', - plugins, overrides: [ { files: ['*.code-workspace'], @@ -31,7 +25,7 @@ module.exports = { { files: ['*.js', '*.js.flow'], options: { - parser: 'hermes', + parser: 'flow', }, }, { diff --git a/flow-typed/environment/node.js b/flow-typed/environment/node.js index 1a99ec0dcbd2..c92ff006df23 100644 --- a/flow-typed/environment/node.js +++ b/flow-typed/environment/node.js @@ -457,8 +457,7 @@ declare class child_process$ChildProcessTyped< TStdin extends stream$Writable | null, TStdout extends stream$Readable | null, TStderr extends stream$Readable | null, -> extends events$EventEmitter -{ +> extends events$EventEmitter { +stdin: TStdin; +stdout: TStdout; +stderr: TStderr; @@ -578,8 +577,7 @@ declare module 'child_process' { declare function execFile( file: string, argsOrCallback?: - | ReadonlyArray - | child_process$execFileCallback, + ReadonlyArray | child_process$execFileCallback, callback?: child_process$execFileCallback, ): child_process$ChildProcessTyped< stream$Writable, @@ -928,14 +926,7 @@ type crypto$key = declare class crypto$KeyObject { +asymmetricKeyType?: - | 'rsa' - | 'rsa-pss' - | 'dsa' - | 'ec' - | 'ed25519' - | 'ed448' - | 'x25519' - | 'x448'; + 'rsa' | 'rsa-pss' | 'dsa' | 'ec' | 'ed25519' | 'ed448' | 'x25519' | 'x448'; +asymmetricKeySize?: number; +symmetricKeySize?: number; +type: 'secret' | 'public' | 'private'; @@ -2560,9 +2551,9 @@ declare class http$Agent<+SocketT = net$Socket> { sockets: {[name: string]: ReadonlyArray, ...}; } -declare class http$IncomingMessage - extends stream$Readable -{ +declare class http$IncomingMessage< + SocketT = net$Socket, +> extends stream$Readable { headers: Object; rawHeaders: Array; httpVersion: string; @@ -2578,9 +2569,9 @@ declare class http$IncomingMessage rawTrailers: Array; } -declare class http$ClientRequest<+SocketT = net$Socket> - extends stream$Writable -{ +declare class http$ClientRequest< + +SocketT = net$Socket, +> extends stream$Writable { abort(): void; aborted: boolean; +connection: SocketT | null; @@ -2878,10 +2869,7 @@ declare class net$Socket extends stream$Duplex { destroyed: boolean; end( chunkOrEncodingOrCallback?: - | Buffer - | Uint8Array - | string - | ((data: any) => void), + Buffer | Uint8Array | string | ((data: any) => void), encodingOrCallback?: string | ((data: any) => void), callback?: (data: any) => void, ): this; @@ -3211,9 +3199,9 @@ declare module 'perf_hooks' { +detail?: T; } - declare export class PerformanceMeasure - extends PerformanceEntry - { + declare export class PerformanceMeasure< + T = unknown, + > extends PerformanceEntry { +entryType: 'measure'; +detail?: T; } @@ -3398,11 +3386,7 @@ declare module 'querystring' { */ declare module 'node:sqlite' { declare export type SupportedValueType = - | null - | number - | bigint - | string - | Uint8Array; + null | number | bigint | string | Uint8Array; declare export type DatabaseSyncOptions = Readonly<{ open?: boolean, @@ -5565,9 +5549,7 @@ declare module 'repl' { writer?: (object: any, options?: util$InspectOptions) => string, completer?: readline$InterfaceCompleter, replMode?: - | $SymbolReplModeMagic - | $SymbolReplModeSloppy - | $SymbolReplModeStrict, + $SymbolReplModeMagic | $SymbolReplModeSloppy | $SymbolReplModeStrict, breakEvalOnSigint?: boolean, ... }): REPLServer; diff --git a/flow-typed/npm/babel-traverse_v7.x.x.js b/flow-typed/npm/babel-traverse_v7.x.x.js index e28bb4921fed..d3b6cd034c47 100644 --- a/flow-typed/npm/babel-traverse_v7.x.x.js +++ b/flow-typed/npm/babel-traverse_v7.x.x.js @@ -246,12 +246,7 @@ declare module '@babel/traverse' { } declare export type BindingKind = - | 'var' - | 'let' - | 'const' - | 'module' - | 'hoisted' - | 'unknown'; + 'var' | 'let' | 'const' | 'module' | 'hoisted' | 'unknown'; declare export class Binding { constructor(opts: { @@ -435,13 +430,7 @@ declare module '@babel/traverse' { isBaseType(baseName: string, soft?: boolean): boolean; couldBeBaseType( name: - | 'string' - | 'number' - | 'boolean' - | 'any' - | 'mixed' - | 'empty' - | 'void', + 'string' | 'number' | 'boolean' | 'any' | 'mixed' | 'empty' | 'void', ): boolean; baseTypeStrictlyMatches(right: NodePath<>): ?boolean; isGenericType(genericName: string): boolean; @@ -1446,8 +1435,7 @@ declare module '@babel/traverse' { }>; declare export type VisitNode<-TNode extends BabelNode, TState> = - | VisitNodeFunction - | VisitNodeObject; + VisitNodeFunction | VisitNodeObject; declare export type Visitor = Readonly<{ enter?: VisitNodeFunction, diff --git a/flow-typed/npm/babel_v7.x.x.js b/flow-typed/npm/babel_v7.x.x.js index abba5ac2cb69..f58af46605e8 100644 --- a/flow-typed/npm/babel_v7.x.x.js +++ b/flow-typed/npm/babel_v7.x.x.js @@ -1293,8 +1293,7 @@ declare module '@babel/template' { }; declare export type PublicReplacements = - | {[string]: ?BabelNode} - | Array; + {[string]: ?BabelNode} | Array; declare export type TemplateBuilder = { // Build a new builder, merging the given options with the previous ones. diff --git a/flow-typed/npm/commander_v12.x.x.js b/flow-typed/npm/commander_v12.x.x.js index 1f18ac9da056..d98bd1ed5335 100644 --- a/flow-typed/npm/commander_v12.x.x.js +++ b/flow-typed/npm/commander_v12.x.x.js @@ -15,8 +15,7 @@ declare module 'commander' { declare type LiteralUnion = - | LiteralType - | {...BaseType, ...{[key: empty]: empty, ...}}; + LiteralType | {...BaseType, ...{[key: empty]: empty, ...}}; declare export class CommanderError mixins Error { code: string; @@ -335,10 +334,7 @@ declare module 'commander' { outputError?: (str: string, write: (str: string) => void) => void; } export type AddHelpTextPosition = - | 'beforeAll' - | 'before' - | 'after' - | 'afterAll'; + 'beforeAll' | 'before' | 'after' | 'afterAll'; export type HookEvent = 'preSubcommand' | 'preAction' | 'postAction'; export type OptionValueSource = LiteralUnion< 'default' | 'config' | 'env' | 'cli' | 'implied', diff --git a/flow-typed/npm/electron-packager_v20.x.x.js b/flow-typed/npm/electron-packager_v20.x.x.js index 38c859aca693..86e3cfc25f1d 100644 --- a/flow-typed/npm/electron-packager_v20.x.x.js +++ b/flow-typed/npm/electron-packager_v20.x.x.js @@ -49,21 +49,14 @@ declare module '@electron/packager' { ProductName?: string, InternalName?: string, 'requested-execution-level'?: - | 'asInvoker' - | 'highestAvailable' - | 'requireAdministrator', + 'asInvoker' | 'highestAvailable' | 'requireAdministrator', 'application-manifest'?: string, }>; declare export type WindowsSignOptions = $FlowFixMe; declare export type OfficialArch = - | 'ia32' - | 'x64' - | 'armv7l' - | 'arm64' - | 'mips64el' - | 'universal'; + 'ia32' | 'x64' | 'armv7l' | 'arm64' | 'mips64el' | 'universal'; declare export type OfficialPlatform = 'linux' | 'win32' | 'darwin' | 'mas'; diff --git a/flow-typed/npm/execa_v5.x.x.js b/flow-typed/npm/execa_v5.x.x.js index c54a07291e94..a3a8dae8b4c3 100644 --- a/flow-typed/npm/execa_v5.x.x.js +++ b/flow-typed/npm/execa_v5.x.x.js @@ -13,12 +13,7 @@ declare module 'execa' { declare type StdIoOption = - | 'pipe' - | 'ipc' - | 'ignore' - | 'inherit' - | stream$Stream - | number; + 'pipe' | 'ipc' | 'ignore' | 'inherit' | stream$Stream | number; declare type CommonOptions = { argv0?: string, @@ -74,8 +69,7 @@ declare module 'execa' { }; declare interface ExecaPromise - extends Promise, - child_process$ChildProcess {} + extends Promise, child_process$ChildProcess {} declare interface ExecaError extends ErrnoError { stdout: string; diff --git a/flow-typed/npm/jest.js b/flow-typed/npm/jest.js index 00767deaa1c8..70597548c96c 100644 --- a/flow-typed/npm/jest.js +++ b/flow-typed/npm/jest.js @@ -214,10 +214,7 @@ type FakeTimersConfig = { */ type JestStyledComponentsMatcherValue = - | string - | JestAsymmetricEqualityType - | RegExp - | void; + string | JestAsymmetricEqualityType | RegExp | void; type JestStyledComponentsMatcherOptions = { media?: string, diff --git a/flow-typed/npm/jsonc-parser_v2.2.x.js b/flow-typed/npm/jsonc-parser_v2.2.x.js index 51eadf91b48a..c8b47b6404ef 100644 --- a/flow-typed/npm/jsonc-parser_v2.2.x.js +++ b/flow-typed/npm/jsonc-parser_v2.2.x.js @@ -155,13 +155,7 @@ declare module 'jsonc-parser' { | 'InvalidCharacter' | ''; export type NodeType = - | 'object' - | 'array' - | 'property' - | 'string' - | 'number' - | 'boolean' - | 'null'; + 'object' | 'array' | 'property' | 'string' | 'number' | 'boolean' | 'null'; export type Node = { type: NodeType, value?: any, diff --git a/flow-typed/npm/node-fetch_v2.x.x.js b/flow-typed/npm/node-fetch_v2.x.x.js index 5b6b9cc51b46..22108efa6b74 100644 --- a/flow-typed/npm/node-fetch_v2.x.x.js +++ b/flow-typed/npm/node-fetch_v2.x.x.js @@ -157,12 +157,7 @@ declare module 'node-fetch' { } declare type ResponseType = - | 'basic' - | 'cors' - | 'default' - | 'error' - | 'opaque' - | 'opaqueredirect'; + 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; declare interface ResponseInit { headers?: HeaderInit; @@ -172,12 +167,7 @@ declare module 'node-fetch' { declare type HeaderInit = Headers | Array; declare type BodyInit = - | string - | null - | Buffer - | Blob - | Readable - | URLSearchParams; + string | null | Buffer | Blob | Readable | URLSearchParams; declare function fetch( url: string | URL | Request, diff --git a/flow-typed/npm/pretty-format_v29.x.x.js b/flow-typed/npm/pretty-format_v29.x.x.js index a13951717f0f..fbcdfda1f5e2 100644 --- a/flow-typed/npm/pretty-format_v29.x.x.js +++ b/flow-typed/npm/pretty-format_v29.x.x.js @@ -37,9 +37,7 @@ declare type PrettyFormatPlugin = declare module 'pretty-format' { declare export type CompareKeys = - | ((a: string, b: string) => number) - | null - | void; + ((a: string, b: string) => number) | null | void; declare export function format( value: unknown, options?: ?{ diff --git a/flow-typed/npm/rxjs_v6.x.x.js b/flow-typed/npm/rxjs_v6.x.x.js index a4326724e3ee..dfb0557cd726 100644 --- a/flow-typed/npm/rxjs_v6.x.x.js +++ b/flow-typed/npm/rxjs_v6.x.x.js @@ -9,11 +9,14 @@ declare interface rxjs$UnaryFunction { (source: T): R; } -declare interface rxjs$OperatorFunction - extends rxjs$UnaryFunction, rxjs$Observable> {} +declare interface rxjs$OperatorFunction extends rxjs$UnaryFunction< + rxjs$Observable, + rxjs$Observable, +> {} declare type rxjs$FactoryOrValue = T | (() => T); -declare interface rxjs$MonoTypeOperatorFunction - extends rxjs$OperatorFunction {} +declare interface rxjs$MonoTypeOperatorFunction< + T, +> extends rxjs$OperatorFunction {} declare interface rxjs$Timestamp { value: T; timestamp: number; @@ -47,9 +50,7 @@ declare interface rxjs$Subscribable { ): rxjs$Unsubscribable; } declare type rxjs$ObservableInput = - | rxjs$SubscribableOrPromise - | Array - | Iterable; + rxjs$SubscribableOrPromise | Array | Iterable; declare type rxjs$InteropObservable = { [string | unknown]: () => rxjs$Subscribable, @@ -588,8 +589,7 @@ declare module 'rxjs' { ((...sources: rxjs$ObservableInput[]) => rxjs$Observable), from( input: - | rxjs$ObservableInput - | rxjs$ObservableInput>, + rxjs$ObservableInput | rxjs$ObservableInput>, scheduler?: rxjs$SchedulerLike, ): rxjs$Observable, ArgumentOutOfRangeError: ArgumentOutOfRangeError, @@ -630,9 +630,7 @@ declare module 'rxjs' { ) => rxjs$Observable) & (( ...observables: ( - | rxjs$ObservableInput - | rxjs$SchedulerLike - | number + rxjs$ObservableInput | rxjs$SchedulerLike | number )[] ) => rxjs$Observable) & (( @@ -3349,9 +3347,7 @@ declare module 'rxjs/webSocket' { _output: rxjs$Subject; constructor( urlConfigOrSource: - | string - | WebSocketSubjectConfig - | rxjs$Observable, + string | WebSocketSubjectConfig | rxjs$Observable, destination?: rxjs$Observer, ): void; lift(operator: rxjs$Operator): WebSocketSubject; diff --git a/flow-typed/npm/shelljs_v0.x.x.js b/flow-typed/npm/shelljs_v0.x.x.js index c5ffdee80618..22eb29608ca5 100644 --- a/flow-typed/npm/shelljs_v0.x.x.js +++ b/flow-typed/npm/shelljs_v0.x.x.js @@ -48,14 +48,7 @@ declare type $npm$shelljs$GrepOpts = $npm$shelljs$OptionsPoly<'-l' | '-v'>; declare type $npm$shelljs$SedOpts = $npm$shelljs$OptionsPoly<'-i'>; declare type $npm$shelljs$SortOpts = $npm$shelljs$OptionsPoly<'-n' | '-r'>; declare type $npm$shelljs$TestOpts = - | '-b' - | '-c' - | '-d' - | '-e' - | '-f' - | '-L' - | '-p' - | '-S'; + '-b' | '-c' | '-d' | '-e' | '-f' | '-L' | '-p' | '-S'; declare type $npm$shelljs$TouchOpts = { [key: '-a' | '-c' | '-m']: boolean, '-d'?: string, diff --git a/flow-typed/npm/tinybench_v4.1.x.js b/flow-typed/npm/tinybench_v4.1.x.js index 2b6bbba9bafa..821e79e0a969 100644 --- a/flow-typed/npm/tinybench_v4.1.x.js +++ b/flow-typed/npm/tinybench_v4.1.x.js @@ -101,9 +101,7 @@ declare module 'tinybench' { // but we type it this way to avoid mistakes (we can make breaking changes // in our definition that they can't). export type Fn = () => - | Promise - | void - | FnReturnedObject; + Promise | void | FnReturnedObject; declare export class Bench extends EventTarget { concurrency: null | 'task' | 'bench'; diff --git a/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js b/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js index 056c07f76f0a..b2a054a622c3 100644 --- a/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js +++ b/packages/dev-middleware/src/inspector-proxy/CdpDebugLogging.js @@ -21,10 +21,7 @@ const debugCDPMessages = require('debug')('Metro:InspectorProxyCDPMessages'); const CDP_MESSAGES_BATCH_DEBUGGING_THROTTLE_MS = 5000; export type CDPMessageDestination = - | 'DebuggerToProxy' - | 'ProxyToDebugger' - | 'DeviceToProxy' - | 'ProxyToDevice'; + 'DebuggerToProxy' | 'ProxyToDebugger' | 'DeviceToProxy' | 'ProxyToDevice'; function getCDPLogPrefix(destination: CDPMessageDestination): string { return util.format( diff --git a/packages/dev-middleware/src/inspector-proxy/types.js b/packages/dev-middleware/src/inspector-proxy/types.js index 89838f2d11d0..707d19ed3bbe 100644 --- a/packages/dev-middleware/src/inspector-proxy/types.js +++ b/packages/dev-middleware/src/inspector-proxy/types.js @@ -107,16 +107,11 @@ export type GetPagesResponse = { // Union type for all possible messages sent from device to Inspector Proxy. export type MessageFromDevice = - | GetPagesResponse - | WrappedEventFromDevice - | DisconnectRequest; + GetPagesResponse | WrappedEventFromDevice | DisconnectRequest; // Union type for all possible messages sent from Inspector Proxy to device. export type MessageToDevice = - | GetPagesRequest - | WrappedEventToDevice - | ConnectRequest - | DisconnectRequest; + GetPagesRequest | WrappedEventToDevice | ConnectRequest | DisconnectRequest; // Page description object that is sent in response to /json HTTP request from debugger. export type PageDescription = Readonly<{ diff --git a/packages/eslint-plugin-react-native/platform-colors.js b/packages/eslint-plugin-react-native/platform-colors.js index b154aabcbe53..2de452899cd6 100644 --- a/packages/eslint-plugin-react-native/platform-colors.js +++ b/packages/eslint-plugin-react-native/platform-colors.js @@ -58,14 +58,12 @@ module.exports = { } const properties = args[0].properties; properties.forEach(property => { - if ( - !( - property.type === 'Property' && - (property.value.type === 'Literal' || - (property.value.type === 'CallExpression' && - property.value.callee.name === 'PlatformColor')) - ) - ) { + if (!( + property.type === 'Property' && + (property.value.type === 'Literal' || + (property.value.type === 'CallExpression' && + property.value.callee.name === 'PlatformColor')) + )) { context.report({ node, messageId: 'dynamicColorIOSValue', diff --git a/packages/eslint-plugin-specs/react-native-modules.js b/packages/eslint-plugin-specs/react-native-modules.js index 78e6e11559ca..5983f10dc832 100644 --- a/packages/eslint-plugin-specs/react-native-modules.js +++ b/packages/eslint-plugin-specs/react-native-modules.js @@ -91,22 +91,18 @@ function isModuleRequire(node) { } const memberExpression = callExpression.callee; - if ( - !( - memberExpression.object.type === 'Identifier' && - memberExpression.object.name === 'TurboModuleRegistry' - ) - ) { + if (!( + memberExpression.object.type === 'Identifier' && + memberExpression.object.name === 'TurboModuleRegistry' + )) { return false; } - if ( - !( - memberExpression.property.type === 'Identifier' && - (memberExpression.property.name === 'get' || - memberExpression.property.name === 'getEnforcing') - ) - ) { + if (!( + memberExpression.property.type === 'Identifier' && + (memberExpression.property.name === 'get' || + memberExpression.property.name === 'getEnforcing') + )) { return false; } return true; diff --git a/packages/jest-preset/jest-preset.js b/packages/jest-preset/jest-preset.js index bfe131904a21..fc99494c37d8 100644 --- a/packages/jest-preset/jest-preset.js +++ b/packages/jest-preset/jest-preset.js @@ -28,9 +28,8 @@ module.exports = { resolver: require.resolve('./jest/resolver.js'), transform: { '^.+\\.(js|ts|tsx)$': 'babel-jest', - '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': require.resolve( - './jest/assetFileTransformer.js', - ), + '^.+\\.(bmp|gif|jpg|jpeg|mp4|png|psd|svg|webp)$': + require.resolve('./jest/assetFileTransformer.js'), }, transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)', diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index 74fc4053c2b7..2f4e44c187bc 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -85,12 +85,10 @@ export function getDefaultConfig(projectRoot: string): ConfigT { transformer: { allowOptionalDependencies: true, assetRegistryPath: 'react-native/asset-registry', - asyncRequireModulePath: require.resolve( - 'metro-runtime/src/modules/asyncRequire', - ), - babelTransformerPath: require.resolve( - '@react-native/metro-babel-transformer', - ), + asyncRequireModulePath: + require.resolve('metro-runtime/src/modules/asyncRequire'), + babelTransformerPath: + require.resolve('@react-native/metro-babel-transformer'), getTransformOptions: async () => ({ transform: { experimentalImportSupport: false, diff --git a/packages/react-native-codegen/src/CodegenSchema.js b/packages/react-native-codegen/src/CodegenSchema.js index 41b8edabf542..1c383704fd72 100644 --- a/packages/react-native-codegen/src/CodegenSchema.js +++ b/packages/react-native-codegen/src/CodegenSchema.js @@ -270,8 +270,7 @@ export type ReservedTypeAnnotation = Readonly<{ * NativeModule Types */ export type Nullable = - | NullableTypeAnnotation - | T; + NullableTypeAnnotation | T; export type NullableTypeAnnotation = Readonly<{ @@ -345,8 +344,7 @@ export type NativeModuleEnumMember = { }; export type NativeModuleEnumMemberType = - | 'NumberTypeAnnotation' - | 'StringTypeAnnotation'; + 'NumberTypeAnnotation' | 'StringTypeAnnotation'; export type NativeModuleEnumDeclaration = Readonly<{ name: string, @@ -436,12 +434,10 @@ export type NativeModuleBaseTypeAnnotation = | NativeModuleMixedTypeAnnotation; export type NativeModuleParamTypeAnnotation = - | NativeModuleBaseTypeAnnotation - | NativeModuleParamOnlyTypeAnnotation; + NativeModuleBaseTypeAnnotation | NativeModuleParamOnlyTypeAnnotation; export type NativeModuleReturnTypeAnnotation = - | NativeModuleBaseTypeAnnotation - | NativeModuleReturnOnlyTypeAnnotation; + NativeModuleBaseTypeAnnotation | NativeModuleReturnOnlyTypeAnnotation; export type NativeModuleTypeAnnotation = | NativeModuleBaseTypeAnnotation @@ -452,8 +448,7 @@ export type NativeModuleTypeAnnotation = type NativeModuleParamOnlyTypeAnnotation = NativeModuleFunctionTypeAnnotation; type NativeModuleReturnOnlyTypeAnnotation = - | NativeModulePromiseTypeAnnotation - | VoidTypeAnnotation; + NativeModulePromiseTypeAnnotation | VoidTypeAnnotation; // Add the allowed component reserved types to the native module union export type CompleteReservedTypeAnnotation = diff --git a/packages/react-native-codegen/src/parsers/flow/components/commands.js b/packages/react-native-codegen/src/parsers/flow/components/commands.js index 8800427a6157..381fef3c88dd 100644 --- a/packages/react-native-codegen/src/parsers/flow/components/commands.js +++ b/packages/react-native-codegen/src/parsers/flow/components/commands.js @@ -49,14 +49,12 @@ function buildCommandSchema( const firstParam = value.params[0].typeAnnotation; - if ( - !( - firstParam.id != null && - firstParam.id.type === 'QualifiedTypeIdentifier' && - firstParam.id.qualification.name === 'React' && - firstParam.id.id.name === 'ElementRef' - ) - ) { + if (!( + firstParam.id != null && + firstParam.id.type === 'QualifiedTypeIdentifier' && + firstParam.id.qualification.name === 'React' && + firstParam.id.id.name === 'ElementRef' + )) { throw new Error( `The first argument of method ${name} must be of type React.ElementRef<>`, ); diff --git a/packages/react-native-codegen/src/parsers/typescript/components/commands.js b/packages/react-native-codegen/src/parsers/typescript/components/commands.js index f51d7ccede32..f097b2b9314b 100644 --- a/packages/react-native-codegen/src/parsers/typescript/components/commands.js +++ b/packages/react-native-codegen/src/parsers/typescript/components/commands.js @@ -33,15 +33,13 @@ function buildCommandSchemaInternal( parser: Parser, ): NamedShape { const firstParam = parameters[0].typeAnnotation; - if ( - !( - firstParam.typeAnnotation != null && - firstParam.typeAnnotation.type === 'TSTypeReference' && - firstParam.typeAnnotation.typeName.left?.name === 'React' && - (firstParam.typeAnnotation.typeName.right?.name === 'ElementRef' || - firstParam.typeAnnotation.typeName.right?.name === 'ComponentRef') - ) - ) { + if (!( + firstParam.typeAnnotation != null && + firstParam.typeAnnotation.type === 'TSTypeReference' && + firstParam.typeAnnotation.typeName.left?.name === 'React' && + (firstParam.typeAnnotation.typeName.right?.name === 'ElementRef' || + firstParam.typeAnnotation.typeName.right?.name === 'ComponentRef') + )) { throw new Error( `The first argument of method ${name} must be of type React.ElementRef<> or React.ComponentRef<>`, ); diff --git a/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js b/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js index 23d16906883b..407327d24c17 100644 --- a/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js +++ b/packages/react-native-codegen/src/parsers/typescript/components/componentsUtils.js @@ -354,8 +354,7 @@ function setDefaultValue( /* $FlowFixMe[invalid-compare] Error discovered during Constant Condition * roll out. See https://fburl.com/workplace/5whu3i34. */ (defaultValue === null ? null : defaultValue ? defaultValue : 0) as - | number - | null; + number | null; break; case 'BooleanTypeAnnotation': /* $FlowFixMe[invalid-compare] Error discovered during Constant Condition @@ -364,8 +363,7 @@ function setDefaultValue( break; case 'StringTypeAnnotation': common.default = (defaultValue === undefined ? null : defaultValue) as - | string - | null; + string | null; break; } } diff --git a/packages/react-native-codegen/src/parsers/utils.js b/packages/react-native-codegen/src/parsers/utils.js index 851450b0b181..3ddb518fbc38 100644 --- a/packages/react-native-codegen/src/parsers/utils.js +++ b/packages/react-native-codegen/src/parsers/utils.js @@ -177,22 +177,18 @@ function isModuleRegistryCall(node: $FlowFixMe): boolean { } const memberExpression = callExpression.callee; - if ( - !( - memberExpression.object.type === 'Identifier' && - memberExpression.object.name === 'TurboModuleRegistry' - ) - ) { + if (!( + memberExpression.object.type === 'Identifier' && + memberExpression.object.name === 'TurboModuleRegistry' + )) { return false; } - if ( - !( - memberExpression.property.type === 'Identifier' && - (memberExpression.property.name === 'get' || - memberExpression.property.name === 'getEnforcing') - ) - ) { + if (!( + memberExpression.property.type === 'Identifier' && + (memberExpression.property.name === 'get' || + memberExpression.property.name === 'getEnforcing') + )) { return false; } diff --git a/packages/react-native-compatibility-check/src/ComparisonResult.js b/packages/react-native-compatibility-check/src/ComparisonResult.js index 40b4d9343953..967206eaef61 100644 --- a/packages/react-native-compatibility-check/src/ComparisonResult.js +++ b/packages/react-native-compatibility-check/src/ComparisonResult.js @@ -120,8 +120,7 @@ export type UnionMembersComparisonResult = { }>, }; export type MembersComparisonResult = - | EnumMembersComparisonResult - | UnionMembersComparisonResult; + EnumMembersComparisonResult | UnionMembersComparisonResult; export type NullableComparisonResult = { /* Four possible cases of change: void goes to T? :: typeRefined !optionsReduced diff --git a/packages/react-native-compatibility-check/src/DiffResults.js b/packages/react-native-compatibility-check/src/DiffResults.js index 22f75d196cdf..1257c6b0c5b0 100644 --- a/packages/react-native-compatibility-check/src/DiffResults.js +++ b/packages/react-native-compatibility-check/src/DiffResults.js @@ -87,9 +87,7 @@ type ExportableSchemaDiffers = { }; export type SchemaDiffCategory = 'new' | 'deprecated' | SchemaDiffers; type ExportableSchemaDiffCategory = - | 'new' - | 'deprecated' - | ExportableSchemaDiffers; + 'new' | 'deprecated' | ExportableSchemaDiffers; export type SchemaDiff = { name: string, framework: Framework, diff --git a/packages/react-native/Libraries/Alert/Alert.js b/packages/react-native/Libraries/Alert/Alert.js index 27108b7f14d3..4130ec511438 100644 --- a/packages/react-native/Libraries/Alert/Alert.js +++ b/packages/react-native/Libraries/Alert/Alert.js @@ -17,10 +17,7 @@ import {alertWithArgs} from './RCTAlertManager'; * @platform ios */ export type AlertType = - | 'default' - | 'plain-text' - | 'secure-text' - | 'login-password'; + 'default' | 'plain-text' | 'secure-text' | 'login-password'; /** * @platform ios diff --git a/packages/react-native/Libraries/Animated/AnimatedEvent.js b/packages/react-native/Libraries/Animated/AnimatedEvent.js index 3f1245e3fd27..866e9bdb20d7 100644 --- a/packages/react-native/Libraries/Animated/AnimatedEvent.js +++ b/packages/react-native/Libraries/Animated/AnimatedEvent.js @@ -21,9 +21,7 @@ import AnimatedValueXY from './nodes/AnimatedValueXY'; import invariant from 'invariant'; export type Mapping = - | {[key: string]: Mapping, ...} - | AnimatedValue - | AnimatedValueXY; + {[key: string]: Mapping, ...} | AnimatedValue | AnimatedValueXY; export type EventConfig = { listener?: ?(NativeSyntheticEvent) => unknown, useNativeDriver: boolean, diff --git a/packages/react-native/Libraries/Animated/createAnimatedComponent.js b/packages/react-native/Libraries/Animated/createAnimatedComponent.js index b6b3953b429d..922e95ec9f6c 100644 --- a/packages/react-native/Libraries/Animated/createAnimatedComponent.js +++ b/packages/react-native/Libraries/Animated/createAnimatedComponent.js @@ -34,20 +34,19 @@ type Builtin = (...ReadonlyArray) => unknown | Date | Error | RegExp; export type WithAnimatedValue = T extends Builtin | Nullable ? T : T extends Primitive - ? - | T - | AnimatedNode - | AnimatedAddition - | AnimatedSubtraction - | AnimatedDivision - | AnimatedMultiplication - | AnimatedModulo - | AnimatedDiffClamp - | AnimatedValue - | AnimatedInterpolation - | AnimatedInterpolation - | AnimatedInterpolation - | AnimatedInterpolation + ? | T + | AnimatedNode + | AnimatedAddition + | AnimatedSubtraction + | AnimatedDivision + | AnimatedMultiplication + | AnimatedModulo + | AnimatedDiffClamp + | AnimatedValue + | AnimatedInterpolation + | AnimatedInterpolation + | AnimatedInterpolation + | AnimatedInterpolation : T extends ReadonlyArray ? ReadonlyArray> : T extends {...} diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js index 480b3217d04b..b6a0d4d3b5f5 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js @@ -28,9 +28,7 @@ import invariant from 'invariant'; type ExtrapolateType = 'extend' | 'identity' | 'clamp'; export type InterpolationConfigSupportedOutputType = - | number - | string - | NativeColorValue; + number | string | NativeColorValue; export type InterpolationConfigType< OutputT extends InterpolationConfigSupportedOutputType, diff --git a/packages/react-native/Libraries/AppState/AppState.js b/packages/react-native/Libraries/AppState/AppState.js index c8a1506fb674..b46ea05b82e0 100644 --- a/packages/react-native/Libraries/AppState/AppState.js +++ b/packages/react-native/Libraries/AppState/AppState.js @@ -29,11 +29,7 @@ import NativeAppState from './NativeAppState'; * @platform ios `inactive` */ export type AppStateStatus = - | 'inactive' - | 'background' - | 'active' - | 'extension' - | 'unknown'; + 'inactive' | 'background' | 'active' | 'extension' | 'unknown'; /** * Events emitted by `AppState`. diff --git a/packages/react-native/Libraries/BatchedBridge/MessageQueue.js b/packages/react-native/Libraries/BatchedBridge/MessageQueue.js index 137233be1dfa..2d0a98f26010 100644 --- a/packages/react-native/Libraries/BatchedBridge/MessageQueue.js +++ b/packages/react-native/Libraries/BatchedBridge/MessageQueue.js @@ -130,8 +130,7 @@ class MessageQueue { } flushedQueue(): - | null - | [Array, Array, Array, number] { + null | [Array, Array, Array, number] { this.__guard(() => { this.__callReactNativeMicrotasks(); }); diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 92e5376cb5d1..b922c77f0626 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -43,10 +43,7 @@ type AccessibilityEventDefinitions = { }; type AccessibilityEventTypes = - | 'click' - | 'focus' - | 'viewHoverEnter' - | 'windowStateChange'; + 'click' | 'focus' | 'viewHoverEnter' | 'windowStateChange'; // Mapping of public event names to platform-specific event names. const EventNames: Map = diff --git a/packages/react-native/Libraries/Components/Button.js b/packages/react-native/Libraries/Components/Button.js index 383f769a4018..c1bec4b65edf 100644 --- a/packages/react-native/Libraries/Components/Button.js +++ b/packages/react-native/Libraries/Components/Button.js @@ -168,8 +168,7 @@ export type ButtonProps = Readonly<{ }>; const NativeTouchable: - | typeof TouchableNativeFeedback - | typeof TouchableOpacity = + typeof TouchableNativeFeedback | typeof TouchableOpacity = Platform.OS === 'android' ? TouchableNativeFeedback : TouchableOpacity; export type ButtonInstance = React.ElementRef; diff --git a/packages/react-native/Libraries/Components/Keyboard/Keyboard.js b/packages/react-native/Libraries/Components/Keyboard/Keyboard.js index da318b738124..40bfa1e121ac 100644 --- a/packages/react-native/Libraries/Components/Keyboard/Keyboard.js +++ b/packages/react-native/Libraries/Components/Keyboard/Keyboard.js @@ -19,11 +19,7 @@ import NativeKeyboardObserver from './NativeKeyboardObserver'; export type KeyboardEventName = keyof KeyboardEventDefinitions; export type KeyboardEventEasing = - | 'easeIn' - | 'easeInEaseOut' - | 'easeOut' - | 'linear' - | 'keyboard'; + 'easeIn' | 'easeInEaseOut' | 'easeOut' | 'linear' | 'keyboard'; export type KeyboardMetrics = Readonly<{ screenX: number, diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index 97e72fcdce0e..c0d1a6d62ab3 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -142,8 +142,7 @@ type PressableBaseProps = Readonly<{ * the component is currently pressed and returns view styles. */ style?: - | ViewStyleProp - | ((state: PressableStateCallbackType) => ViewStyleProp), + ViewStyleProp | ((state: PressableStateCallbackType) => ViewStyleProp), /** * Identifier used to find this view in tests. diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 8f218c6e5120..26b01e15f3eb 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -199,8 +199,7 @@ export type DecelerationRateType = 'fast' | 'normal' | number; export type ScrollResponderType = ScrollViewImperativeMethods; export interface ScrollViewInstance - extends HostInstance, - ScrollViewImperativeMethods {} + extends HostInstance, ScrollViewImperativeMethods {} type InnerViewInstance = React.ElementRef; @@ -394,10 +393,7 @@ export type ScrollViewPropsIOS = Readonly<{ * @platform ios */ contentInsetAdjustmentBehavior?: ?( - | 'automatic' - | 'scrollableAxes' - | 'never' - | 'always' + 'automatic' | 'scrollableAxes' | 'never' | 'always' ), }>; @@ -569,9 +565,11 @@ type ScrollViewBaseProps = Readonly<{ * * @default `'none'` */ - keyboardDismissMode?: ?// default - // cross-platform - ('none' | 'on-drag' | 'interactive'), // ios only + keyboardDismissMode?: ?( + // default + // cross-platform + 'none' | 'on-drag' | 'interactive' + ), // ios only /** * Determines when the keyboard should stay visible after a tap. * diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js index 9cd18a3d2bac..6762a52b3345 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js @@ -32,10 +32,7 @@ export type ScrollViewNativeProps = Readonly<{ centerContent?: ?boolean, contentInset?: ?EdgeInsetsProp, contentInsetAdjustmentBehavior?: ?( - | 'automatic' - | 'scrollableAxes' - | 'never' - | 'always' + 'automatic' | 'scrollableAxes' | 'never' | 'always' ), contentOffset?: ?PointProp, decelerationRate?: ?('fast' | 'normal' | number), diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index cb3b241f8089..eaf81c88d84b 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -171,36 +171,20 @@ export type KeyboardTypeIOS = export type KeyboardTypeAndroid = 'visible-password'; export type KeyboardTypeOptions = - | KeyboardType - | KeyboardTypeIOS - | KeyboardTypeAndroid; + KeyboardType | KeyboardTypeIOS | KeyboardTypeAndroid; export type InputModeOptions = - | 'none' - | 'text' - | 'decimal' - | 'numeric' - | 'tel' - | 'search' - | 'email' - | 'url'; + 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'; export type ReturnKeyType = 'done' | 'go' | 'next' | 'search' | 'send'; export type ReturnKeyTypeIOS = - | 'default' - | 'emergency-call' - | 'google' - | 'join' - | 'route' - | 'yahoo'; + 'default' | 'emergency-call' | 'google' | 'join' | 'route' | 'yahoo'; export type ReturnKeyTypeAndroid = 'none' | 'previous'; export type ReturnKeyTypeOptions = - | ReturnKeyType - | ReturnKeyTypeIOS - | ReturnKeyTypeAndroid; + ReturnKeyType | ReturnKeyTypeIOS | ReturnKeyTypeAndroid; export type SubmitBehavior = 'submit' | 'blurAndSubmit' | 'newline'; @@ -261,9 +245,7 @@ export type EnterKeyHintTypeIOS = 'enter'; export type EnterKeyHintType = 'done' | 'go' | 'next' | 'search' | 'send'; export type EnterKeyHintTypeOptions = - | EnterKeyHintType - | EnterKeyHintTypeAndroid - | EnterKeyHintTypeIOS; + EnterKeyHintType | EnterKeyHintTypeAndroid | EnterKeyHintTypeIOS; type PasswordRules = string; @@ -306,8 +288,7 @@ export type TextInputIOSProps = Readonly<{ * @platform ios */ dataDetectorTypes?: - | ?DataDetectorTypesType - | ReadonlyArray, + ?DataDetectorTypesType | ReadonlyArray, /** * If `true`, the keyboard disables the return key when there is no text and @@ -385,12 +366,7 @@ export type TextInputIOSProps = Readonly<{ * @platform ios */ lineBreakModeIOS?: ?( - | 'wordWrapping' - | 'char' - | 'clip' - | 'head' - | 'middle' - | 'tail' + 'wordWrapping' | 'char' | 'clip' | 'head' | 'middle' | 'tail' ), /** @@ -447,11 +423,7 @@ export type TextInputAndroidProps = Readonly<{ * - `yesExcludeDescendants` - this view is important for autofill but its children aren't */ importantForAutofill?: ?( - | 'auto' - | 'no' - | 'noExcludeDescendants' - | 'yes' - | 'yesExcludeDescendants' + 'auto' | 'no' | 'noExcludeDescendants' | 'yes' | 'yesExcludeDescendants' ), /** diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 2854f5e53cc0..5203cf36e873 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -641,53 +641,54 @@ function InternalTextInput(props: TextInputProps): React.Node { ? selectionColor : selectionHandleColor, }; - textInput = ( - /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match up - * exactly with the props for TextInput. This will need to get fixed */ - /* $FlowFixMe[incompatible-type] the types for AndroidTextInput don't - * match up exactly with the props for TextInput. This will need to get - * fixed */ - /* $FlowFixMe[incompatible-type-arg] the types for AndroidTextInput don't - * match up exactly with the props for TextInput. This will need to get - * fixed */ - - ); + textInput = + ( + /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match up + * exactly with the props for TextInput. This will need to get fixed */ + /* $FlowFixMe[incompatible-type] the types for AndroidTextInput don't + * match up exactly with the props for TextInput. This will need to get + * fixed */ + /* $FlowFixMe[incompatible-type-arg] the types for AndroidTextInput don't + * match up exactly with the props for TextInput. This will need to get + * fixed */ + + ); } return {textInput}; } diff --git a/packages/react-native/Libraries/Components/Touchable/Touchable.js b/packages/react-native/Libraries/Components/Touchable/Touchable.js index 5804082fd26b..827477f98c5b 100644 --- a/packages/react-native/Libraries/Components/Touchable/Touchable.js +++ b/packages/react-native/Libraries/Components/Touchable/Touchable.js @@ -694,7 +694,7 @@ const TouchableMixinImpl = { curState + '` for Touchable responder `' + typeof this.state.touchable.responderID === - 'number' + 'number' ? this.state.touchable.responderID : 'host component' + '`', ); @@ -707,7 +707,7 @@ const TouchableMixinImpl = { signal + '` for responder `' + typeof this.state.touchable.responderID === - 'number' + 'number' ? this.state.touchable.responderID : '<>' + '`', ); diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js index 73f140fa79d9..e975a0bd3858 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js @@ -84,8 +84,7 @@ export type TouchableNativeFeedbackProps = Readonly<{ | Readonly<{ type: 'ThemeAttrAndroid', attribute: - | 'selectableItemBackground' - | 'selectableItemBackgroundBorderless', + 'selectableItemBackground' | 'selectableItemBackgroundBorderless', rippleRadius: ?number, }> | Readonly<{ diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js index b82a035874ec..3e9b92ad06b1 100755 --- a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -74,10 +74,7 @@ export type TouchableWithoutFeedbackProps = Readonly< */ id?: string, importantForAccessibility?: ?( - | 'auto' - | 'yes' - | 'no' - | 'no-hide-descendants' + 'auto' | 'yes' | 'no' | 'no-hide-descendants' ), nativeID?: ?string, onAccessibilityAction?: ?(event: AccessibilityActionEvent) => unknown, diff --git a/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js b/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js index 32d452656eb7..ed018ab6ad63 100644 --- a/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js +++ b/packages/react-native/Libraries/EventEmitter/NativeEventEmitter.js @@ -62,8 +62,7 @@ export default class NativeEventEmitter< TEventToArgsMap extends Readonly< Record>, > = Readonly>>, -> implements IEventEmitter -{ +> implements IEventEmitter { _nativeModule: ?NativeModule; /** diff --git a/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js b/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js index 9670253ebb44..c6584d87446c 100644 --- a/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js +++ b/packages/react-native/Libraries/EventEmitter/__mocks__/NativeEventEmitter.js @@ -20,8 +20,7 @@ import RCTDeviceEventEmitter from '../RCTDeviceEventEmitter'; */ export default class NativeEventEmitter< TEventToArgsMap extends Readonly>>, -> implements IEventEmitter -{ +> implements IEventEmitter { addListener( eventType: TEvent, listener: (...args: TEventToArgsMap[TEvent]) => unknown, diff --git a/packages/react-native/Libraries/Image/ImageSource.js b/packages/react-native/Libraries/Image/ImageSource.js index de099b77c4b3..48c08d25ab0b 100644 --- a/packages/react-native/Libraries/Image/ImageSource.js +++ b/packages/react-native/Libraries/Image/ImageSource.js @@ -85,9 +85,7 @@ export interface ImageURISource { export type ImageRequireSource = number; export type ImageSource = - | ImageRequireSource - | ImageURISource - | ReadonlyArray; + ImageRequireSource | ImageURISource | ReadonlyArray; type ImageSourceProperties = { body?: ?string, diff --git a/packages/react-native/Libraries/Modal/Modal.js b/packages/react-native/Libraries/Modal/Modal.js index e9201f02ff3f..5f3c943e9776 100644 --- a/packages/react-native/Libraries/Modal/Modal.js +++ b/packages/react-native/Libraries/Modal/Modal.js @@ -117,10 +117,7 @@ export type ModalPropsIOS = { * @platform ios */ presentationStyle?: ?( - | 'fullScreen' - | 'pageSheet' - | 'formSheet' - | 'overFullScreen' + 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen' ), /** diff --git a/packages/react-native/Libraries/Network/XMLHttpRequest.js b/packages/react-native/Libraries/Network/XMLHttpRequest.js index b125c9e9e7fa..7edb4c2a24c5 100644 --- a/packages/react-native/Libraries/Network/XMLHttpRequest.js +++ b/packages/react-native/Libraries/Network/XMLHttpRequest.js @@ -34,12 +34,7 @@ const DEBUG_NETWORK_SEND_DELAY: false = false; // Set to a number of millisecond export type NativeResponseType = 'base64' | 'blob' | 'text'; export type ResponseType = - | '' - | 'arraybuffer' - | 'blob' - | 'document' - | 'json' - | 'text'; + '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; export type Response = ?Object | string; type XHRInterceptor = interface { @@ -658,13 +653,11 @@ class XMLHttpRequest extends EventTarget { } // only call onreadystatechange if there is something to abort, // below logic is per spec - if ( - !( - this.readyState === this.UNSENT || - (this.readyState === this.OPENED && !this._sent) || - this.readyState === this.DONE - ) - ) { + if (!( + this.readyState === this.UNSENT || + (this.readyState === this.OPENED && !this._sent) || + this.readyState === this.DONE + )) { this._reset(); this.setReadyState(this.DONE); } diff --git a/packages/react-native/Libraries/Pressability/HoverState.js b/packages/react-native/Libraries/Pressability/HoverState.js index cfa3f2fd59a8..7f971a29e872 100644 --- a/packages/react-native/Libraries/Pressability/HoverState.js +++ b/packages/react-native/Libraries/Pressability/HoverState.js @@ -19,9 +19,9 @@ let isEnabled = false; if (Platform.OS === 'web') { const canUseDOM = Boolean( typeof window !== 'undefined' && - window.document && - // $FlowFixMe[method-unbinding] - window.document.createElement, + window.document && + // $FlowFixMe[method-unbinding] + window.document.createElement, ); if (canUseDOM) { diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index dec894ab9129..c04cfe1c4b20 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -540,12 +540,7 @@ type ____LayoutStyle_Internal = Readonly<{ * for more details. */ alignSelf?: - | 'auto' - | 'flex-start' - | 'flex-end' - | 'center' - | 'stretch' - | 'baseline', + 'auto' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline', /** `alignContent` controls how rows align in the cross direction, * overriding the `alignContent` of the parent. @@ -736,10 +731,7 @@ type LinearGradientValue = { }; type RadialExtent = - | 'closest-corner' - | 'closest-side' - | 'farthest-corner' - | 'farthest-side'; + 'closest-corner' | 'closest-side' | 'farthest-corner' | 'farthest-side'; export type RadialGradientPosition = | { top: number | string, @@ -785,10 +777,7 @@ export type BackgroundSizeValue = { }; export type BackgroundRepeatKeyword = - | 'repeat' - | 'space' - | 'round' - | 'no-repeat'; + 'repeat' | 'space' | 'round' | 'no-repeat'; export type BackgroundPositionValue = | { @@ -906,8 +895,7 @@ export type ____ViewStyle_InternalBase = Readonly<{ experimental_backgroundImage?: ReadonlyArray | string, experimental_backgroundSize?: ReadonlyArray | string, experimental_backgroundPosition?: - | ReadonlyArray - | string, + ReadonlyArray | string, experimental_backgroundRepeat?: ReadonlyArray | string, isolation?: 'auto' | 'isolate', }>; @@ -1032,20 +1020,11 @@ type ____TextStyle_InternalBase = Readonly<{ letterSpacing?: number, lineHeight?: number, textAlign?: - | 'auto' - | 'left' - | 'right' - | 'center' - | 'justify' - | 'start' - | 'end', + 'auto' | 'left' | 'right' | 'center' | 'justify' | 'start' | 'end', textAlignVertical?: 'auto' | 'top' | 'bottom' | 'center', includeFontPadding?: boolean, textDecorationLine?: - | 'none' - | 'underline' - | 'line-through' - | 'underline line-through', + 'none' | 'underline' | 'line-through' | 'underline line-through', textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy', textDecorationColor?: ____ColorValue_Internal, textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase', @@ -1093,12 +1072,7 @@ export type ____DangerouslyImpreciseStyle_Internal = Readonly<{ }>; export type StyleProp = - | null - | void - | T - | false - | '' - | ReadonlyArray>; + null | void | T | false | '' | ReadonlyArray>; export type ____DangerouslyImpreciseStyleProp_Internal = StyleProp< Partial<____DangerouslyImpreciseStyle_Internal>, diff --git a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js index b46421463c3a..0c2abd7faa0a 100644 --- a/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js +++ b/packages/react-native/Libraries/StyleSheet/private/_TransformStyle.js @@ -74,8 +74,7 @@ export type ____TransformStyle_Internal = Readonly<{ // TODO: what is the actual type it expects? | { readonly matrix: - | ReadonlyArray - | AnimatedNode, + ReadonlyArray | AnimatedNode, }, >, >, @@ -94,6 +93,5 @@ export type ____TransformStyle_Internal = Readonly<{ * E.g. transformOrigin: '30% 80% 15px' */ transformOrigin?: - | [string | number, string | number, string | number] - | string, + [string | number, string | number, string | number] | string, }>; diff --git a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js index 36f67b2217c2..f733c96c9645 100644 --- a/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js +++ b/packages/react-native/Libraries/StyleSheet/processBackgroundImage.js @@ -39,8 +39,7 @@ const LINEAR_GRADIENT_DEFAULT_DIRECTION: LinearGradientDirection = { }; type LinearGradientDirection = - | {type: 'angle', value: number} - | {type: 'keyword', value: string}; + {type: 'angle', value: number} | {type: 'keyword', value: string}; type LinearGradientBackgroundImage = { type: 'linear-gradient', @@ -77,8 +76,7 @@ type ColorStopColor = ProcessedColorValue | null; type ColorStopPosition = number | string | null; type ParsedBackgroundImageValue = - | LinearGradientBackgroundImage - | RadialGradientBackgroundImage; + LinearGradientBackgroundImage | RadialGradientBackgroundImage; export default function processBackgroundImage( backgroundImage: ?(ReadonlyArray | string), diff --git a/packages/react-native/Libraries/Utilities/PlatformTypes.js b/packages/react-native/Libraries/Utilities/PlatformTypes.js index 5a65087c0794..24c9aac73023 100644 --- a/packages/react-native/Libraries/Utilities/PlatformTypes.js +++ b/packages/react-native/Libraries/Utilities/PlatformTypes.js @@ -9,12 +9,7 @@ */ export type PlatformOSType = - | 'ios' - | 'android' - | 'macos' - | 'windows' - | 'web' - | 'native'; + 'ios' | 'android' | 'macos' | 'windows' | 'web' | 'native'; type OptionalPlatformSelectSpec = { [key in PlatformOSType]?: T, // eslint-disable-line no-unused-vars @@ -259,8 +254,4 @@ type WebPlatform = { }; export type PlatformType = - | IOSPlatform - | AndroidPlatform - | WindowsPlatform - | MacOSPlatform - | WebPlatform; + IOSPlatform | AndroidPlatform | WindowsPlatform | MacOSPlatform | WebPlatform; diff --git a/packages/react-native/Libraries/Utilities/useWindowDimensions.js b/packages/react-native/Libraries/Utilities/useWindowDimensions.js index 1b04aeb0ad4c..48c689116154 100644 --- a/packages/react-native/Libraries/Utilities/useWindowDimensions.js +++ b/packages/react-native/Libraries/Utilities/useWindowDimensions.js @@ -22,8 +22,7 @@ import {useEffect, useState} from 'react'; * @see https://reactnative.dev/docs/usewindowdimensions */ export default function useWindowDimensions(): - | DisplayMetrics - | DisplayMetricsAndroid { + DisplayMetrics | DisplayMetricsAndroid { const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); useEffect(() => { function handleChange({ diff --git a/packages/react-native/flow/bom.js.flow b/packages/react-native/flow/bom.js.flow index 64fd2d2b1a64..a7045edfb507 100644 --- a/packages/react-native/flow/bom.js.flow +++ b/packages/react-native/flow/bom.js.flow @@ -81,7 +81,6 @@ declare var console: { // Stack tagging createTask(label: string): ConsoleTask, - ... }; @@ -515,9 +514,7 @@ declare class XMLHttpRequestEventTarget extends EventTarget { // this part of spec is not finished yet, apparently // https://stackoverflow.com/questions/35296664/can-fetch-get-object-as-headers type HeadersInit = - | Headers - | Array<[string, string]> - | {[key: string]: string, ...}; + Headers | Array<[string, string]> | {[key: string]: string, ...}; // TODO Heades and URLSearchParams are almost the same thing. // Could it somehow be abstracted away? @@ -599,12 +596,7 @@ type ReferrerPolicyType = | 'unsafe-url'; type ResponseType = - | 'basic' - | 'cors' - | 'default' - | 'error' - | 'opaque' - | 'opaqueredirect'; + 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; type BodyInit = | string diff --git a/packages/react-native/flow/cssom.js.flow b/packages/react-native/flow/cssom.js.flow index 50bd5c15deac..6633fbecf443 100644 --- a/packages/react-native/flow/cssom.js.flow +++ b/packages/react-native/flow/cssom.js.flow @@ -409,10 +409,7 @@ type AnimationPlayState = 'idle' | 'running' | 'paused' | 'finished'; type AnimationReplaceState = 'active' | 'removed' | 'persisted'; type FillMode = 'none' | 'forwards' | 'backwards' | 'both' | 'auto'; type PlaybackDirection = - | 'normal' - | 'reverse' - | 'alternate' - | 'alternate-reverse'; + 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'; type IterationCompositeOperation = 'replace' | 'accumulate'; type CompositeOperation = 'replace' | 'add' | 'accumulate'; type CompositeOperationOrAuto = CompositeOperation | 'auto'; @@ -481,12 +478,7 @@ type PropertyIndexedKeyframes = { easing?: string | string[], offset?: number | (number | null)[], [property: string]: - | string - | string[] - | number - | null - | (number | null)[] - | void, + string | string[] | number | null | (number | null)[] | void, ... }; diff --git a/packages/react-native/flow/dom.js.flow b/packages/react-native/flow/dom.js.flow index 55e96b385ebd..038a6f01493b 100644 --- a/packages/react-native/flow/dom.js.flow +++ b/packages/react-native/flow/dom.js.flow @@ -172,68 +172,52 @@ type EventHandler = (event: Event) => unknown; type EventListener = {handleEvent: EventHandler, ...} | EventHandler; type MouseEventHandler = (event: MouseEvent) => unknown; type MouseEventListener = - | {handleEvent: MouseEventHandler, ...} - | MouseEventHandler; + {handleEvent: MouseEventHandler, ...} | MouseEventHandler; type FocusEventHandler = (event: FocusEvent) => unknown; type FocusEventListener = - | {handleEvent: FocusEventHandler, ...} - | FocusEventHandler; + {handleEvent: FocusEventHandler, ...} | FocusEventHandler; type KeyboardEventHandler = (event: KeyboardEvent) => unknown; type KeyboardEventListener = - | {handleEvent: KeyboardEventHandler, ...} - | KeyboardEventHandler; + {handleEvent: KeyboardEventHandler, ...} | KeyboardEventHandler; type InputEventHandler = (event: InputEvent) => unknown; type InputEventListener = - | {handleEvent: InputEventHandler, ...} - | InputEventHandler; + {handleEvent: InputEventHandler, ...} | InputEventHandler; type TouchEventHandler = (event: TouchEvent) => unknown; type TouchEventListener = - | {handleEvent: TouchEventHandler, ...} - | TouchEventHandler; + {handleEvent: TouchEventHandler, ...} | TouchEventHandler; type WheelEventHandler = (event: WheelEvent) => unknown; type WheelEventListener = - | {handleEvent: WheelEventHandler, ...} - | WheelEventHandler; + {handleEvent: WheelEventHandler, ...} | WheelEventHandler; type AbortProgressEventHandler = (event: ProgressEvent) => unknown; type AbortProgressEventListener = - | {handleEvent: AbortProgressEventHandler, ...} - | AbortProgressEventHandler; + {handleEvent: AbortProgressEventHandler, ...} | AbortProgressEventHandler; type ProgressEventHandler = (event: ProgressEvent) => unknown; type ProgressEventListener = - | {handleEvent: ProgressEventHandler, ...} - | ProgressEventHandler; + {handleEvent: ProgressEventHandler, ...} | ProgressEventHandler; type DragEventHandler = (event: DragEvent) => unknown; type DragEventListener = - | {handleEvent: DragEventHandler, ...} - | DragEventHandler; + {handleEvent: DragEventHandler, ...} | DragEventHandler; type PointerEventHandler = (event: PointerEvent) => unknown; type PointerEventListener = - | {handleEvent: PointerEventHandler, ...} - | PointerEventHandler; + {handleEvent: PointerEventHandler, ...} | PointerEventHandler; type AnimationEventHandler = (event: AnimationEvent) => unknown; type AnimationEventListener = - | {handleEvent: AnimationEventHandler, ...} - | AnimationEventHandler; + {handleEvent: AnimationEventHandler, ...} | AnimationEventHandler; type ClipboardEventHandler = (event: ClipboardEvent) => unknown; type ClipboardEventListener = - | {handleEvent: ClipboardEventHandler, ...} - | ClipboardEventHandler; + {handleEvent: ClipboardEventHandler, ...} | ClipboardEventHandler; type TransitionEventHandler = (event: TransitionEvent) => unknown; type TransitionEventListener = - | {handleEvent: TransitionEventHandler, ...} - | TransitionEventHandler; + {handleEvent: TransitionEventHandler, ...} | TransitionEventHandler; type MessageEventHandler = (event: MessageEvent) => unknown; type MessageEventListener = - | {handleEvent: MessageEventHandler, ...} - | MessageEventHandler; + {handleEvent: MessageEventHandler, ...} | MessageEventHandler; type BeforeUnloadEventHandler = (event: BeforeUnloadEvent) => unknown; type BeforeUnloadEventListener = - | {handleEvent: BeforeUnloadEventHandler, ...} - | BeforeUnloadEventHandler; + {handleEvent: BeforeUnloadEventHandler, ...} | BeforeUnloadEventHandler; type StorageEventHandler = (event: StorageEvent) => unknown; type StorageEventListener = - | {handleEvent: StorageEventHandler, ...} - | StorageEventHandler; + {handleEvent: StorageEventHandler, ...} | StorageEventHandler; type SecurityPolicyViolationEventHandler = ( event: SecurityPolicyViolationEvent, ) => unknown; @@ -242,8 +226,7 @@ type SecurityPolicyViolationEventListener = | SecurityPolicyViolationEventHandler; type USBConnectionEventHandler = (event: USBConnectionEvent) => unknown; type USBConnectionEventListener = - | {handleEvent: USBConnectionEventHandler, ...} - | USBConnectionEventHandler; + {handleEvent: USBConnectionEventHandler, ...} | USBConnectionEventHandler; type MediaKeySessionType = 'temporary' | 'persistent-license'; type MediaKeyStatus = @@ -272,13 +255,7 @@ type TouchEventTypes = 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel'; type WheelEventTypes = 'wheel'; type AbortProgressEventTypes = 'abort'; type ProgressEventTypes = - | 'abort' - | 'error' - | 'load' - | 'loadend' - | 'loadstart' - | 'progress' - | 'timeout'; + 'abort' | 'error' | 'load' | 'loadend' | 'loadstart' | 'progress' | 'timeout'; type DragEventTypes = | 'drag' | 'dragend' @@ -300,15 +277,10 @@ type PointerEventTypes = | 'gotpointercapture' | 'lostpointercapture'; type AnimationEventTypes = - | 'animationstart' - | 'animationend' - | 'animationiteration'; + 'animationstart' | 'animationend' | 'animationiteration'; type ClipboardEventTypes = 'clipboardchange' | 'cut' | 'copy' | 'paste'; type TransitionEventTypes = - | 'transitionrun' - | 'transitionstart' - | 'transitionend' - | 'transitioncancel'; + 'transitionrun' | 'transitionstart' | 'transitionend' | 'transitioncancel'; type MessageEventTypes = string; type BeforeUnloadEventTypes = 'beforeunload'; type StorageEventTypes = 'storage'; @@ -6051,8 +6023,7 @@ type NodeFilterCallback = ( | typeof NodeFilter.FILTER_SKIP; type NodeFilterInterface = - | NodeFilterCallback - | {acceptNode: NodeFilterCallback, ...}; + NodeFilterCallback | {acceptNode: NodeFilterCallback, ...}; // TODO: window.NodeFilter exists at runtime and behaves as a constructor // as far as `instanceof` is concerned, but it is not callable. diff --git a/packages/react-native/scripts/compose-source-maps.js b/packages/react-native/scripts/compose-source-maps.js index dd1c39999b1f..42a67b762c32 100755 --- a/packages/react-native/scripts/compose-source-maps.js +++ b/packages/react-native/scripts/compose-source-maps.js @@ -16,7 +16,7 @@ const {composeSourceMaps} = require('metro-source-map'); const argv = process.argv.slice(2); let outputPath; -for (let i = 0; i < argv.length; ) { +for (let i = 0; i < argv.length;) { if (argv[i] === '-o') { outputPath = argv[i + 1]; argv.splice(i, 2); diff --git a/packages/react-native/scripts/featureflags/types.js b/packages/react-native/scripts/featureflags/types.js index 086d3f9bbe05..acec7193a8ae 100644 --- a/packages/react-native/scripts/featureflags/types.js +++ b/packages/react-native/scripts/featureflags/types.js @@ -23,10 +23,7 @@ export type FeatureFlagDefinitions = Readonly<{ * - stable: the value of the feature flag will be `expectedReleaseValue` on all releases. */ export type OSSReleaseStageValue = - | 'none' - | 'experimental' - | 'canary' - | 'stable'; + 'none' | 'experimental' | 'canary' | 'stable'; export type CommonFeatureFlagConfig< TValue extends FeatureFlagValue = FeatureFlagValue, diff --git a/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js b/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js index 52817be7732e..76b13411730f 100644 --- a/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js +++ b/packages/react-native/src/private/viewtransition/specs/NativeViewTransition.js @@ -25,7 +25,7 @@ export interface Spec extends TurboModule { }; readonly findPseudoElementShadowNodeByTag: ( reactTag: number, - ) => ?unknown /* Node */; + ) => ?unknown; /* Node */ readonly waitForTransitionAnimation: (animationId: number) => void; readonly transitionAnimationFinished: (animationId: number) => void; } diff --git a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js index 12a34399221c..5253243ef670 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js +++ b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js @@ -19,9 +19,7 @@ export opaque type NativeElementReference = ShadowNode; export opaque type NativeTextReference = ShadowNode; export type NativeNodeReference = - | NativeElementReference - | NativeTextReference - | RootTag; + NativeElementReference | NativeTextReference | RootTag; export type MeasureInWindowOnSuccessCallback = ( x: number, @@ -58,16 +56,16 @@ export interface Spec extends TurboModule { readonly getChildNodes: ( nativeNodeReference: unknown /* NativeNodeReference */, - ) => ReadonlyArray /* $ReadOnlyArray */; + ) => ReadonlyArray; /* $ReadOnlyArray */ readonly getElementById?: ( nativeNodeReference: unknown /* NativeNodeReference */, id: string, - ) => unknown /* ?InstanceHandle */; + ) => unknown; /* ?InstanceHandle */ readonly getParentNode: ( nativeNodeReference: unknown /* NativeNodeReference */, - ) => unknown /* ?InstanceHandle */; + ) => unknown; /* ?InstanceHandle */ readonly isConnected: ( nativeNodeReference: unknown /* NativeNodeReference */, @@ -79,24 +77,24 @@ export interface Spec extends TurboModule { readonly getBorderWidth: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [topWidth: number, rightWidth: number, bottomWidth: number, leftWidth: number] */; + ) => ReadonlyArray; /* [topWidth: number, rightWidth: number, bottomWidth: number, leftWidth: number] */ readonly getBoundingClientRect: ( nativeElementReference: unknown /* NativeElementReference */, includeTransform: boolean, - ) => ReadonlyArray /* [x: number, y: number, width: number, height: number] */; + ) => ReadonlyArray; /* [x: number, y: number, width: number, height: number] */ readonly getInnerSize: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [width: number, height: number] */; + ) => ReadonlyArray; /* [width: number, height: number] */ readonly getScrollPosition: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [scrollLeft: number, scrollTop: number] */; + ) => ReadonlyArray; /* [scrollLeft: number, scrollTop: number] */ readonly getScrollSize: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [scrollWidth: number, scrollHeight: number] */; + ) => ReadonlyArray; /* [scrollWidth: number, scrollHeight: number] */ readonly getTagName: ( nativeElementReference: unknown /* NativeElementReference */, @@ -127,7 +125,7 @@ export interface Spec extends TurboModule { readonly getOffset: ( nativeElementReference: unknown /* NativeElementReference */, - ) => ReadonlyArray /* [offsetParent: ?InstanceHandle, top: number, left: number] */; + ) => ReadonlyArray; /* [offsetParent: ?InstanceHandle, top: number, left: number] */ /* * Special methods to handle the root node. @@ -136,7 +134,7 @@ export interface Spec extends TurboModule { readonly linkRootNode?: ( rootTag: number /* RootTag */, instanceHandle: unknown /* InstanceHandle */, - ) => unknown /* ?NativeElementReference */; + ) => unknown; /* ?NativeElementReference */ /** * Legacy layout APIs (for `ReactNativeElement`). diff --git a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js index 7440f670a96a..ac6eda8c618a 100644 --- a/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js +++ b/packages/react-native/src/private/webapis/intersectionobserver/__tests__/IntersectionObserver-itest.js @@ -38,14 +38,12 @@ export function expectRectEquals( expected: {x: number, y: number, width: number, height: number}, ): boolean { const {x, y, width, height} = expected; - if ( - !( - rect.x === x && - rect.y === y && - rect.width === width && - rect.height === height - ) - ) { + if (!( + rect.x === x && + rect.y === y && + rect.width === width && + rect.height === height + )) { const received = { x: rect.x, y: rect.y, diff --git a/packages/react-native/src/private/webapis/performance/PerformanceEntry.js b/packages/react-native/src/private/webapis/performance/PerformanceEntry.js index 9e4fa32fe455..aef916f4e7f1 100644 --- a/packages/react-native/src/private/webapis/performance/PerformanceEntry.js +++ b/packages/react-native/src/private/webapis/performance/PerformanceEntry.js @@ -14,11 +14,7 @@ import {setPlatformObject} from '../webidl/PlatformObjects'; export type DOMHighResTimeStamp = number; export type PerformanceEntryType = - | 'mark' - | 'measure' - | 'event' - | 'longtask' - | 'resource'; + 'mark' | 'measure' | 'event' | 'longtask' | 'resource'; export type PerformanceEntryJSON = { name: string, diff --git a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js index de5ee16441f4..45af579fcd7f 100644 --- a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js +++ b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js @@ -25,8 +25,7 @@ export type FailingPlatformTestAssertionResult = Readonly<{ }>; export type PlatformTestAssertionResult = - | PassingPlatformTestAssertionResult - | FailingPlatformTestAssertionResult; + PassingPlatformTestAssertionResult | FailingPlatformTestAssertionResult; export type PlatformTestResultStatus = 'PASS' | 'FAIL' | 'ERROR' | 'SKIPPED'; diff --git a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js index 4ee3377ff31f..9bf56b7f6a16 100644 --- a/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js +++ b/packages/rn-tester/js/examples/KeyboardAvoidingView/KeyboardAvoidingViewExample.js @@ -49,8 +49,7 @@ const TextInputForm = () => { const CloseButton = ( props: - | {behavior: any, setModalOpen: any} - | {behavior: string, setModalOpen: any}, + {behavior: any, setModalOpen: any} | {behavior: string, setModalOpen: any}, ) => { return ( = first && - first >= 0 && - last < itemCount && - first >= overscanFirst && - last <= overscanLast && - first <= visible.first && - last >= visible.last - ) - ) { + if (!( + last >= first && + first >= 0 && + last < itemCount && + first >= overscanFirst && + last <= overscanLast && + first <= visible.first && + last >= visible.last + )) { throw new Error( 'Bad window calculation ' + JSON.stringify({ diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 298331a0b839..a8593403e4cd 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -1973,9 +1973,9 @@ class VirtualizedList extends StateSafePureComponent< // Keep a viewport's worth of content around the last focused cell to allow // random navigation around it without any blanking. E.g. tabbing from one // focused item out of viewport to another. - if ( - !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey]) - ) { + if (!( + this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey] + )) { return []; } diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js index 55a63f8866ff..b9551b655421 100644 --- a/packages/virtualized-lists/Lists/VirtualizedSectionList.js +++ b/packages/virtualized-lists/Lists/VirtualizedSectionList.js @@ -131,10 +131,8 @@ type State = {childProps: VirtualizedListProps, ...}; */ class VirtualizedSectionList< ItemT, - SectionT extends SectionBase< - ItemT, + SectionT extends SectionBase = DefaultVirtualizedSectionT, - > = DefaultVirtualizedSectionT, > extends React.PureComponent< VirtualizedSectionListProps, State, @@ -643,10 +641,8 @@ function ItemWithSeparator( const VirtualizedSectionListComponent = VirtualizedSectionList as component< ItemT, - SectionT extends SectionBase< - ItemT, + SectionT extends SectionBase = DefaultVirtualizedSectionT, - > = DefaultVirtualizedSectionT, >( ref?: React.RefSetter< interface { diff --git a/private/react-native-fantom/src/Benchmark.js b/private/react-native-fantom/src/Benchmark.js index 37a8b297ff73..f564d7f8e8ec 100644 --- a/private/react-native-fantom/src/Benchmark.js +++ b/private/react-native-fantom/src/Benchmark.js @@ -61,8 +61,7 @@ type SuiteResults = Array>; type TestWithArgName = string | ((testArg: TestArgType) => string); type TestWithArgOptions = - | FnOptions - | ((testArg: TestArgType) => FnOptions); + FnOptions | ((testArg: TestArgType) => FnOptions); interface ParameterizedTestFunction { ( From 7d855222cd8ccfaf50d1daed2ceeb0986943ff20 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Tue, 14 Jul 2026 00:05:22 -0700 Subject: [PATCH 050/834] Perform AnimationEndSync commits on the React revision (#57493) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57493 Changelog: [General][Fixed] Perform AnimationEndSync commits on the React revision ``` AB Merge UI thread -----------*----------*------------ JS thread ---*------------*------------------ React abFlush ``` In this scenario: - AB - Animation Backend update - React - React commit - Merge - Merge commit - abFlush - Animation Backend [flush](https://www.internalfb.com/code/fbsource/[aecb908843884d2fe57e866611a468e47b36518b]/xplat/js/react-native-github/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.cpp?lines=242-268) 1. React revision captures the currently mounted main revision and applies the update on top of it. The result becomes the new `reactRevision`. 2. Animation Backend performs updates on the mounted tree, and requests a flush 3. The flush runs on the JS thread, clearing the `animatedPropsRegistry` 4. Merge commit runs, mounting the `reactRevision` and effectively dropping the updates. This diff changes the flush to happen on the react revision instead, preventing this from happening. Reviewed By: zeyap Differential Revision: D111219420 fbshipit-source-id: 6494e8347c1cc6d45ad8cb2f7ad88c6ab076b0e3 --- .../ReactCommon/react/renderer/mounting/ShadowTree.cpp | 3 ++- .../ReactCommon/react/renderer/mounting/updateMountedFlag.cpp | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/ShadowTree.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/ShadowTree.cpp index 5633a99ad37d..bdd301f5c3d5 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/ShadowTree.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/ShadowTree.cpp @@ -347,7 +347,8 @@ CommitStatus ShadowTree::tryCommit( getShadowTreeCommitSourceName(commitOptions.source)); auto isReactBranch = ReactNativeFeatureFlags::enableFabricCommitBranching() && - commitOptions.source == CommitSource::React; + (commitOptions.source == CommitSource::React || + commitOptions.source == CommitSource::AnimationEndSync); // Commits on the JS branch are never synchronous. react_native_assert(!isReactBranch || !commitOptions.mountSynchronously); diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/updateMountedFlag.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/updateMountedFlag.cpp index 5c50b8377360..0c4ee4902c6c 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/updateMountedFlag.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/updateMountedFlag.cpp @@ -38,7 +38,8 @@ void updateMountedFlag( // Runtime shadow node references are updated during the React revision // commits so that JS can access layout data from the merged tree. bool shouldUpdateRuntimeReference = - (commitSource == ShadowTreeCommitSource::React && + ((commitSource == ShadowTreeCommitSource::React || + commitSource == ShadowTreeCommitSource::AnimationEndSync) && ReactNativeFeatureFlags::updateRuntimeShadowNodeReferencesOnCommit()) || (ReactNativeFeatureFlags:: updateRuntimeShadowNodeReferencesOnCommitThread() && From cb0cf07c2162b1805048b57748aff2b9f5c0e65a Mon Sep 17 00:00:00 2001 From: Sam Zhou Date: Tue, 14 Jul 2026 00:43:40 -0700 Subject: [PATCH 051/834] Deploy 0.322.0 to xplat (#57538) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57538 [changelog](https://github.com/facebook/flow/blob/main/Changelog.md) Changelog: [Internal] Reviewed By: gkz Differential Revision: D111826645 fbshipit-source-id: c3eb44de4c2d6bc12374814996740a05e1f2b5d5 --- .flowconfig | 2 +- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.flowconfig b/.flowconfig index 832667b526f1..defd5fc23571 100644 --- a/.flowconfig +++ b/.flowconfig @@ -89,4 +89,4 @@ untyped-import untyped-type-import [version] -^0.319.0 +^0.322.0 diff --git a/package.json b/package.json index 53e361add704..b073df203c47 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "eslint-plugin-relay": "^1.8.3", "fb-dotslash": "0.5.8", "flow-api-translator": "0.36.1", - "flow-bin": "^0.319.0", + "flow-bin": "^0.322.0", "hermes-eslint": "0.36.1", "hermes-transform": "0.36.1", "ini": "^5.0.0", diff --git a/yarn.lock b/yarn.lock index 293089ace771..a0b569fac6a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4655,10 +4655,10 @@ flow-api-translator@0.36.1: hermes-transform "0.36.1" typescript "5.3.2" -flow-bin@^0.319.0: - version "0.319.0" - resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.319.0.tgz#7ff6c2c531b4d8bd13ec8fecf69e8964f14b6499" - integrity sha512-cl14ZCtILLYmrSW60hoSNfChiA5Og0xacKhqTiSDfSnhJd0P7jLip8IziGE7bOnOi5JIGo+tOlniibkXmckb/w== +flow-bin@^0.322.0: + version "0.322.0" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.322.0.tgz#b396ad85819af86d9ec8db841d4e1305c2089aa1" + integrity sha512-epxfCrmoA2LXi9NjrTT9GbDXuLxDlMDfHsrFKbHuM5Aug7HTe33/8yvurHK+m4CPzCesRF6ycGx3R2xJl0YMAA== flow-enums-runtime@^0.0.6: version "0.0.6" From 8ce269b28d1655136000aca4670478998013e6c4 Mon Sep 17 00:00:00 2001 From: Intl Scheduler Date: Tue, 14 Jul 2026 08:59:56 -0700 Subject: [PATCH 052/834] translation auto-update for batch 3/64 on master Summary: Chronos Job Instance ID: 1125908308964340 Sandcastle Job Instance ID: 58546795662281634 Processed xml files: android_res/com/facebook/payments/appinfo/res/values/strings.xml android_res/com/facebook/bizapp/bizposts/res/values/strings.xml android_res/com/facebook/browser/lite/bondi/featureview/nativebase/res/values/strings.xml android_res/com/facebook/browser/lite/bondi/lite/res/values/strings.xml android_res/com/facebook/browser/lite/extensions/moreinfo/lite/res/values/strings.xml android_res/com/facebook/browser/lite/chrome/messenger/res/values/strings.xml android_res/com/facebook/messaging/browser/util/res/values/strings.xml android_res/com/facebook/messaging/quickpromotion/chatentity/composer/res/values/strings.xml android_res/com/facebook/xapp/messaging/composer/mention/component/res/values/strings.xml android_res/com/facebook/messaging/media/commonutil/res/values/strings.xml android_res/com/facebook/messaging/montage/composer/model/res/values/strings.xml android_res/com/facebook/messaging/threadview/titlebar/m4/res/values/strings.xml android_res/com/facebook/messaging/deletemessage/res/values/strings.xml android_res/com/facebook/messaging/groups/create/res/values/strings.xml android_res/com/facebook/messaging/groups/description/res/values/strings.xml android_res/com/facebook/messaging/search/res/values/strings.xml android_res/com/facebook/messaging/hometabs/res/values/strings.xml android_res/com/facebook/resources/impl/res/values/strings.xml android_res/com/facebook/xapp/messaging/common/date/res/values/strings.xml android_res/com/facebook/messaging/threads/threadconnectivity/res/values/strings.xml android_res/com/facebook/messaginginblue/inbox/ui/sections/threadlist/res/values/strings.xml android_res/com/facebook/messaginginblue/diode/ui/components/res/values/strings.xml android_res/com/facebook/messaginginblue/common/ui/components/msysbootstrap/res/values/strings.xml android_res/com/facebook/xapp/messaging/message/multiselect/bottomsheet/res/values/strings.xml android_res/com/facebook/messaging/mutators/res/values/strings.xml android_res/com/facebook/messaging/snippet/res/values/strings.xml android_res/com/facebook/messaging/notify/res/values/strings.xml android_res/com/facebook/messaging/media/viewer/res/values/strings.xml android_res/com/facebook/messaging/aibot/res/values/strings.xml android_res/com/facebook/messaging/media/resharehub/res/values/strings.xml android_res/com/facebook/messaging/notify/permissions/res/values/strings.xml android_res/com/facebook/messaging/communitymessaging/tab/plugins/tabcontent/res/values/strings.xml android_res/com/facebook/messaging/aitab/res/values/strings.xml android_res/com/facebook/messaging/aibot/aistudiotab/res/values/strings.xml android_res/com/facebook/messaging/linkhandling/res/values/strings.xml android_res/com/facebook/messaging/montage/viewer/res/values/strings.xml android_res/com/facebook/messaginginblue/sharesheet/components/externalsharingitem/res/values/strings.xml android_res/com/facebook/messaging/montage/viewer/viewcontroller/res/values/strings.xml android_res/com/facebook/ipc/stories/viewer/res/values/strings.xml android_res/com/facebook/audience/stories/archive/res/values/strings.xml android_res/com/facebook/messaging/montage/viewer/contextualreplies/res/values/strings.xml android_res/com/facebook/messaging/montage/viewer/avatarquickreplies/res/values/strings.xml android_res/com/facebook/messaging/integrity/res/values/strings.xml android_res/com/facebook/messaging/business/montageads/res/values/strings.xml android_res/com/facebook/ipc/stories/viewersheet/res/values/strings.xml android_res/com/facebook/fbavatar/res/values/strings.xml android_res/com/facebook/presence/note/ui/shared/res/values/strings.xml android_res/com/facebook/oxygen/preloads/integration/tosacceptance/res/values/strings.xml android_res/com/facebook/bizapp/auth/res/values/strings.xml android_res/com/facebook/bizapp/login/res/values/strings.xml android_res/com/facebook/messaging/montage/composer/art/res/values/strings.xml android_res/com/facebook/messaging/rtc/meetups/res/values/strings.xml android_res/com/facebook/xapp/messaging/feature/audiomessage/waveforms/strings/res/values/strings.xml android_res/com/facebook/video/watchandgo/res/values/strings.xml android_res/com/facebook/fbshorts/viewer/res/values/strings.xml android_res/com/facebook/expression/res/values/strings.xml android_res/com/facebook/rtc/res/values/strings.xml android_res/com/facebook/messaging/rtc/incall/shared/widgets/res/values/strings.xml android_res/com/facebook/expression/screensharing/res/values/strings.xml android_res/com/facebook/messaging/rtc/incall/impl/expression/res/values/strings.xml android_res/com/facebook/messaging/avatar/upgrade/res/values/strings.xml android_res/com/facebook/expression/effect/strings/res/values/strings.xml android_res/com/facebook/messaging/avatar/upsell/res/values/strings.xml android_res/com/facebook/alohacommon/ar/effects/res/values/strings.xml android_res/com/facebook/alohacommon/calling/experiences/ar/photobooth/ar/effects/res/values/strings.xml android_res/com/facebook/alohacommon/ar/photobooth/common/res/values/strings.xml android_res/com/facebook/workchat/rtc/incall/impl/links/utils/res/values/strings.xml android_res/com/facebook/video/settings/res/values/strings.xml android_res/com/facebook/feed/video/inline/res/values/strings.xml android_res/com/facebook/facecast/display/res/values/strings.xml android_res/com/facebook/widget/friendselector/res/values/strings.xml android_res/com/facebook/facecast/view/res/values/strings.xml android_res/com/facebook/facecast/display/streamingreactions/res/values/strings.xml android_res/com/facebook/facecast/display/livestatus/res/values/strings.xml android_res/com/facebook/facecast/display/flexiblebonusbutton/res/values/strings.xml android_res/com/facebook/search/results/rows/videos/res/values/strings.xml android_res/com/facebook/zero/nativetemplate/res/values/strings.xml android_res/com/facebook/video/watch/components/discover/res/values/strings.xml android_res/com/facebook/video/settings/globalsubtitle/res/values/strings.xml android_res/com/facebook/video/commercialbreak/res/values/strings.xml android_res/com/facebook/youth/camera/components/music/res/values/strings.xml android_res/com/facebook/video/watchandgo/player/plugin/res/values/strings.xml android_res/com/facebook/spherical/common/res/values/strings.xml android_res/com/facebook/audience/stories/storysurface/activity/main/res/values/strings.xml ../xplat/orca/msys/feature_aggregation/src/i18n/res/values/strings.xml android_res/com/facebook/messaging/nativepagereply/pagenotificationsetting/res/values/strings.xml android_res/com/facebook/messaging/media/download/res/values/strings.xml android_res/com/facebook/messaging/ui/name/res/values/strings.xml android_res/com/facebook/messaging/sharing/quickshare/res/values/strings.xml android_res/com/facebook/rtc/connectionservice/res/values/strings.xml android_res/com/facebook/rtc/helpers/connectionservicecoordinator/res/values/strings.xml android_res/com/facebook/messaging/groups/linkshare/res/values/strings.xml android_res/com/facebook/messaging/rtc/links/res/values/strings.xml android_res/com/facebook/messaging/rtc/links/sharesheet/res/values/strings.xml android_res/com/facebook/mig/input/res/values/strings.xml android_res/com/facebook/mig/button/progress/res/values/strings.xml android_res/com/facebook/rtc/permissions/res/values/strings.xml android_res/com/facebook/messaging/rtc/links/join/res/values/strings.xml android_res/com/facebook/messaging/rtc/incall/impl/links/errormessage/res/values/strings.xml ../xplat/js/react-native-github/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml allow-large-files ignore-conflict-markers opt-out-review drop-conflicts Differential Revision: D111928274 fbshipit-source-id: f41c5898323797562c43a9cb94163c88ddc1cfb8 --- .../src/main/res/views/uimanager/values-zu/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml index e588156675d6..86b1553f60c8 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values-zu/strings.xml @@ -4,7 +4,9 @@ Imenyu + Into Yemenyu Ibha Lokuskrola Ithebhu + Isibali-sikhathi kuvaliwe From bbb5be9b416efc9b1c365b08a0bcf932c14562ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 09:31:46 -0700 Subject: [PATCH 053/834] Remove SceneTracker module and its use in AppRegistry (#57417) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57417 `SceneTracker` was a small helper in `Libraries/Utilities` that tracked the active navigation scene. It was only ever consumed through a deep import and was never part of the public API. This removes it from the React Native package and moves scene tracking to the application layer that actually depends on it. As part of this, `AppRegistry.runApplication` no longer sets the active scene to the app key. To let the application layer preserve that behavior for surfaces launched by app key, `WrapperComponentProvider` now receives the app key as an optional second argument. Changelog: [General][Breaking] - Remove the `SceneTracker` module from `Libraries/Utilities`, stop setting the active scene from `AppRegistry.runApplication`, and pass the app key as an optional second argument to `WrapperComponentProvider` Reviewed By: huntie, javache Differential Revision: D110471317 fbshipit-source-id: 28d44c74d2cff7926094959f26aa1ed0bf4be9ad --- .../Libraries/ReactNative/AppRegistry.flow.js | 1 + .../Libraries/ReactNative/AppRegistryImpl.js | 5 +-- .../Libraries/Utilities/SceneTracker.js | 42 ------------------- .../Utilities/__tests__/SceneTracker-itest.js | 28 ------------- packages/react-native/ReactNativeApi.d.ts | 7 ++-- .../virtualized-lists/Lists/FillRateHelper.js | 2 +- 6 files changed, 8 insertions(+), 77 deletions(-) delete mode 100644 packages/react-native/Libraries/Utilities/SceneTracker.js delete mode 100644 packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js diff --git a/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js b/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js index c6f5ee3fb988..94de6edde6ec 100644 --- a/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js +++ b/packages/react-native/Libraries/ReactNative/AppRegistry.flow.js @@ -44,5 +44,6 @@ export type Registry = { }; export type WrapperComponentProvider = ( appParameters: Object, + appKey?: string, ) => React.ComponentType; export type RootViewStyleProvider = (appParameters: Object) => ViewStyleProp; diff --git a/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js b/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js index 92db1cd98ea2..33e0c84f3ba1 100644 --- a/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js +++ b/packages/react-native/Libraries/ReactNative/AppRegistryImpl.js @@ -22,7 +22,6 @@ import type { WrapperComponentProvider, } from './AppRegistry.flow'; -import SceneTracker from '../Utilities/SceneTracker'; import DeprecatedPerformanceLoggerStub from './DeprecatedPerformanceLoggerStub'; import {coerceDisplayMode} from './DisplayMode'; import HeadlessJsTaskError from './HeadlessJsTaskError'; @@ -105,7 +104,8 @@ export function registerComponent( initialProps: appParameters.initialProps, rootTag: appParameters.rootTag, WrapperComponent: - wrapperComponentProvider && wrapperComponentProvider(appParameters), + wrapperComponentProvider && + wrapperComponentProvider(appParameters, appKey), rootViewStyle: rootViewStyleProvider && rootViewStyleProvider(appParameters), isLogBox: appKey === 'LogBox', @@ -212,7 +212,6 @@ export function runApplication( "* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called.", ); - SceneTracker.setActiveScene({name: appKey}); runnables[appKey](appParameters, coerceDisplayMode(displayMode)); } diff --git a/packages/react-native/Libraries/Utilities/SceneTracker.js b/packages/react-native/Libraries/Utilities/SceneTracker.js deleted file mode 100644 index 2cedf5df6f07..000000000000 --- a/packages/react-native/Libraries/Utilities/SceneTracker.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict - * @format - */ - -'use strict'; - -export type Scene = {name: string, [string]: unknown, ...}; - -let _listeners: Array<(scene: Scene) => void> = []; - -let _activeScene: Scene = {name: 'default'}; - -const SceneTracker = { - setActiveScene(scene: Scene) { - _activeScene = scene; - _listeners.forEach(listener => listener(_activeScene)); - }, - - getActiveScene(): Scene { - return _activeScene; - }, - - addActiveSceneChangedListener(callback: (scene: Scene) => void): { - remove: () => void, - ... - } { - _listeners.push(callback); - return { - remove: () => { - _listeners = _listeners.filter(listener => callback !== listener); - }, - }; - }, -}; - -export default SceneTracker; diff --git a/packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js b/packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js deleted file mode 100644 index 392f8fad5c32..000000000000 --- a/packages/react-native/Libraries/Utilities/__tests__/SceneTracker-itest.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - */ - -import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; - -const SceneTracker = require('../SceneTracker').default; - -describe('setActiveScene', function () { - it('can handle multiple listeners and unsubscribe', function () { - const listeners = [jest.fn(), jest.fn(), jest.fn()]; - const subscriptions = listeners.map(listener => - SceneTracker.addActiveSceneChangedListener(listener), - ); - subscriptions[1].remove(); - const newScene = {name: 'scene1'}; - SceneTracker.setActiveScene(newScene); - expect(listeners[0]).toBeCalledWith(newScene); - expect(listeners[1]).not.toBeCalled(); - expect(listeners[2]).toBeCalledWith(newScene); - }); -}); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 5c0ec3775e04..fdf2d8b0e554 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<<0d6ccb57cc35f5518f7a83a709927f2f>> + * @generated SignedSource<<3c6e653165b5e8314aba1038abcadea2>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5698,6 +5698,7 @@ declare function Wrapper_default( ): React.ReactNode declare type WrapperComponentProvider = ( appParameters: Object, + appKey?: string, ) => React.ComponentType export { AccessibilityActionEvent, // a0d4daa0 @@ -5720,7 +5721,7 @@ export { AndroidKeyboardEvent, // e03becc8 Animated, // 91a7b171 AppConfig, // 35c0ca70 - AppRegistry, // 7ef8e53a + AppRegistry, // 1e8c5a00 AppState, // 12012be5 AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 @@ -6001,7 +6002,7 @@ export { VirtualizedSectionList, // 9fd9cd61 VirtualizedSectionListInstance, // 12b706d5 VirtualizedSectionListProps, // 12a58a4f - WrapperComponentProvider, // 4b8c7962 + WrapperComponentProvider, // 9ef54e61 codegenNativeCommands, // 628a7c0a codegenNativeComponent, // 32a1bca6 findNodeHandle, // 93f80214 diff --git a/packages/virtualized-lists/Lists/FillRateHelper.js b/packages/virtualized-lists/Lists/FillRateHelper.js index 22232d6affdb..b8e10264e9ad 100644 --- a/packages/virtualized-lists/Lists/FillRateHelper.js +++ b/packages/virtualized-lists/Lists/FillRateHelper.js @@ -43,7 +43,7 @@ let _sampleRate = DEBUG ? 1 : null; * samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`. * * Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with - * `SceneTracker.getActiveScene` to determine the context of the events. + * the active scene/route to determine the context of the events. */ class FillRateHelper { _anyBlankStartTime: ?number = null; From 96d3f911246f81a00644379da364a373fae1f0ca Mon Sep 17 00:00:00 2001 From: Christoph Purrer Date: Tue, 14 Jul 2026 10:57:04 -0700 Subject: [PATCH 054/834] Fix Dependabot security update for concurrent-ruby in react-native (#57355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57355 Changelog: [INTERNAL] Fix Dependabot security update for concurrent-ruby in react-native The Dependabot GitHub Action on `react/react-native` `main` has been failing repeatedly because of `concurrent-ruby`. A security advisory marks `concurrent-ruby < 1.3.7` as affected (patched in `1.3.7`), but all three RN Gemfiles pin `gem 'concurrent-ruby', '<= 1.3.4'`. Dependabot cannot satisfy the advisory under that pin, so it opens a security PR to bump to `1.3.7` and then, on every subsequent run, reports `pull_request_exists_for_latest_version` as a hard error — failing the check and regenerating the internal CI task. The `<= 1.3.4` upper bound was originally added because `concurrent-ruby 1.3.5` dropped its `logger` dependency, which broke older `activesupport`/CocoaPods setups. That cause is already mitigated: every Gemfile now explicitly lists `gem 'logger'`. The upper-bound pin is therefore obsolete. This change relaxes the constraint from `<= 1.3.4` to `>= 1.3.7` in all three Gemfiles (root, `private/helloworld`, `packages/rn-tester`) and updates the two corresponding `Gemfile.lock` files to resolve `concurrent-ruby 1.3.7`. `1.3.7` introduces no new transitive dependencies over `1.3.4`, so no other lockfile entries change. With the advisory satisfied on `main`, Dependabot stops recreating the security PR and the recurring check failure stops. Reviewed By: javache Differential Revision: D109967250 fbshipit-source-id: 88f702bc6677053456557591cfd703776bb6c018 --- Gemfile | 9 ++++++++- Gemfile.lock | 4 ++-- packages/rn-tester/Gemfile | 9 ++++++++- private/helloworld/Gemfile | 9 ++++++++- private/helloworld/Gemfile.lock | 4 ++-- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index e9b4cc2be6d7..e7162a4dadb6 100644 --- a/Gemfile +++ b/Gemfile @@ -3,10 +3,17 @@ source 'https://rubygems.org' # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version ruby ">= 2.6.10" +# concurrent-ruby >= 1.3.5 no longer requires 'logger', and activesupport 6.1.x +# references Logger without requiring it, so `bundle exec pod` crashes with +# "uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger". A bare +# `gem 'logger'` is not enough because bundler does not auto-require Gemfile gems; +# require it here so it is loaded before cocoapods loads active_support. +require 'logger' + gem 'cocoapods', '~> 1.13', '!= 1.15.0', '!= 1.15.1' gem 'activesupport', '>= 6.1.7.5', '< 7.1.0' gem 'xcodeproj', '< 1.26.0' -gem 'concurrent-ruby', '<= 1.3.4' +gem 'concurrent-ruby', '>= 1.3.7' # Ruby 3.4.0 has removed some libraries from the standard library. gem 'bigdecimal' diff --git a/Gemfile.lock b/Gemfile.lock index 8eb0039d4c08..cc3096498876 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -55,7 +55,7 @@ GEM netrc (~> 0.11) cocoapods-try (1.2.0) colored2 (3.1.2) - concurrent-ruby (1.2.2) + concurrent-ruby (1.3.7) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) @@ -97,7 +97,7 @@ DEPENDENCIES benchmark bigdecimal cocoapods (~> 1.13, != 1.15.1, != 1.15.0) - concurrent-ruby (<= 1.3.4) + concurrent-ruby (>= 1.3.7) logger mutex_m xcodeproj (< 1.26.0) diff --git a/packages/rn-tester/Gemfile b/packages/rn-tester/Gemfile index c8b1aad6c2f3..f81cbd110085 100644 --- a/packages/rn-tester/Gemfile +++ b/packages/rn-tester/Gemfile @@ -3,13 +3,20 @@ source 'https://rubygems.org' # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version ruby ">= 2.6.10" +# concurrent-ruby >= 1.3.5 no longer requires 'logger', and activesupport 6.1.x +# references Logger without requiring it, so `bundle exec pod` crashes with +# "uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger". A bare +# `gem 'logger'` is not enough because bundler does not auto-require Gemfile gems; +# require it here so it is loaded before cocoapods loads active_support. +require 'logger' + # Cocoapods 1.15 introduced a bug which break the build. We will remove the upper # bound in the template on Cocoapods with next React Native release. gem 'cocoapods', '~> 1.13', '!= 1.15.0', '!= 1.15.1' gem 'rexml' gem 'activesupport', '>= 6.1.7.5', '< 7.1.0' gem 'xcodeproj', '< 1.26.0' -gem 'concurrent-ruby', '<= 1.3.4' +gem 'concurrent-ruby', '>= 1.3.7' # Ruby 3.4.0 has removed some libraries from the standard library. gem 'bigdecimal' diff --git a/private/helloworld/Gemfile b/private/helloworld/Gemfile index 4fbe0d436d40..b694d98f521a 100644 --- a/private/helloworld/Gemfile +++ b/private/helloworld/Gemfile @@ -2,10 +2,17 @@ source 'https://rubygems.org' ruby ">= 2.6.10" +# concurrent-ruby >= 1.3.5 no longer requires 'logger', and activesupport 6.1.x +# references Logger without requiring it, so `bundle exec pod` crashes with +# "uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger". A bare +# `gem 'logger'` is not enough because bundler does not auto-require Gemfile gems; +# require it here so it is loaded before cocoapods loads active_support. +require 'logger' + gem 'cocoapods', '~> 1.13', '!= 1.15.0', '!= 1.15.1' gem 'activesupport', '>= 6.1.7.5', '< 7.1.0' gem 'xcodeproj', '< 1.26.0' -gem 'concurrent-ruby', '<= 1.3.4' +gem 'concurrent-ruby', '>= 1.3.7' # Ruby 3.4.0 has removed some libraries from the standard library. gem 'bigdecimal' diff --git a/private/helloworld/Gemfile.lock b/private/helloworld/Gemfile.lock index 30ada298ab8a..18811995a299 100644 --- a/private/helloworld/Gemfile.lock +++ b/private/helloworld/Gemfile.lock @@ -58,7 +58,7 @@ GEM netrc (~> 0.11) cocoapods-try (1.2.0) colored2 (3.1.2) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.7) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) @@ -101,7 +101,7 @@ DEPENDENCIES benchmark bigdecimal cocoapods (~> 1.13, != 1.15.1, != 1.15.0) - concurrent-ruby (<= 1.3.4) + concurrent-ruby (>= 1.3.7) ffi (>= 1.17.2) logger mutex_m From 1ecff7769638bc25cd3dd6bbc2dacc4dab510547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 11:29:42 -0700 Subject: [PATCH 055/834] Document why EventTarget feature flags stay at ossReleaseStage 'none' (#57527) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57527 Add TODO comments to the `enableImperativeEvents` and `enableNativeEventTargetEventDispatching` feature flags explaining why they haven't progressed yet (the OSS renderer cannot be upgraded with the necessary changes until React 19.3.0 is released). This is a comment-only change with no functional impact. Changelog: [Internal] Reviewed By: huntie Differential Revision: D111695477 fbshipit-source-id: e124ed4a032cb0a7a05eeb5ae83832b1eb0b1622 --- .../scripts/featureflags/ReactNativeFeatureFlags.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 54ffc48eefc9..0feb8d5d21f0 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1033,8 +1033,10 @@ const definitions: FeatureFlagDefinitions = { expectedReleaseValue: true, purpose: 'release', }, + // TODO: This should be "canary" now but the OSS renderer cannot be upgraded with the necessary changes until React 19.3.0 is released. ossReleaseStage: 'none', }, + // TODO: This feature flag should be shipped and clean up now, but the OSS renderer cannot be upgraded with the necessary changes until React 19.3.0 is released. enableNativeEventTargetEventDispatching: { defaultValue: false, metadata: { From 79962854d749bc73c5a5c6a457dd6519f1191d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 11:29:42 -0700 Subject: [PATCH 056/834] Remove enableDirectEventsInEventTarget feature flag (#57528) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57528 Cleans up the `enableDirectEventsInEventTarget` JS feature flag and inlines its usages as `true`. Direct events (those that neither bubble nor capture, such as `onLayout`) now always take the fast target-only dispatch path when EventTarget-based event dispatching is enabled, without a separate gate. - Removed the flag definition and regenerated the feature flags. - Inlined the flag in `dispatchNativeEvent` so `rnIsDirect` is derived directly from the event config. - Updated integration/benchmark tests: dropped the flag pragmas, made the fast-path test always run, and removed the now-dead legacy-path test. Changelog: [Internal] Reviewed By: huntie Differential Revision: D111695476 fbshipit-source-id: 17269e604a6eda4061332c60c3f5975892df4da4 --- .../ReactNativeFeatureFlags.config.js | 11 -- .../featureflags/ReactNativeFeatureFlags.js | 8 +- .../EventDispatching-benchmark-itest.js | 1 - .../__tests__/EventTargetDispatching-itest.js | 124 ++++++------------ .../renderer/events/dispatchNativeEvent.js | 13 +- 5 files changed, 46 insertions(+), 111 deletions(-) diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 0feb8d5d21f0..848f7222d711 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1014,17 +1014,6 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, - enableDirectEventsInEventTarget: { - defaultValue: false, - metadata: { - dateAdded: '2026-07-06', - description: - 'When enabled (together with enableNativeEventTargetEventDispatching), direct events (those that neither bubble nor capture, such as onLayout) are dispatched only to the target node via a fast path that skips construction and traversal of the ancestor event path.', - expectedReleaseValue: true, - purpose: 'experimentation', - }, - ossReleaseStage: 'none', - }, enableImperativeEvents: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index a9c690414348..0b7d259dcf67 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<71e307d568421d49e3943f58c4fcb163>> + * @generated SignedSource<<81820f6c8f87bd90ed861ad9ddb153bb>> * @flow strict * @noformat */ @@ -33,7 +33,6 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{ animatedForceNativeDriver: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, deferFlatListFocusChangeRenderUpdate: Getter, - enableDirectEventsInEventTarget: Getter, enableImperativeEvents: Getter, enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, @@ -162,11 +161,6 @@ export const animatedShouldSyncValueBeforeStartCallback: Getter = creat */ export const deferFlatListFocusChangeRenderUpdate: Getter = createJavaScriptFlagGetter('deferFlatListFocusChangeRenderUpdate', false); -/** - * When enabled (together with enableNativeEventTargetEventDispatching), direct events (those that neither bubble nor capture, such as onLayout) are dispatched only to the target node via a fast path that skips construction and traversal of the ancestor event path. - */ -export const enableDirectEventsInEventTarget: Getter = createJavaScriptFlagGetter('enableDirectEventsInEventTarget', false); - /** * When enabled, ReactNativeElement and ReadOnlyText expose the public EventTarget API (addEventListener, removeEventListener, dispatchEvent). When disabled, those methods are removed from those final classes. */ diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js index da6c8f3087da..2b2614bca945 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js @@ -5,7 +5,6 @@ * LICENSE file in the root directory of this source tree. * * @fantom_flags enableNativeEventTargetEventDispatching:* - * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js index 8f6edc9e6d5b..9f3fd6984f49 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js @@ -6,7 +6,6 @@ * * @fantom_flags enableNativeEventTargetEventDispatching:* * @fantom_flags enableImperativeEvents:* - * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ @@ -1730,94 +1729,51 @@ const {isOSS} = Fantom.getConstants(); ).toBe(0); }); - (ReactNativeFeatureFlags.enableDirectEventsInEventTarget() - ? it - : it.skip)( - 'restricts the event path to the target (AT_TARGET only, no ancestor capture listeners)', - () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const ancestorCapture = jest.fn(); - let observedPhase: number | null = null; - let observedPathLength: number | null = null; - let observedCurrentIsTarget: boolean | null = null; - - const handler = jest.fn((e: $FlowFixMe) => { - observedPhase = e.eventPhase; - observedPathLength = e.composedPath().length; - observedCurrentIsTarget = e.currentTarget === childRef.current; - }); - - Fantom.runTask(() => { - root.render(createNestedViewsWithLayout(5, childRef, () => {})); - }); - - asEventTarget(childRef.current).addEventListener('layout', handler); - asEventTarget(root.document.documentElement).addEventListener( - 'layout', - ancestorCapture, - {capture: true}, - ); - Fantom.flushAllNativeEvents(); - - const ancestorCaptureBefore = ancestorCapture.mock.calls.length; - - Fantom.dispatchNativeEvent( - childRef, - 'onLayout', - {layout: {x: 0, y: 0, width: 100, height: 50}}, - {category: Fantom.NativeEventCategory.Discrete}, - ); + it('restricts the event path to the target (AT_TARGET only, no ancestor capture listeners)', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const ancestorCapture = jest.fn(); + let observedPhase: number | null = null; + let observedPathLength: number | null = null; + let observedCurrentIsTarget: boolean | null = null; + + const handler = jest.fn((e: $FlowFixMe) => { + observedPhase = e.eventPhase; + observedPathLength = e.composedPath().length; + observedCurrentIsTarget = e.currentTarget === childRef.current; + }); - expect(handler).toHaveBeenCalled(); - expect(observedPhase).toBe(Event.AT_TARGET); - // Event path is just the target node. - expect(observedPathLength).toBe(1); - expect(observedCurrentIsTarget).toBe(true); - // With the fast path, the capture phase does not traverse ancestors. - expect( - ancestorCapture.mock.calls.length - ancestorCaptureBefore, - ).toBe(0); - }, - ); + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(5, childRef, () => {})); + }); - (ReactNativeFeatureFlags.enableDirectEventsInEventTarget() - ? it.skip - : it)( - 'without the fast path, the capture phase still traverses ancestors for direct events', - () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const ancestorCapture = jest.fn(); - - Fantom.runTask(() => { - root.render(createNestedViewsWithLayout(5, childRef, () => {})); - }); - - asEventTarget(root.document.documentElement).addEventListener( - 'layout', - ancestorCapture, - {capture: true}, - ); - Fantom.flushAllNativeEvents(); + asEventTarget(childRef.current).addEventListener('layout', handler); + asEventTarget(root.document.documentElement).addEventListener( + 'layout', + ancestorCapture, + {capture: true}, + ); + Fantom.flushAllNativeEvents(); - const ancestorCaptureBefore = ancestorCapture.mock.calls.length; + const ancestorCaptureBefore = ancestorCapture.mock.calls.length; - Fantom.dispatchNativeEvent( - childRef, - 'onLayout', - {layout: {x: 0, y: 0, width: 100, height: 50}}, - {category: Fantom.NativeEventCategory.Discrete}, - ); + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + {category: Fantom.NativeEventCategory.Discrete}, + ); - // The DOM dispatch algorithm runs the capture phase over every - // ancestor even for non-bubbling events, so the ancestor capture - // listener fires. - expect( - ancestorCapture.mock.calls.length - ancestorCaptureBefore, - ).toBe(1); - }, - ); + expect(handler).toHaveBeenCalled(); + expect(observedPhase).toBe(Event.AT_TARGET); + // Event path is just the target node. + expect(observedPathLength).toBe(1); + expect(observedCurrentIsTarget).toBe(true); + // With the fast path, the capture phase does not traverse ancestors. + expect(ancestorCapture.mock.calls.length - ancestorCaptureBefore).toBe( + 0, + ); + }); }); }, ); diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js index bc43624f1d8d..f7333082cea0 100644 --- a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -14,7 +14,6 @@ import { customBubblingEventTypes, customDirectEventTypes, } from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; -import * as ReactNativeFeatureFlags from '../../featureflags/ReactNativeFeatureFlags'; import { setBubbledPropName, setCapturedPropName, @@ -62,13 +61,11 @@ export default function dispatchNativeEvent( bubbleConfig.phasedRegistrationNames.skipBubbling !== true; // A "direct" event is one registered only in the direct-event config - // (e.g. `onLayout`): it neither bubbles nor captures. When the feature - // flag is enabled, tag it so the EventTarget dispatch takes the fast - // target-only path. Note that bubbling events with `skipBubbling` (e.g. - // `onPointerEnter`) still have a capture phase and are NOT direct. + // (e.g. `onLayout`): it neither bubbles nor captures. Tag it so the + // EventTarget dispatch takes the fast target-only path. Note that + // bubbling events with `skipBubbling` (e.g. `onPointerEnter`) still have + // a capture phase and are NOT direct. const isDirect = bubbleConfig == null && directConfig != null; - const rnIsDirect = - isDirect && ReactNativeFeatureFlags.enableDirectEventsInEventTarget(); const eventType = topLevelTypeToEventType(type); const options: { @@ -78,7 +75,7 @@ export default function dispatchNativeEvent( } = { bubbles, cancelable: true, - rnIsDirect, + rnIsDirect: isDirect, }; // Preserve the native event timestamp for backwards compatibility. From 17e55f6b8fe91077ff6ccb24c56852805fb76408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 14:33:44 -0700 Subject: [PATCH 057/834] Add Fantom integration tests for core public APIs (#57466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57466 Adds Fantom `-itest.js` coverage for maintained, public React Native APIs that were previously untested or thinly tested, driving both the JavaScript logic and the underlying cross-platform C++ through the public `react-native` package surface. To make JavaScript-driven animations deterministic under the test runner, this also introduces a small timing indirection in `Animated`: - New module `Animated/AnimationTimingUtils.js` exports `getCurrentAnimationTime` (returns `Date.now()` by default) and `setAnimationTimeProvider` (overrides the time source; pass `null` to restore the default). - `TimingAnimation`, `SpringAnimation` and `DecayAnimation` now read the current time from `getCurrentAnimationTime()` instead of calling `Date.now()` directly. The default behavior is unchanged. With this, a test can override the provider with a clock that advances one frame per read, which drives the JS-driver animation at the same cadence as the native frame clock. The spring and decay tests use this to verify the actual animation curve (overshoot for an underdamped spring, exponential deceleration for decay), not just the final value, and to run a single unified code path for both the JS and native drivers. New test files: - `Animated/__tests__/SpringAnimation-itest.js`, `DecayAnimation-itest.js`, `AnimatedColor-itest.js`, `AnimatedComposition-itest.js` — spring/decay curve shape (both drivers), `Animated.Color` value semantics, and the composition nodes (`add`/`subtract`/`multiply`/`divide`/`modulo`/`diffClamp`/tracking). `AnimatedFantomTestUtils.js` holds the shared trajectory helper. - `Components/View/__tests__/View-nativeCSSParsing-itest.js` — string-valued CSS (color functions, transform, gradients) parsed via native CSS parsing. - `Pressability/__tests__/Pressability-touch-itest.js` — press in/out/press, long press, press delay, and responder termination via touch events and the deterministic timer mock. - `Interaction/__tests__/PanResponder-itest.js` — grant/move/release/terminate and gesture state across touch sequences. - `LayoutAnimation/__tests__/LayoutAnimation-itest.js` — `configureNext`/`create`/presets applying the resulting layout. - `Network/__tests__/Network-itest.js` — synchronous `XMLHttpRequest` lifecycle and validation. Extended test files: - `Components/TextInput/__tests__/TextInput-itest.js` — `onSelectionChange`/`onSubmitEditing`/`onKeyPress`/`onEndEditing` events. - `Lists/__tests__/FlatList-itest.js` — `onViewableItemsChanged` across all `viewabilityConfig` options (`itemVisiblePercentThreshold`, `viewAreaCoveragePercentThreshold`, `minimumViewTime`, `waitForInteraction`). - `Text/__tests__/Text-itest.js` — `letterSpacing`, `lineHeight`, and `fontVariant` styles. The runtime behavior of `Animated` is unchanged; the new timing indirection defaults to `Date.now()`. Changelog: [Internal] Reviewed By: javache, zeyap Differential Revision: D110784436 fbshipit-source-id: 886c5fa735adb01b107ffba7c52c96f2a31b8909 --- .../Animated/AnimationTimingUtils.js | 39 ++ .../Animated/__tests__/AnimatedColor-itest.js | 195 +++++++++ .../__tests__/AnimatedComposition-itest.js | 381 ++++++++++++++++++ .../__tests__/AnimatedFantomTestUtils.js | 100 +++++ .../__tests__/DecayAnimation-itest.js | 106 +++++ .../__tests__/SpringAnimation-itest.js | 214 ++++++++++ .../Animated/animations/DecayAnimation.js | 5 +- .../Animated/animations/SpringAnimation.js | 5 +- .../Animated/animations/TimingAnimation.js | 5 +- .../TextInput/__tests__/TextInput-itest.js | 128 +++++- .../__tests__/View-nativeCSSParsing-itest.js | 75 ++++ .../__tests__/PanResponder-itest.js | 143 +++++++ .../__tests__/LayoutAnimation-itest.js | 109 +++++ .../Lists/__tests__/FlatList-itest.js | 213 +++++++++- .../Network/__tests__/Network-itest.js | 90 +++++ .../__tests__/Pressability-touch-itest.js | 167 ++++++++ .../Libraries/Text/__tests__/Text-itest.js | 49 ++- 17 files changed, 1996 insertions(+), 28 deletions(-) create mode 100644 packages/react-native/Libraries/Animated/AnimationTimingUtils.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js create mode 100644 packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js create mode 100644 packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js create mode 100644 packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js create mode 100644 packages/react-native/Libraries/Network/__tests__/Network-itest.js create mode 100644 packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js diff --git a/packages/react-native/Libraries/Animated/AnimationTimingUtils.js b/packages/react-native/Libraries/Animated/AnimationTimingUtils.js new file mode 100644 index 000000000000..d153a622dee7 --- /dev/null +++ b/packages/react-native/Libraries/Animated/AnimationTimingUtils.js @@ -0,0 +1,39 @@ +/** + * 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'; + +export type AnimationTimeProvider = () => number; + +const defaultAnimationTimeProvider: AnimationTimeProvider = () => Date.now(); + +let animationTimeProvider: AnimationTimeProvider = defaultAnimationTimeProvider; + +/** + * Returns the current time, in milliseconds, used to drive JavaScript-based + * animations (timing, spring and decay). + * + * Defaults to `Date.now()`. The value can be overridden with + * `setAnimationTimeProvider`, e.g. to drive animations from a controlled clock + * in tests. + */ +export function getCurrentAnimationTime(): number { + return animationTimeProvider(); +} + +/** + * Overrides the time source used by `getCurrentAnimationTime`. Pass `null` to restore + * the default `Date.now()`-based provider. + */ +export function setAnimationTimeProvider( + provider: ?AnimationTimeProvider, +): void { + animationTimeProvider = provider ?? defaultAnimationTimeProvider; +} diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js new file mode 100644 index 000000000000..651811587b5b --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedColor-itest.js @@ -0,0 +1,195 @@ +/** + * 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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import {DRIVERS} from './AnimatedFantomTestUtils'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {Animated} from 'react-native'; + +// Renders `color` as a View's backgroundColor so it can be observed through the +// public rendered output rather than private state. +function renderColor(color: Animated.Color): Fantom.Root { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + return root; +} + +function mountedBackgroundColor(color: Animated.Color): string { + return renderColor(color) + .getRenderedOutput({props: ['backgroundColor']}) + .toJSONObject().props.backgroundColor; +} + +describe('Animated.Color', () => { + it('defaults to opaque black', () => { + expect(mountedBackgroundColor(new Animated.Color())).toBe( + 'rgba(0, 0, 0, 1)', + ); + }); + + it('parses an rgba() string', () => { + expect( + mountedBackgroundColor(new Animated.Color('rgba(255, 128, 0, 1)')), + ).toBe('rgba(255, 128, 0, 1)'); + }); + + it('parses a hex string', () => { + expect(mountedBackgroundColor(new Animated.Color('#ff0000'))).toBe( + 'rgba(255, 0, 0, 1)', + ); + }); + + it('accepts an rgba object', () => { + // The rendered color quantizes alpha to 8 bits (0.5 -> 128/255). + expect( + mountedBackgroundColor(new Animated.Color({r: 10, g: 20, b: 30, a: 0.5})), + ).toBe('rgba(10, 20, 30, 0.501961)'); + }); + + it('accepts individual AnimatedValues for each channel', () => { + expect( + mountedBackgroundColor( + new Animated.Color({ + r: new Animated.Value(1), + g: new Animated.Value(2), + b: new Animated.Value(3), + a: new Animated.Value(1), + }), + ), + ).toBe('rgba(1, 2, 3, 1)'); + }); + + it('updates all channels via setValue', () => { + const color = new Animated.Color('rgba(0, 0, 0, 1)'); + const root = renderColor(color); + + Fantom.runTask(() => { + color.setValue({r: 5, g: 6, b: 7, a: 1}); + }); + + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + }); + + it('applies an offset on top of the base value', () => { + const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1}); + const root = renderColor(color); + + // `setOffset` on its own does not flush to a connected view (unlike the + // native driver, which does); the following value update flushes the + // composed color, which includes the offset (base 20 + offset 5). + Fantom.runTask(() => { + color.setOffset({r: 5, g: 5, b: 5, a: 0}); + color.setValue({r: 20, g: 20, b: 20, a: 1}); + }); + + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + }); + + it('flattenOffset and extractOffset preserve the composed value', () => { + const color = new Animated.Color({r: 10, g: 10, b: 10, a: 1}); + renderColor(color); + + let afterFlatten: string = ''; + let afterExtract: string = ''; + Fantom.runTask(() => { + color.setOffset({r: 5, g: 5, b: 5, a: 0}); + // Merges the offset (5) into the base (10) -> base 15, offset 0. + color.flattenOffset(); + color.stopAnimation(value => { + afterFlatten = String(value); + }); + // Moves the base (15) into the offset -> base 0, offset 15. + color.extractOffset(); + color.stopAnimation(value => { + afterExtract = String(value); + }); + }); + + expect(afterFlatten).toBe('rgba(15, 15, 15, 1)'); + expect(afterExtract).toBe('rgba(15, 15, 15, 1)'); + }); + + it('resetAnimation restores the value and reports it to the callback', () => { + const color = new Animated.Color('rgba(1, 2, 3, 1)'); + renderColor(color); + + let reported: string = ''; + Fantom.runTask(() => { + color.resetAnimation(value => { + reported = String(value); + }); + }); + + expect(reported).toBe('rgba(1, 2, 3, 1)'); + }); + + it('updates a native-driven color via setValue', () => { + const color = new Animated.Color('rgba(0, 0, 0, 1)', { + useNativeDriver: true, + }); + renderColor(color); + + // A native-driven color applies via direct manipulation rather than the + // committed tree, so observe the value through the animation callback. + let reported: string = ''; + Fantom.runTask(() => { + color.setValue({r: 5, g: 6, b: 7, a: 1}); + color.stopAnimation(value => { + reported = String(value); + }); + }); + Fantom.unstable_produceFramesForDuration(16); + Fantom.runWorkLoop(); + + expect(reported).toBe('rgba(5, 6, 7, 1)'); + }); + + for (const {name, useNativeDriver} of DRIVERS) { + it(`animates to a target color (${name})`, () => { + const color = new Animated.Color('rgba(255, 0, 0, 1)'); + const root = renderColor(color); + + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + + let finished = false; + Fantom.runTask(() => { + Animated.timing(color, { + toValue: {r: 0, g: 0, b: 255, a: 1}, + duration: 100, + useNativeDriver, + }).start(result => { + finished = result.finished; + }); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + // The final driven color is flushed to the committed tree and observed + // through the public rendered output. + expect(finished).toBe(true); + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + }); + } +}); diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js new file mode 100644 index 000000000000..ba97eacec7f9 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js @@ -0,0 +1,381 @@ +/** + * 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 '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {Animated, useAnimatedValue} from 'react-native'; + +// Renders an whose translateX is driven by `makeNode(base)` and +// returns the base AnimatedValue plus the mounted element so tests can mutate the +// base and observe the derived value on the shadow tree. +function renderWithDerivedTranslateX( + makeNode: (base: Animated.Value) => Animated.WithAnimatedValue, +): { + base: Animated.Value, + element: HostInstance, + root: Fantom.Root, +} { + let base: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const value = useAnimatedValue(0); + base = value; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + const element = nullthrows(viewRef.current); + return {base: nullthrows(base), element, root}; +} + +function getTranslateX(root: Fantom.Root): number { + const output = root.getRenderedOutput({props: ['transform']}).toJSONObject(); + const transform = JSON.parse(output.props.transform); + return transform[0].translateX; +} + +describe('Animated.subtract', () => { + it('computes the difference of two values and reacts to updates', () => { + const {base, root} = renderWithDerivedTranslateX(value => + Animated.subtract(100, value), + ); + + expect(getTranslateX(root)).toBe(100); + + Fantom.runTask(() => { + base.setValue(30); + }); + + expect(getTranslateX(root)).toBe(70); + }); +}); + +describe('Animated.divide', () => { + it('computes the quotient of two values and reacts to updates', () => { + const {base, root} = renderWithDerivedTranslateX(value => + Animated.divide(value, 2), + ); + + Fantom.runTask(() => { + base.setValue(20); + }); + expect(getTranslateX(root)).toBe(10); + + Fantom.runTask(() => { + base.setValue(80); + }); + + expect(getTranslateX(root)).toBe(40); + }); + + it('returns 0 instead of Infinity when dividing by zero', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + expect( + Number( + root.getRenderedOutput({props: ['width']}).toJSONObject().props.width, + ), + ).toBe(0); + }); +}); + +describe('Animated.modulo', () => { + it('wraps values into the [0, modulus) range for positive and negative inputs', () => { + const {base, root} = renderWithDerivedTranslateX(value => + Animated.modulo(value, 10), + ); + + Fantom.runTask(() => { + base.setValue(25); + }); + expect(getTranslateX(root)).toBe(5); + + Fantom.runTask(() => { + base.setValue(-3); + }); + // ((-3 % 10) + 10) % 10 === 7 + expect(getTranslateX(root)).toBe(7); + }); +}); + +describe('Animated.diffClamp', () => { + it('clamps the accumulated delta between min and max', () => { + const base = new Animated.Value(0); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + + // `width` (unlike a zero `translateX`) is preserved in the rendered output, + // so the accumulated, clamped value is observed publicly. + const getWidth = () => + Number( + root.getRenderedOutput({props: ['width']}).toJSONObject().props.width, + ); + + expect(getWidth()).toBe(0); + + // Increase beyond max: 0 + 100 clamped to 20. + Fantom.runTask(() => base.setValue(100)); + expect(getWidth()).toBe(20); + + // Decrease by 30: 20 + (70 - 100) = -10, clamped to 0. + Fantom.runTask(() => base.setValue(70)); + expect(getWidth()).toBe(0); + + // Increase by 5: 0 + (75 - 70) = 5, within range. + Fantom.runTask(() => base.setValue(75)); + expect(getWidth()).toBe(5); + }); +}); + +describe('Animated.add and Animated.multiply', () => { + it('compose additions and multiplications', () => { + const {base, root} = renderWithDerivedTranslateX(value => + Animated.add(Animated.multiply(value, 2), 10), + ); + + Fantom.runTask(() => { + base.setValue(20); + }); + + // 20 * 2 + 10 = 50 + expect(getTranslateX(root)).toBe(50); + }); +}); + +describe('Animated tracking (toValue is an AnimatedNode)', () => { + it('follows the target value when animating toward another AnimatedValue', () => { + let follower: ?Animated.Value; + let leader: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const followerValue = useAnimatedValue(0); + const leaderValue = useAnimatedValue(0); + follower = followerValue; + leader = leaderValue; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + Animated.timing(nullthrows(follower), { + toValue: nullthrows(leader), + duration: 100, + useNativeDriver: false, + }).start(); + }); + + Fantom.runTask(() => { + nullthrows(leader).setValue(50); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(getTranslateX(root)).toBeCloseTo(50, 1); + }); + + it('follows the target value on the native driver', () => { + let follower: ?Animated.Value; + let leader: ?Animated.Value; + let animation: ?Animated.CompositeAnimation; + const viewRef = createRef(); + + function MyApp() { + const followerValue = useAnimatedValue(0); + const leaderValue = useAnimatedValue(0); + follower = followerValue; + leader = leaderValue; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const element = nullthrows(viewRef.current); + + Fantom.runTask(() => { + animation = Animated.timing(nullthrows(follower), { + toValue: nullthrows(leader), + duration: 100, + useNativeDriver: true, + }); + animation.start(); + }); + + Fantom.runTask(() => { + nullthrows(leader).setValue(50); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().x).toBeCloseTo(50, 0); + + Fantom.runTask(() => { + nullthrows(animation).stop(); + }); + Fantom.runTask(() => { + root.render(); + }); + Fantom.unstable_produceFramesForDuration(16); + Fantom.runWorkLoop(); + }); +}); + +// Exercises the native-config, interpolation and detach paths of the +// composition nodes (the JS `__getValue` path is covered by the tests above). +describe('composition nodes: native driver, interpolation and detach', () => { + const factories = [ + { + name: 'add', + make: (base: Animated.Value) => Animated.add(base, 10), + expected: 60, + }, + { + name: 'subtract', + make: (base: Animated.Value) => Animated.subtract(base, 10), + expected: 40, + }, + { + name: 'multiply', + make: (base: Animated.Value) => Animated.multiply(base, 2), + expected: 100, + }, + { + name: 'divide', + make: (base: Animated.Value) => Animated.divide(base, 2), + expected: 25, + }, + { + name: 'modulo', + make: (base: Animated.Value) => Animated.modulo(base, 7), + expected: 50 % 7, + }, + { + name: 'diffClamp', + make: (base: Animated.Value) => Animated.diffClamp(base, 0, 100), + expected: 50, + }, + ]; + + for (const {name, make, expected} of factories) { + it(`${name} runs on the native driver, interpolates, and detaches`, () => { + let base: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const value = useAnimatedValue(0); + base = value; + const node = make(value); + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const element = nullthrows(viewRef.current); + + let animation: ?Animated.CompositeAnimation; + Fantom.runTask(() => { + animation = Animated.timing(nullthrows(base), { + toValue: 50, + duration: 100, + useNativeDriver: true, + }); + animation.start(); + }); + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().x).toBeCloseTo(expected, 0); + + // Stop the animation and re-render without the node to detach the + // composition graph and drain pending work. + Fantom.runTask(() => { + nullthrows(animation).stop(); + }); + Fantom.runTask(() => { + root.render(); + }); + Fantom.unstable_produceFramesForDuration(16); + Fantom.runWorkLoop(); + }); + } +}); diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js new file mode 100644 index 000000000000..0a94e454275f --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedFantomTestUtils.js @@ -0,0 +1,100 @@ +/** + * 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 + * @oncall react_native + */ + +import * as Fantom from '@react-native/fantom'; +import {Animated} from 'react-native'; +import {setAnimationTimeProvider} from 'react-native/Libraries/Animated/AnimationTimingUtils'; + +export type Driver = {readonly name: string, readonly useNativeDriver: boolean}; + +// Every animation is exercised with both drivers. The JS driver runs the +// closed-form integration in JavaScript; the native driver hands the config to +// the C++ backend, which drives the view on each frame. +export const DRIVERS: ReadonlyArray = [ + {name: 'JS driver', useNativeDriver: false}, + {name: 'native driver', useNativeDriver: true}, +]; + +// Fantom's `produceFramesForDuration` advances its frame clock in fixed +// ~16.333ms (60fps) steps. We advance the JS animation clock by the same step +// so both drivers step at the same cadence. +export const FRAME_STEP_MS = 16333 / 1000; + +/** + * Runs `createAnimation(useNativeDriver)` on `value` to completion and returns + * the per-frame trajectory recorded from `value`'s listener. + * + * Both drivers step at the same frame cadence, driven by two clocks advanced + * together: + * - frame timing: `unstable_produceFramesForDuration` advances Fantom's frame + * clock (drives the native driver and flushes each frame's work), and + * - animation timing: `getCurrentAnimationTime` is overridden with a clock + * that advances one frame step per read, driving the JS driver + * deterministically (the native driver ignores it and reads the frame clock + * in C++). + * + * This makes the JS and native drivers produce the same trajectory, so tests + * can assert one curve for both. + */ +export function collectAnimationTrajectory( + value: Animated.Value, + createAnimation: (useNativeDriver: boolean) => Animated.CompositeAnimation, + useNativeDriver: boolean, + durationMs: number, +): {samples: Array, finished: boolean} { + const samples: Array = []; + const listenerId = value.addListener(state => { + samples.push(state.value); + }); + + let animationTime = 0; + setAnimationTimeProvider(() => { + const current = animationTime; + animationTime += FRAME_STEP_MS; + return current; + }); + + let finished = false; + try { + Fantom.runTask(() => { + createAnimation(useNativeDriver).start(result => { + finished = result.finished; + }); + }); + Fantom.unstable_produceFramesForDuration(durationMs); + Fantom.runWorkLoop(); + } finally { + setAnimationTimeProvider(null); + value.removeListener(listenerId); + } + + return {samples, finished}; +} + +/** Diffs between consecutive samples. */ +export function deltas(samples: ReadonlyArray): Array { + const result: Array = []; + for (let i = 1; i < samples.length; i++) { + result.push(samples[i] - samples[i - 1]); + } + return result; +} + +/** + * Asserts the samples rise monotonically to their peak (allowing tiny + * floating-point noise), i.e. no dips on the way up. + */ +export function expectMonotonicToPeak(samples: ReadonlyArray): void { + const peakIndex = samples.indexOf(Math.max(...samples)); + for (let i = 1; i <= peakIndex; i++) { + expect(samples[i]).toBeGreaterThanOrEqual(samples[i - 1] - 1e-6); + } +} diff --git a/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js new file mode 100644 index 000000000000..6863a40b66c3 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/DecayAnimation-itest.js @@ -0,0 +1,106 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import { + DRIVERS, + collectAnimationTrajectory, + deltas, + expectMonotonicToPeak, +} from './AnimatedFantomTestUtils'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {Animated, useAnimatedValue} from 'react-native'; + +function renderTranslateX(): Animated.Value { + let value: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const translateX = useAnimatedValue(0); + value = translateX; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + return nullthrows(value); +} + +const VELOCITY = 2; +const DECELERATION = 0.99; +// value(t) = v0 / (1 - deceleration) * (1 - deceleration^t): the asymptote is +// v0 / (1 - deceleration). +const ASYMPTOTE = VELOCITY / (1 - DECELERATION); + +for (const {name, useNativeDriver} of DRIVERS) { + describe(`Animated.decay (${name})`, () => { + it('decelerates exponentially to a resting position', () => { + const value = renderTranslateX(); + + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.decay(value, { + velocity: VELOCITY, + deceleration: DECELERATION, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expect(samples.length).toBeGreaterThan(10); + + // Comes to rest just below the analytical asymptote. + const rest = samples[samples.length - 1]; + expect(rest).toBeGreaterThan(ASYMPTOTE * 0.97); + expect(rest).toBeLessThanOrEqual(ASYMPTOTE + 1e-6); + + // Monotonically increasing to its resting position (no dips), which for + // decay is also the peak. + expectMonotonicToPeak(samples); + + // Exponential deceleration has two signatures over the early frames + // (before it converges into floating-point noise): + // 1. each per-frame step is strictly smaller than the previous one, and + // 2. the ratio between consecutive steps is (nearly) constant — the + // defining property of geometric/exponential decay, which a linear + // ramp (constant deltas, ratio 1) or an accelerating curve (ratio + // > 1) would fail. + const frameDeltas = deltas(samples); + const ratios: Array = []; + for (let i = 1; i < 9; i++) { + expect(frameDeltas[i]).toBeLessThan(frameDeltas[i - 1]); + ratios.push(frameDeltas[i] / frameDeltas[i - 1]); + } + for (const ratio of ratios) { + expect(ratio).toBeGreaterThan(0.75); + expect(ratio).toBeLessThan(0.95); + } + expect(Math.max(...ratios) - Math.min(...ratios)).toBeLessThan(0.1); + }); + }); +} diff --git a/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js new file mode 100644 index 000000000000..5d26452130de --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/SpringAnimation-itest.js @@ -0,0 +1,214 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import { + DRIVERS, + collectAnimationTrajectory, + expectMonotonicToPeak, +} from './AnimatedFantomTestUtils'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {Animated, useAnimatedValue} from 'react-native'; + +// Springs are observed through an 's opacity. The animation is +// bound to a mounted view (required for the native driver) and its per-frame +// trajectory is recorded from the value's listener. +function renderOpacity(): Animated.Value { + let value: ?Animated.Value; + const viewRef = createRef(); + + function MyApp() { + const opacity = useAnimatedValue(0); + value = opacity; + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + return nullthrows(value); +} + +function expectSettledAtTarget(sample: number, target: number): void { + expect(Math.abs(sample - target)).toBeLessThan(0.02); +} + +for (const {name, useNativeDriver} of DRIVERS) { + describe(`Animated.spring (${name})`, () => { + it('follows an underdamped curve that overshoots then settles', () => { + const value = renderOpacity(); + + // zeta = c / (2 * sqrt(k * m)) = 20 / (2 * sqrt(200)) ~= 0.707, which + // overshoots the target by exp(-zeta * pi / sqrt(1 - zeta^2)) ~= 4.3%. + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + stiffness: 200, + damping: 20, + mass: 1, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expect(samples.length).toBeGreaterThan(3); + // Rises smoothly through the full range (not a snap to the end). + expect(samples.some(v => v > 0.2 && v < 0.4)).toBe(true); + expect(samples.some(v => v > 0.6 && v < 0.8)).toBe(true); + expectMonotonicToPeak(samples); + + // Overshoots the target by the amount the damping ratio predicts. + const peak = Math.max(...samples); + expect(peak).toBeGreaterThan(1.02); + expect(peak).toBeLessThan(1.07); + + // Oscillates back down from the peak and settles at the target. + const last = samples[samples.length - 1]; + expect(last).toBeLessThan(peak); + expectSettledAtTarget(last, 1); + }); + + it('follows an overdamped curve that approaches the target without overshoot', () => { + const value = renderOpacity(); + + // zeta = 30 / (2 * sqrt(100)) = 1.5 > 1 (overdamped): a smooth sigmoid to + // the target with no overshoot. + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + stiffness: 100, + damping: 30, + mass: 1, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expect(samples.some(v => v > 0.2 && v < 0.4)).toBe(true); + expect(samples.some(v => v > 0.6 && v < 0.8)).toBe(true); + expectMonotonicToPeak(samples); + + // No meaningful overshoot for an overdamped spring. + expect(Math.max(...samples)).toBeLessThan(1.005); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + + it('does not overshoot past toValue when overshootClamping is enabled', () => { + const value = renderOpacity(); + + // Same underdamped config as the overshoot test, but clamping must + // suppress the overshoot entirely. + const {samples} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + stiffness: 200, + damping: 20, + mass: 1, + overshootClamping: true, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expectMonotonicToPeak(samples); + // Clamped: overshoot is suppressed to well under the ~4.3% the same + // unclamped config produces (peak ~1.043). + expect(Math.max(...samples)).toBeLessThan(1.02); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + + it('settles at the target when configured via tension/friction', () => { + const value = renderOpacity(); + + // Exercises the Origami tension/friction -> stiffness/damping conversion + // in SpringConfig (`fromOrigamiTensionAndFriction`). + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + tension: 40, + friction: 7, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expectMonotonicToPeak(samples); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + + // `speed` values chosen so the bounciness/speed -> stiffness/damping + // conversion exercises all three friction regimes in SpringConfig + // (bouncyTension <= 18, 18 < bouncyTension <= 44, and > 44). + for (const speed of [2, 5, 12]) { + it(`settles at the target when configured via bounciness/speed (speed ${speed})`, () => { + const value = renderOpacity(); + + const {samples, finished} = collectAnimationTrajectory( + value, + driver => + Animated.spring(value, { + toValue: 1, + bounciness: 12, + speed, + useNativeDriver: driver, + }), + useNativeDriver, + 3000, + ); + + expect(finished).toBe(true); + expectSettledAtTarget(samples[samples.length - 1], 1); + }); + } + }); +} + +describe('Animated.spring config validation', () => { + it('throws when combining mutually exclusive config groups', () => { + const value = renderOpacity(); + + expect(() => { + Fantom.runTask(() => { + Animated.spring(value, { + toValue: 1, + stiffness: 100, + bounciness: 10, + useNativeDriver: true, + }).start(); + }); + }).toThrow(); + }); +}); diff --git a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js index d6b834b032ee..07dc60372058 100644 --- a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js @@ -12,6 +12,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig'; import type AnimatedValue from '../nodes/AnimatedValue'; import type {AnimationConfig, EndCallback} from './Animation'; +import {getCurrentAnimationTime} from '../AnimationTimingUtils'; import Animation from './Animation'; export type DecayAnimationConfig = Readonly<{ @@ -82,7 +83,7 @@ export default class DecayAnimation extends Animation { this._lastValue = fromValue; this._fromValue = fromValue; this._onUpdate = onUpdate; - this._startTime = Date.now(); + this._startTime = getCurrentAnimationTime(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out @@ -92,7 +93,7 @@ export default class DecayAnimation extends Animation { } onUpdate(): void { - const now = Date.now(); + const now = getCurrentAnimationTime(); const value = this._fromValue + diff --git a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js index f04a527469b3..62ede656adce 100644 --- a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js @@ -14,6 +14,7 @@ import type AnimatedValue from '../nodes/AnimatedValue'; import type AnimatedValueXY from '../nodes/AnimatedValueXY'; import type {AnimationConfig, EndCallback} from './Animation'; +import {getCurrentAnimationTime} from '../AnimationTimingUtils'; import AnimatedColor from '../nodes/AnimatedColor'; import * as SpringConfig from '../SpringConfig'; import Animation from './Animation'; @@ -211,7 +212,7 @@ export default class SpringAnimation extends Animation { this._lastPosition = this._startPosition; this._onUpdate = onUpdate; - this._lastTime = Date.now(); + this._lastTime = getCurrentAnimationTime(); this._frameTime = 0.0; if (previousAnimation instanceof SpringAnimation) { @@ -274,7 +275,7 @@ export default class SpringAnimation extends Animation { // computation and will continue on the next frame. It's better to have it // running at faster speed than jumping to the end. const MAX_STEPS = 64; - let now = Date.now(); + let now = getCurrentAnimationTime(); if (now > this._lastTime + MAX_STEPS) { now = this._lastTime + MAX_STEPS; } diff --git a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js index dffb737a9882..e83d1a449a05 100644 --- a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js @@ -16,6 +16,7 @@ import type AnimatedValueXY from '../nodes/AnimatedValueXY'; import type {AnimationConfig, EndCallback} from './Animation'; import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; +import {getCurrentAnimationTime} from '../AnimationTimingUtils'; import AnimatedColor from '../nodes/AnimatedColor'; import Animation from './Animation'; @@ -126,7 +127,7 @@ export default class TimingAnimation extends Animation { } const start = () => { - this._startTime = Date.now(); + this._startTime = getCurrentAnimationTime(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out @@ -150,7 +151,7 @@ export default class TimingAnimation extends Animation { } onUpdate(): void { - const now = Date.now(); + const now = getCurrentAnimationTime(); if (now >= this._startTime + this._duration) { if (this._duration === 0) { this._onUpdate(this._toValue); diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js index 7a9e2a47bc3c..d7f1636c2cdb 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js @@ -10,12 +10,13 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; +import type {HostInstance} from 'react-native'; + import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef, useEffect, useLayoutEffect, useRef} from 'react'; import {TextInput} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; describe('', () => { describe('props', () => { @@ -43,7 +44,7 @@ describe('', () => { describe('onChange', () => { it('is called when the change native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); const onChange = jest.fn(); Fantom.runTask(() => { @@ -74,7 +75,7 @@ describe('', () => { describe('onChangeText', () => { it('is called when the change native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); const onChangeText = jest.fn(); Fantom.runTask(() => { @@ -98,7 +99,7 @@ describe('', () => { describe('onFocus', () => { it('is called when the focus native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); let focusEvent = jest.fn(); @@ -124,7 +125,7 @@ describe('', () => { describe('onBlur', () => { it('is called when the blur native event is dispatched', () => { const root = Fantom.createRoot(); - const nodeRef = createRef>(); + const nodeRef = createRef(); let blurEvent = jest.fn(); @@ -147,6 +148,113 @@ describe('', () => { }); }); + describe('onSelectionChange', () => { + it('is called with the updated selection', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onSelectionChange = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onSelectionChange(event.nativeEvent); + }} + ref={nodeRef}> + hello world + , + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'selectionChange', { + selection: {start: 2, end: 5}, + }); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const [entry] = onSelectionChange.mock.lastCall; + expect(entry.selection).toEqual({start: 2, end: 5}); + }); + }); + + describe('onSubmitEditing', () => { + it('is called when the submit native event is dispatched', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onSubmitEditing = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onSubmitEditing(event.nativeEvent); + }} + ref={nodeRef} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'submitEditing', { + text: 'submitted text', + }); + + expect(onSubmitEditing).toHaveBeenCalledTimes(1); + const [entry] = onSubmitEditing.mock.lastCall; + expect(entry.text).toEqual('submitted text'); + }); + }); + + describe('onKeyPress', () => { + it('is called with the pressed key', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onKeyPress = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onKeyPress(event.nativeEvent); + }} + ref={nodeRef} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'keyPress', {key: 'a'}); + + expect(onKeyPress).toHaveBeenCalledTimes(1); + const [entry] = onKeyPress.mock.lastCall; + expect(entry.key).toEqual('a'); + }); + }); + + describe('onEndEditing', () => { + it('is called when the end editing native event is dispatched', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onEndEditing = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + onEndEditing(event.nativeEvent); + }} + ref={nodeRef} + />, + ); + }); + + Fantom.dispatchNativeEvent(nodeRef, 'endEditing', { + text: 'final text', + }); + + expect(onEndEditing).toHaveBeenCalledTimes(1); + const [entry] = onEndEditing.mock.lastCall; + expect(entry.text).toEqual('final text'); + }); + }); + describe('id and nativeID', () => { it(`has 'id' propagated as 'nativeID' to the mounting layer`, () => { const root = Fantom.createRoot(); @@ -282,7 +390,7 @@ describe('', () => { describe('ref', () => { it('is an element node', () => { - const ref = createRef>(); + const ref = createRef(); const root = Fantom.createRoot(); @@ -290,7 +398,7 @@ describe('', () => { root.render(); }); - expect(ref.current).toBeInstanceOf(ReactNativeElement); + expect(ref.current).toBeInstanceOf(HTMLElement); }); it('provides additional methods: clear, isFocused, getNativeRef, setSelection', () => { @@ -311,7 +419,7 @@ describe('', () => { describe('focus()', () => { it('dispatches the focus command', () => { const root = Fantom.createRoot(); - const ref = createRef>(); + const ref = createRef(); Fantom.runTask(() => { root.render(); @@ -448,7 +556,7 @@ describe('', () => { describe('blur()', () => { it('does NOT dispatch any commands if the input is NOT focused', () => { const root = Fantom.createRoot(); - const ref = createRef>(); + const ref = createRef(); Fantom.runTask(() => { root.render(); @@ -467,7 +575,7 @@ describe('', () => { it('does dispatches the blur command if the input is focused', () => { const root = Fantom.createRoot(); - const ref = createRef>(); + const ref = createRef(); Fantom.runTask(() => { root.render(); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js new file mode 100644 index 000000000000..8aefac7b4248 --- /dev/null +++ b/packages/react-native/Libraries/Components/View/__tests__/View-nativeCSSParsing-itest.js @@ -0,0 +1,75 @@ +/** + * 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 + * @fantom_flags enableNativeCSSParsing:true + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {View} from 'react-native'; + +// These tests render with string-valued CSS properties. With +// `enableNativeCSSParsing` forced on, the strings are parsed by the C++ CSS +// parsers (color functions, transforms, filters, box shadows, gradients), and +// we assert the resulting mounted props. `collapsable={false}` keeps views that +// carry only a non-layout prop present in the mounting layer. +function mountedProp(style: ViewStyleProp, prop: string): string { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + return root.getRenderedOutput({props: [prop]}).toJSONObject().props[prop]; +} + +describe(' native CSS parsing', () => { + describe('color functions', () => { + it('parses hwb() into an rgba color', () => { + // hwb(0 0% 0%) is pure red. + expect( + mountedProp({backgroundColor: 'hwb(0 0% 0%)'}, 'backgroundColor'), + ).toBe('rgba(255, 0, 0, 1)'); + }); + + it('parses hsl() into an rgba color', () => { + // hsl(120, 100%, 50%) is pure green. + expect( + mountedProp( + {backgroundColor: 'hsl(120, 100%, 50%)'}, + 'backgroundColor', + ), + ).toBe('rgba(0, 255, 0, 1)'); + }); + }); + + describe('transform', () => { + it('parses string transform syntax', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect(root.getRenderedOutput({props: ['transform']}).toJSX()).toEqual( + , + ); + }); + }); + + describe('backgroundImage', () => { + it('parses a linear-gradient()', () => { + const backgroundImage = mountedProp( + {backgroundImage: 'linear-gradient(#e66465, #9198e5)'}, + 'backgroundImage', + ); + expect(backgroundImage).toContain('linear-gradient'); + expect(backgroundImage).toContain('rgba(230, 100, 101, 1)'); + }); + }); +}); diff --git a/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js b/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js new file mode 100644 index 000000000000..f5846d4be360 --- /dev/null +++ b/packages/react-native/Libraries/Interaction/__tests__/PanResponder-itest.js @@ -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. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {PanResponderCallbacks} from '../PanResponder'; +import type {HostInstance} from 'react-native'; + +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {PanResponder, View} from 'react-native'; + +function touchAt( + pageX: number, + pageY: number, + timestamp: number, +): {touches: Array<{...}>, changedTouches: Array<{...}>} { + return { + touches: [{identifier: 0, pageX, pageY, timestamp}], + changedTouches: [{identifier: 0, pageX, pageY, timestamp}], + }; +} + +function endAt( + pageX: number, + pageY: number, + timestamp: number, +): {touches: Array<{...}>, changedTouches: Array<{...}>} { + return { + touches: [], + changedTouches: [{identifier: 0, pageX, pageY, timestamp}], + }; +} + +describe('PanResponder', () => { + function renderWithHandlers(config: PanResponderCallbacks): HostInstance { + const panResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + ...config, + }); + const ref = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + return nullthrows(ref.current); + } + + function dispatch( + element: HostInstance, + type: string, + payload: Readonly<{[key: string]: unknown}>, + ): void { + Fantom.dispatchNativeEvent(element, type, payload, { + category: Fantom.NativeEventCategory.Discrete, + }); + } + + it('grants the responder on touch start', () => { + const onPanResponderGrant = jest.fn(); + const element = renderWithHandlers({onPanResponderGrant}); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + expect(onPanResponderGrant).toHaveBeenCalledTimes(1); + + dispatch(element, 'onTouchEnd', endAt(5, 5, 100)); + }); + + it('reports gesture displacement on move', () => { + let capturedDx = null; + let capturedDy = null; + const onPanResponderMove = jest.fn((event, gestureState) => { + capturedDx = gestureState.dx; + capturedDy = gestureState.dy; + }); + const element = renderWithHandlers({onPanResponderMove}); + + dispatch(element, 'onTouchStart', touchAt(0, 0, 0)); + dispatch(element, 'onTouchMove', touchAt(10, 20, 100)); + + expect(onPanResponderMove).toHaveBeenCalled(); + expect(capturedDx).toBe(10); + expect(capturedDy).toBe(20); + + dispatch(element, 'onTouchEnd', endAt(10, 20, 200)); + }); + + it('releases the responder on touch end', () => { + const onPanResponderRelease = jest.fn(); + const element = renderWithHandlers({onPanResponderRelease}); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + dispatch(element, 'onTouchEnd', endAt(5, 5, 100)); + + expect(onPanResponderRelease).toHaveBeenCalledTimes(1); + }); + + it('terminates the responder on touch cancel', () => { + const onPanResponderTerminate = jest.fn(); + const element = renderWithHandlers({onPanResponderTerminate}); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + dispatch(element, 'onTouchCancel', endAt(5, 5, 100)); + + expect(onPanResponderTerminate).toHaveBeenCalledTimes(1); + }); + + it('tracks the number of active touches across a gesture', () => { + let grantTouches = null; + let releaseTouches = null; + const element = renderWithHandlers({ + onPanResponderGrant: (event, gestureState) => { + grantTouches = gestureState.numberActiveTouches; + }, + onPanResponderRelease: (event, gestureState) => { + releaseTouches = gestureState.numberActiveTouches; + }, + }); + + dispatch(element, 'onTouchStart', touchAt(5, 5, 0)); + expect(grantTouches).toBe(1); + + dispatch(element, 'onTouchEnd', endAt(5, 5, 100)); + expect(releaseTouches).toBe(0); + }); +}); diff --git a/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js new file mode 100644 index 000000000000..8c5f9b76febd --- /dev/null +++ b/packages/react-native/Libraries/LayoutAnimation/__tests__/LayoutAnimation-itest.js @@ -0,0 +1,109 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {LayoutAnimation, View} from 'react-native'; + +// Note: Fantom's mock mounting layer commits the post-animation layout in a +// single mounting instruction — it does not replay the C++ +// LayoutAnimationKeyFrameManager's per-frame interpolation, so stepping the +// virtual clock exposes only the final layout (verified: the box jumps straight +// to its end size and `takeMountingManagerLogs` shows a single Update, with no +// intermediate frames). These tests therefore exercise the JS LayoutAnimation +// config/creation paths and assert that the configured layout change is +// applied; intermediate-frame verification would require frame-by-frame +// interpolation support in Fantom's mounting layer. +function renderBox( + viewRef: {current: HostInstance | null}, + width: number, +): React.MixedElement { + return ( + + ); +} + +describe('LayoutAnimation', () => { + it('applies the layout change scheduled with a preset', () => { + const viewRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(renderBox(viewRef, 100)); + }); + + const element = nullthrows(viewRef.current); + expect(element.getBoundingClientRect().width).toBe(100); + + Fantom.runTask(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + root.render(renderBox(viewRef, 300)); + }); + + Fantom.unstable_produceFramesForDuration(500); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().width).toBe(300); + }); + + it('supports LayoutAnimation.create() as the config', () => { + const viewRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(renderBox(viewRef, 100)); + }); + const element = nullthrows(viewRef.current); + + Fantom.runTask(() => { + LayoutAnimation.configureNext( + LayoutAnimation.create(200, 'linear', 'scaleXY'), + ); + root.render(renderBox(viewRef, 250)); + }); + + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().width).toBe(250); + }); + + it('exposes the easeInEaseOut() shortcut', () => { + const viewRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(renderBox(viewRef, 100)); + }); + const element = nullthrows(viewRef.current); + + Fantom.runTask(() => { + LayoutAnimation.easeInEaseOut(); + root.render(renderBox(viewRef, 150)); + }); + + Fantom.unstable_produceFramesForDuration(500); + Fantom.runWorkLoop(); + + expect(element.getBoundingClientRect().width).toBe(150); + }); +}); diff --git a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js index f45861320c83..15afb533d503 100644 --- a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js +++ b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js @@ -11,13 +11,11 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {FlatListProps} from 'react-native/Libraries/Lists/FlatList'; -import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {FlatList, Text, View} from 'react-native'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; function testPropPropagatedToMountingLayer({ propName, @@ -482,9 +480,8 @@ describe('', () => { ); // Scroll down to trigger rendering of additional items. - const scrollView = ensureInstance( + const scrollView = nullthrows( nullthrows(flatListRef.current).getNativeScrollRef(), - ReactNativeElement, ); Fantom.scrollTo(scrollView, {x: 0, y: 100}); @@ -527,9 +524,8 @@ describe('', () => { ); }); - const scrollView = ensureInstance( + const scrollView = nullthrows( nullthrows(flatListRef.current).getNativeScrollRef(), - ReactNativeElement, ); Fantom.scrollTo(scrollView, {x: 0, y: 800}); @@ -538,6 +534,211 @@ describe('', () => { }); }); + describe('onViewableItemsChanged', () => { + it('reports viewable items after scrolling', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfig={{itemVisiblePercentThreshold: 50}} + onViewableItemsChanged={onViewableItemsChanged} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + + // Scroll so items around index 3 become the viewable window. + Fantom.scrollTo(scrollView, {x: 0, y: 300}); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + const lastCall = + onViewableItemsChanged.mock.calls[ + onViewableItemsChanged.mock.calls.length - 1 + ][0]; + const viewableKeys = lastCall.viewableItems.map( + (item: {key: string, ...}) => item.key, + ); + expect(viewableKeys).toContain('3'); + }); + + it('supports viewabilityConfigCallbackPairs', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfigCallbackPairs={[ + { + viewabilityConfig: {viewAreaCoveragePercentThreshold: 50}, + onViewableItemsChanged, + }, + ]} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + + Fantom.scrollTo(scrollView, {x: 0, y: 300}); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + const lastCall = + onViewableItemsChanged.mock.calls[ + onViewableItemsChanged.mock.calls.length - 1 + ][0]; + expect(lastCall.viewableItems.length).toBeGreaterThan(0); + }); + + it('respects minimumViewTime before reporting items', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + const timers = Fantom.installTimerMock(); + try { + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfig={{ + itemVisiblePercentThreshold: 50, + minimumViewTime: 200, + }} + onViewableItemsChanged={onViewableItemsChanged} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + Fantom.scrollTo(scrollView, {x: 0, y: 300}); + + // Items must remain viewable for `minimumViewTime` before being + // reported, so nothing has fired yet. + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + + timers.advanceTimersByTime(200); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + } finally { + timers.uninstall(); + } + }); + + it('waits for interaction before reporting items when waitForInteraction is set', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 200, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({ + length: 100, + offset: 100 * index, + index, + })} + viewabilityConfig={{ + itemVisiblePercentThreshold: 50, + waitForInteraction: true, + }} + onViewableItemsChanged={onViewableItemsChanged} + initialNumToRender={4} + windowSize={5} + />, + ); + }); + + // Initially visible items are withheld until the user interacts. + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + + // Signal an interaction (scrolling records this via onScrollBeginDrag; the + // public method is the deterministic equivalent), then a viewability + // re-evaluation reports the visible items. + Fantom.runTask(() => { + nullthrows(flatListRef.current).recordInteraction(); + }); + + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + Fantom.scrollTo(scrollView, {x: 0, y: 100}); + + expect(onViewableItemsChanged).toHaveBeenCalled(); + }); + }); + describe('extraData', () => { it('triggers re-render when extraData changes', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Network/__tests__/Network-itest.js b/packages/react-native/Libraries/Network/__tests__/Network-itest.js new file mode 100644 index 000000000000..5e9d748d799b --- /dev/null +++ b/packages/react-native/Libraries/Network/__tests__/Network-itest.js @@ -0,0 +1,90 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +// `XMLHttpRequest` is set up as a global by the default environment (via +// `setUpXHR`), so these tests use the public global rather than importing the +// internal module. Fantom's HTTP client stub does not deliver responses, so +// they cover the synchronous XMLHttpRequest lifecycle and validation logic +// rather than response/progress/load/error events. +describe('XMLHttpRequest', () => { + it('transitions to OPENED after open()', () => { + const xhr = new XMLHttpRequest(); + expect(xhr.readyState).toBe(XMLHttpRequest.UNSENT); + + xhr.open('GET', 'https://example.com/data'); + expect(xhr.readyState).toBe(XMLHttpRequest.OPENED); + }); + + it('throws for synchronous requests', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.open('GET', 'https://example.com', false); + }).toThrow('Synchronous http requests are not supported'); + }); + + it('throws when opening an empty url', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.open('GET', ''); + }).toThrow('Cannot load an empty url'); + }); + + it('throws when setting a request header before open()', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.setRequestHeader('Content-Type', 'application/json'); + }).toThrow('Request has not been opened'); + }); + + it('accepts request headers after open()', () => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'https://example.com'); + expect(() => { + xhr.setRequestHeader('Content-Type', 'application/json'); + }).not.toThrow(); + }); + + it('throws when sending before open()', () => { + const xhr = new XMLHttpRequest(); + expect(() => { + xhr.send(); + }).toThrow('Request has not been opened'); + }); + + it('exposes an empty responseText before loading', () => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://example.com'); + expect(xhr.responseText).toBe(''); + expect(xhr.status).toBe(0); + expect(xhr.getAllResponseHeaders()).toBe(null); + }); + + it('supports setting a valid responseType before send', () => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://example.com'); + xhr.responseType = 'json'; + expect(xhr.responseType).toBe('json'); + }); + + it('exposes the upload object and fires onreadystatechange when opened', () => { + const xhr = new XMLHttpRequest(); + const onReadyStateChange = jest.fn(); + xhr.onreadystatechange = onReadyStateChange; + + xhr.open('GET', 'https://example.com'); + + // Opening advances readyState to OPENED, firing readystatechange. + expect(onReadyStateChange).toHaveBeenCalled(); + expect(xhr.upload).toBeDefined(); + }); +}); diff --git a/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js b/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js new file mode 100644 index 000000000000..7b31bc57a0c6 --- /dev/null +++ b/packages/react-native/Libraries/Pressability/__tests__/Pressability-touch-itest.js @@ -0,0 +1,167 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {PressabilityConfig} from '../Pressability'; +import type {HostInstance} from 'react-native'; + +import usePressability from '../usePressability'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +function touchPayload( + identifier: number = 0, + pageX: number = 5, + pageY: number = 5, +): {touches: Array<{...}>, changedTouches: Array<{...}>} { + return { + touches: [{identifier, pageX, pageY, timestamp: 0}], + changedTouches: [{identifier, pageX, pageY, timestamp: 0}], + }; +} + +function touchEndPayload(identifier: number = 0): { + touches: Array<{...}>, + changedTouches: Array<{...}>, +} { + return { + touches: [], + changedTouches: [{identifier, pageX: 5, pageY: 5, timestamp: 200}], + }; +} + +function PressabilityTestView({ + config, + ...viewProps +}: { + config: PressabilityConfig, + ref?: React.RefSetter, + style?: {height: number, width: number}, +}) { + const eventHandlers = usePressability(config); + return ; +} + +function renderPressability(config: PressabilityConfig): HostInstance { + const ref = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + return nullthrows(ref.current); +} + +function touchStart(element: HostInstance): void { + Fantom.dispatchNativeEvent(element, 'onTouchStart', touchPayload(), { + category: Fantom.NativeEventCategory.Discrete, + }); +} + +function touchEnd(element: HostInstance): void { + Fantom.dispatchNativeEvent(element, 'onTouchEnd', touchEndPayload(), { + category: Fantom.NativeEventCategory.Discrete, + }); +} + +describe('Pressability (touch)', () => { + let timers: Fantom.TimerMock; + + beforeEach(() => { + timers = Fantom.installTimerMock(); + }); + + afterEach(() => { + timers.uninstall(); + }); + + it('fires onPressIn on touch start and onPressOut/onPress on touch end', () => { + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onPress = jest.fn(); + + const element = renderPressability({onPressIn, onPressOut, onPress}); + + touchStart(element); + expect(onPressIn).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(0); + + touchEnd(element); + // Flush the minimum-press-duration timer that gates onPressOut. + timers.runAllTimers(); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(1); + }); + + it('fires onLongPress after the long press delay and suppresses onPress', () => { + const onLongPress = jest.fn(); + const onPress = jest.fn(); + + const element = renderPressability({onLongPress, onPress}); + + touchStart(element); + // Default long press delay is 500ms. + timers.advanceTimersByTime(500); + expect(onLongPress).toHaveBeenCalledTimes(1); + + touchEnd(element); + timers.runAllTimers(); + + // A long press does not additionally fire onPress. + expect(onPress).toHaveBeenCalledTimes(0); + }); + + it('delays onPressIn when delayPressIn is set', () => { + const onPressIn = jest.fn(); + + const element = renderPressability({ + onPressIn, + delayPressIn: 200, + }); + + touchStart(element); + expect(onPressIn).toHaveBeenCalledTimes(0); + + timers.advanceTimersByTime(200); + expect(onPressIn).toHaveBeenCalledTimes(1); + + // Release the responder to clean up global responder state. + touchEnd(element); + timers.runAllTimers(); + }); + + it('does not fire onPress when the responder is terminated', () => { + const onPress = jest.fn(); + const onPressIn = jest.fn(); + + const element = renderPressability({onPress, onPressIn}); + + touchStart(element); + expect(onPressIn).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(element, 'onTouchCancel', touchEndPayload(), { + category: Fantom.NativeEventCategory.Discrete, + }); + timers.runAllTimers(); + + expect(onPress).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/react-native/Libraries/Text/__tests__/Text-itest.js b/packages/react-native/Libraries/Text/__tests__/Text-itest.js index a902d3d528a0..a7a50140cd5a 100644 --- a/packages/react-native/Libraries/Text/__tests__/Text-itest.js +++ b/packages/react-native/Libraries/Text/__tests__/Text-itest.js @@ -10,10 +10,12 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import type {AccessibilityProps, HostInstance} from 'react-native'; +import type {HostInstance} from 'react-native'; +import type {AccessibilityProps} from 'react-native'; import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {Text} from 'react-native'; @@ -21,7 +23,7 @@ import accessibilityPropsSuite, { rolePropSuite, } from 'react-native/src/private/__tests__/utilities/accessibilityPropsSuite'; import {testIDPropSuite} from 'react-native/src/private/__tests__/utilities/commonPropsSuite'; -import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; +import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement'; import ReadOnlyText from 'react-native/src/private/webapis/dom/nodes/ReadOnlyText'; const TEST_TEXT = 'the text'; @@ -579,7 +581,7 @@ describe('', () => { root.render({TEST_TEXT}); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.tagName).toBe('RN:Paragraph'); }); @@ -592,7 +594,7 @@ describe('', () => { root.render({TEST_TEXT}); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.childNodes.length).toBe(1); const textChild = ensureInstance(element.childNodes[0], ReadOnlyText); @@ -612,7 +614,7 @@ describe('', () => { ); }); - const element = ensureInstance(elementRef.current, ReactNativeElement); + const element = nullthrows(elementRef.current); expect(element.childNodes.length).toBe(2); const firstChild = ensureInstance(element.childNodes[0], ReadOnlyText); @@ -620,7 +622,7 @@ describe('', () => { const secondChild = ensureInstance( element.childNodes[1], - ReactNativeElement, + ReadOnlyElement, ); expect(secondChild.tagName).toBe('RN:Text'); expect(secondChild.childNodes.length).toBe(1); @@ -816,6 +818,41 @@ describe('', () => { }); }); + describe('text style props', () => { + it('propagates letterSpacing to the mounting layer', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render({TEST_TEXT}); + }); + expect( + root.getRenderedOutput({props: ['letterSpacing']}).toJSX(), + ).toEqual({TEST_TEXT}); + }); + + it('propagates lineHeight to the mounting layer', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render({TEST_TEXT}); + }); + expect(root.getRenderedOutput({props: ['lineHeight']}).toJSX()).toEqual( + {TEST_TEXT}, + ); + }); + + it('propagates fontVariant to the mounting layer', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {TEST_TEXT}, + ); + }); + const fontVariant = root + .getRenderedOutput({props: ['fontVariant']}) + .toJSONObject().props.fontVariant; + expect(fontVariant).toContain('small-caps'); + }); + }); + component TestComponent(testID?: ?string, ...props: AccessibilityProps) { return ( From 704e286c8934a56a6803888a7c50de3b4878d51e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 14:33:44 -0700 Subject: [PATCH 058/834] Add Fantom tests for Animated components and value hooks (#57547) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57547 Adds Fantom `-itest.js` coverage for public Animated APIs that were previously untested: the `Animated.Text` and `Animated.SectionList` animated component wrappers, and the `useAnimatedColor` and `useAnimatedValueXY` hooks (including that each hook returns a stable, memoized value across re-renders). Changelog: [Internal] Reviewed By: sammy-SC Differential Revision: D111905729 fbshipit-source-id: 8576db981821103b704ca16225797cff9e1c9623 --- .../__tests__/AnimatedComponents-itest.js | 102 ++++++++++++++++++ .../__tests__/AnimatedValueHooks-itest.js | 102 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedComponents-itest.js create mode 100644 packages/react-native/Libraries/Animated/__tests__/AnimatedValueHooks-itest.js diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedComponents-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedComponents-itest.js new file mode 100644 index 000000000000..caff0460ffba --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedComponents-itest.js @@ -0,0 +1,102 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {Animated, Text} from 'react-native'; + +describe('Animated.Text', () => { + it('renders its children and applies an animated style prop', () => { + const root = Fantom.createRoot(); + const opacity = new Animated.Value(0.5); + + Fantom.runTask(() => { + root.render( + Hello Animated, + ); + }); + + expect(root.getRenderedOutput({props: ['opacity']}).toJSX()).toEqual( + Hello Animated, + ); + }); + + it('drives a text style prop with an animation', () => { + const root = Fantom.createRoot(); + const opacity = new Animated.Value(0); + + Fantom.runTask(() => { + root.render(Fade); + }); + + // The animation starts from the initial value. + expect(root.getRenderedOutput({props: ['opacity']}).toJSX()).toEqual( + Fade, + ); + + let finished = false; + Fantom.runTask(() => { + Animated.timing(opacity, { + toValue: 0.25, + duration: 100, + useNativeDriver: false, + }).start(result => { + finished = result.finished; + }); + }); + Fantom.unstable_produceFramesForDuration(200); + Fantom.runWorkLoop(); + + expect(finished).toBe(true); + expect(root.getRenderedOutput({props: ['opacity']}).toJSX()).toEqual( + Fade, + ); + }); +}); + +describe('Animated.SectionList', () => { + it('renders section headers and items through the animated wrapper', () => { + const root = Fantom.createRoot({viewportWidth: 400, viewportHeight: 400}); + + Fantom.runTask(() => { + root.render( + {item}} + renderSectionHeader={({ + section, + }: { + section: {title: string, ...}, + ... + }) => {section.title}} + keyExtractor={(item: string) => item} + />, + ); + }); + + expect(root.getRenderedOutput({props: []}).toJSON()).toEqual({ + type: 'ScrollView', + props: {}, + children: [ + { + type: 'View', + props: {}, + children: [ + {type: 'Paragraph', props: {}, children: ['A']}, + {type: 'Paragraph', props: {}, children: ['x']}, + ], + }, + ], + }); + }); +}); diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedValueHooks-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedValueHooks-itest.js new file mode 100644 index 000000000000..0d7fdbbc02a0 --- /dev/null +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedValueHooks-itest.js @@ -0,0 +1,102 @@ +/** + * 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 + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {useState} from 'react'; +import {Animated, useAnimatedColor, useAnimatedValueXY} from 'react-native'; + +describe('useAnimatedColor', () => { + it('drives backgroundColor and returns a stable value across re-renders', () => { + const colors: Array = []; + let forceRender: ?() => void; + + function MyApp() { + const [, setTick] = useState(0); + forceRender = () => setTick(tick => tick + 1); + const color = useAnimatedColor('rgba(1, 2, 3, 1)'); + colors.push(color); + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['backgroundColor']}).toJSX(), + ).toEqual(); + + // Re-render: the hook must return the same memoized instance. + Fantom.runTask(() => { + nullthrows(forceRender)(); + }); + + expect(colors.length).toBeGreaterThan(1); + expect(colors[colors.length - 1]).toBe(colors[0]); + }); +}); + +describe('useAnimatedValueXY', () => { + it('drives a translate transform and returns a stable value across re-renders', () => { + const values: Array = []; + let value: ?Animated.ValueXY; + let forceRender: ?() => void; + + function MyApp() { + const [, setTick] = useState(0); + forceRender = () => setTick(tick => tick + 1); + const xy = useAnimatedValueXY({x: 0, y: 0}); + value = xy; + values.push(xy); + return ( + + ); + } + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + // Stable instance across re-renders. + Fantom.runTask(() => { + nullthrows(forceRender)(); + }); + expect(values.length).toBeGreaterThan(1); + expect(values[values.length - 1]).toBe(values[0]); + + // Updating the value updates both translate axes. + Fantom.runTask(() => { + nullthrows(value).setValue({x: 50, y: 20}); + }); + + const transform = JSON.parse( + root.getRenderedOutput({props: ['transform']}).toJSONObject().props + .transform, + ); + expect(transform).toEqual([{translateX: 50}, {translateY: 20}]); + }); +}); From 8149e22f4fcac0ea55db70f191d77b2c59998f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 14:33:44 -0700 Subject: [PATCH 059/834] Add Fantom tests for ActivityIndicator and Switch (#57548) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57548 Adds Fantom `-itest.js` coverage for two public components that were previously untested: `ActivityIndicator` (small/large/numeric sizes and prop forwarding) and `Switch` (on/off/disabled rendering, track/thumb color resolution, and `onValueChange`/`onChange` firing when the native switch toggles). Changelog: [Internal] Reviewed By: sammy-SC Differential Revision: D111908071 fbshipit-source-id: 795e6f88a52d64221990e1e61c35541b7c48571d --- .../__tests__/ActivityIndicator-itest.js | 206 +++--------------- .../Switch/__tests__/Switch-itest.js | 87 ++++++-- 2 files changed, 100 insertions(+), 193 deletions(-) diff --git a/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js b/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js index f36831c26737..51636fc49de2 100644 --- a/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js +++ b/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js @@ -6,189 +6,53 @@ * * @flow strict-local * @format + * @oncall react_native */ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import type {HostInstance} from 'react-native'; - import * as Fantom from '@react-native/fantom'; -import nullthrows from 'nullthrows'; import * as React from 'react'; -import {createRef} from 'react'; import {ActivityIndicator} from 'react-native'; -describe('', () => { - it('sets displayName', () => { - expect(ActivityIndicator.displayName).toBe('ActivityIndicator'); +function renderSizeOutput(element: React.MixedElement): React.Node { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(element); }); - - describe('props', () => { - describe('size', () => { - it('defaults to "small" (20x20)', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - expect(root.getRenderedOutput().toJSX()).toEqual( - , - ); - }); - - it('renders with size "small"', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - expect(root.getRenderedOutput().toJSX()).toEqual( - , - ); - }); - - it('renders with size "large" (36x36)', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - expect(root.getRenderedOutput().toJSX()).toEqual( - , - ); - }); - - it('renders with numeric size on Android', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - expect(root.getRenderedOutput().toJSX()).toEqual( - , - ); - }); - }); - - describe('color', () => { - it('renders an AndroidProgressBar when color is set', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - // Color is a native prop not serialized as a view attribute, - // but the component still renders correctly - expect(root.getRenderedOutput().toJSX()).toEqual( - , - ); - }); - }); - - describe('animating', () => { - it('defaults to true', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - // Component renders normally when animating (default) - expect(root.getRenderedOutput().toJSX()).toEqual( - , - ); - }); - - it('renders when animating is false', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - // Component still renders when not animating - expect(root.getRenderedOutput().toJSX()).toEqual( - , - ); - }); - }); - - describe('style', () => { - it('applies wrapper View style', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - expect(root.getRenderedOutput({props: ['opacity']}).toJSX()).toEqual( - - - , - ); - }); - }); - - describe('accessibilityLabel', () => { - it('is propagated to the native component', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render( - , - ); - }); - - expect( - root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), - ).toEqual( - , - ); - }); - }); - - describe('testID', () => { - it('is propagated to the native component', () => { - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - expect(root.getRenderedOutput({props: ['testID']}).toJSX()).toEqual( - , - ); - }); - }); + return root.getRenderedOutput({props: ['width', 'height']}).toJSX(); +} + +describe('ActivityIndicator', () => { + it('renders the default (small) size', () => { + expect(renderSizeOutput()).toEqual( + , + ); }); - describe('ref', () => { - it('provides a valid HTMLElement instance', () => { - const elementRef = createRef(); - const root = Fantom.createRoot(); - - Fantom.runTask(() => { - root.render(); - }); - - expect(elementRef.current).toBeInstanceOf(HTMLElement); - }); - - it('has the correct tag name', () => { - const elementRef = createRef(); - const root = Fantom.createRoot(); + it('renders the large size', () => { + expect(renderSizeOutput()).toEqual( + , + ); + }); - Fantom.runTask(() => { - root.render(); - }); + it('renders a numeric size as an explicit width/height', () => { + expect(renderSizeOutput()).toEqual( + , + ); + }); - const element = nullthrows(elementRef.current); - expect(element.tagName).toBe('RN:AndroidProgressBar'); - }); + it('renders with color, animating and hidesWhenStopped props applied', () => { + // color/animating/hidesWhenStopped are forwarded to the native component; + // the mounted size still reflects the (default, small) size. + expect( + renderSizeOutput( + , + ), + ).toEqual(); }); }); diff --git a/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js b/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js index 710d0e93d323..11a9d90ef17b 100644 --- a/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js +++ b/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js @@ -6,6 +6,7 @@ * * @flow strict-local * @format + * @oncall react_native */ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; @@ -18,30 +19,72 @@ import * as React from 'react'; import {createRef} from 'react'; import {Switch} from 'react-native'; -describe('', () => { - describe('props', () => { - describe('exampleProp', () => { - // more describe('') or tests with it('') - }); - // ... more props +function render(element: React.MixedElement): Fantom.Root { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(element); + }); + return root; +} + +describe('Switch', () => { + it('renders the native switch', () => { + const root = render(); + + expect( + root.getRenderedOutput({props: ['accessibilityRole']}).toJSX(), + ).toEqual(); + }); + + it('reflects the disabled state via accessibilityState', () => { + const root = render(); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); }); - describe('ref', () => { - describe('exampleMethod()', () => { - // more describe('') or tests with it('') + + it('renders with track and thumb color props applied', () => { + // Exercises the color-resolution path; the mounted switch still renders as + // the native switch host. + const root = render( + , + ); + + expect( + root.getRenderedOutput({props: ['accessibilityRole']}).toJSX(), + ).toEqual(); + }); + + it('fires onValueChange and onChange when the native switch toggles', () => { + const onValueChange = jest.fn(); + const onChange = jest.fn(); + const ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); }); - // ... more methods - describe('instance', () => { - it('uses the "RN:Switch" tag name', () => { - const elementRef = createRef(); - const root = Fantom.createRoot(); - Fantom.runTask(() => { - root.render(); - }); - - expect(elementRef.current).toBeInstanceOf(HTMLElement); - const element = nullthrows(elementRef.current); - expect(element.tagName).toBe('RN:Switch'); - }); + + Fantom.dispatchNativeEvent(nullthrows(ref.current), 'change', { + value: true, }); + + expect(onValueChange).toHaveBeenCalledWith(true); + expect(onChange).toHaveBeenCalled(); }); }); From ef54f74deb598ba6c751c2fba61627280f5cb82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 14:33:44 -0700 Subject: [PATCH 060/834] Extend Image Fantom tests for alt, aria-label and prefetchWithMetadata (#57549) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57549 Extends `Image-itest.js` with coverage for public `Image` APIs that were previously untested, all exercised through the public `react-native` surface: - the `alt` prop, which maps to `accessibilityLabel` and marks the image accessible; - the `aria-label` prop, which maps to `accessibilityLabel`; and - the `Image.prefetchWithMetadata` static method. Changelog: [Internal] Reviewed By: sammy-SC Differential Revision: D111914101 fbshipit-source-id: c63aaf2df28af7853dc1d75b2ba5ea6fbed894bb --- .../Libraries/Image/__tests__/Image-itest.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/react-native/Libraries/Image/__tests__/Image-itest.js b/packages/react-native/Libraries/Image/__tests__/Image-itest.js index 98466bd85f80..33ac94c94566 100644 --- a/packages/react-native/Libraries/Image/__tests__/Image-itest.js +++ b/packages/react-native/Libraries/Image/__tests__/Image-itest.js @@ -620,6 +620,34 @@ describe('', () => { }); }); + describe('alt', () => { + it('is passed as accessibilityLabel and marks the image accessible', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(a picture); + }); + expect( + root + .getRenderedOutput({props: ['accessibilityLabel', 'accessible']}) + .toJSX(), + ).toEqual( + , + ); + }); + }); + + describe('aria-label', () => { + it('is passed as accessibilityLabel', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual(); + }); + }); + component TestComponent(testID?: ?string, ...props: AccessibilityProps) { return ; } @@ -1047,6 +1075,24 @@ describe('', () => { }); }); + describe('prefetchWithMetadata', () => { + it('prefetches the image', () => { + const uri = 'https://reactnative.dev/img/tiny_logo.png'; + + NativeFantom.setImageResponse(uri, { + width: 100, + height: 100, + }); + + let result; + Fantom.runTask(async () => { + result = await Image.prefetchWithMetadata(uri, 'queryRootName'); + }); + + expect(result).toEqual(true); + }); + }); + describe('queryCache', () => { it('returns empty when image is not cached', () => { const uri = 'https://reactnative.dev/img/tiny_logo.png'; From 08c323346be6c0fbbd70900d48435f977a78a1bb Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 14 Jul 2026 23:00:36 -0700 Subject: [PATCH 061/834] Bootstrap Metro from InitializeCore until setup-env is graph-reachable (#57557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57557 Fresh projects **redbox on launch** — e.g.: ``` Failed to call into JavaScript module method RCTDeviceEventEmitter.emit(). Module has not been registered as callable. Registered callable JavaScript modules (n = 1): AppRegistry. ``` (The named module varies — `HMRClient.setup()`, `RCTDeviceEventEmitter.emit()` — it's whichever callable module native calls first.) `n = 1: AppRegistry` is the tell: **core initialization never runs**. ## Root cause (https://github.com/react/react-native/issues/57475) Core init runs via Metro's `serializer.getModulesRunBeforeMainModule`. Metro (`metro/src/lib/getAppendScripts.js`) only emits the run-before `__r()` for a module **already present in the bundle graph**, and silently skips it otherwise: ```js const paths = [...options.runBeforeMainModule, entryPoint]; for (const path of paths) { if (modules.some((module) => module.path === path)) { // only if already in the graph /* __r(moduleId) */ } } ``` **https://github.com/react/react-native/issues/57475** switched the run-before target from `Libraries/Core/InitializeCore` → `src/setup-env.js`. - `InitializeCore.js` **is** reachable (imported by `Libraries/ReactPrivate/ReactNativePrivateInitializeCore.js`). - `src/setup-env.js` is imported by **nothing** → not in the graph → silently skipped → core init never runs. The two modules are functionally identical (both call `setUpDefaultReactNativeEnvironment().default()`), so only *reachability* changed. https://github.com/react/react-native/issues/57498 was cosmetic (same resolved file) and does not fix this. ### Evidence (bundle tail) | Run-before target | Bundle tail | Boots? | |---|---|---| | `setup-env` (current) | `__r(0);` | ❌ | | `InitializeCore` (this PR) | `__r(); __r(0);` | ✅ | Verified end-to-end via `test-release-local -t RNTestProject -p iOS`. Ruled out: Metro cache (cold `--reset-cache` identical), stale Metro, and https://github.com/react/react-native/issues/57484 (`./src/*` exports removal). Reproduces via the `react-native-community/cli` bundling path (OSS `react-native start`); internal Meta bundling is unaffected, which is why CI/internal stayed green. ## Scope — interim stopgap Only the **internal Metro bootstrap target** reverts to `InitializeCore`. The public `react-native/setup-env` entry point and its deprecation of `InitializeCore` are **unchanged**. The **durable fix** is to make `setup-env` graph-reachable (or make Metro treat `getModulesRunBeforeMainModule` entries as graph roots) — which will then allow `InitializeCore` to be removed as intended. cc huntie (`setup-env` stack owner). A cherry-pick of this is also up against `0.87-stable` (https://github.com/react/react-native/issues/57553). ## Changelog [General][Fixed] - Fix apps failing to boot ("... not registered as callable") caused by core init not running. Reviewed By: christophpurrer Differential Revision: D112002434 fbshipit-source-id: ea3559ef74d31f2c0f9af78aa0a020f4b322e7e0 --- packages/community-cli-plugin/src/utils/loadMetroConfig.js | 6 +++--- packages/metro-config/src/index.flow.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index 9b6a79ee1041..7e2f5c3d002a 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -58,15 +58,15 @@ function getCommunityCliDefaultConfig( return { resolver, serializer: { - // We can include multiple copies of setup-env here because Metro will + // We can include multiple copies of InitializeCore here because metro will // only add ones that are already part of the bundle getModulesRunBeforeMainModule: () => [ - require.resolve('react-native/setup-env', { + require.resolve('react-native/Libraries/Core/InitializeCore', { paths: [ctx.root], }), ...outOfTreePlatforms.map(platform => require.resolve( - `${ctx.platforms[platform].npmPackageName}/setup-env`, + `${ctx.platforms[platform].npmPackageName}/Libraries/Core/InitializeCore`, {paths: [ctx.root]}, ), ), diff --git a/packages/metro-config/src/index.flow.js b/packages/metro-config/src/index.flow.js index 2f4e44c187bc..bb4f611e5371 100644 --- a/packages/metro-config/src/index.flow.js +++ b/packages/metro-config/src/index.flow.js @@ -61,7 +61,7 @@ export function getDefaultConfig(projectRoot: string): ConfigT { serializer: { // NOTE: Overridden in community-cli-plugin getModulesRunBeforeMainModule: () => [ - require.resolve('react-native/setup-env'), + require.resolve('react-native/Libraries/Core/InitializeCore'), ], getPolyfills: () => require('@react-native/js-polyfills')(), isThirdPartyModule({path: modulePath}: Readonly<{path: string, ...}>) { From 2dfdcbf082316e0a5d174f7a9377223ca5d5435f Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Wed, 15 Jul 2026 00:33:49 -0700 Subject: [PATCH 062/834] Fix local release testing: publish with --tag for prerelease versions (#57552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `scripts/e2e/init-project-e2e.js` publishes in-repo packages to the local Verdaccio proxy with `npm publish`, but omits `--tag`. **npm ≥ 11** (bundled with Node 24+) refuses to publish a prerelease version (e.g. `0.87.0-rc.0`) without an explicit tag: ``` npm error You must specify a tag using --tag when publishing a prerelease version. ``` This breaks `yarn test-release-local` immediately at the publish step on any machine using npm ≥ 11. ## Fix Pass an explicit `--tag react-native-e2e`. This is a throwaway local registry and the install step pins the **exact** version, so the dist-tag value is not significant for resolution. ## Changelog [Internal] - Fix `test-release-local` publishing on npm ≥ 11 Pull Request resolved: https://github.com/react/react-native/pull/57552 Test Plan: `yarn test-release-local -t RNTestProject -p iOS` now gets past the publish step on npm 11 (previously failed immediately at `npm publish`). Reviewed By: christophpurrer Differential Revision: D111995913 Pulled By: zeyap fbshipit-source-id: 107a96d5c4317129228f783ad52bffe1578d1138 --- scripts/e2e/init-project-e2e.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/init-project-e2e.js b/scripts/e2e/init-project-e2e.js index 30db3b9c33d2..2415e52993cd 100644 --- a/scripts/e2e/init-project-e2e.js +++ b/scripts/e2e/init-project-e2e.js @@ -122,7 +122,10 @@ async function initNewProjectFromSource( `${desc} ${styleText('dim', '.').repeat(Math.max(0, 72 - desc.length))} `, ); execSync( - `npm publish --registry ${VERDACCIO_SERVER_URL} --access public`, + // `--tag` is required by npm >= 11 when publishing a prerelease + // version. This is a throwaway local registry and the install step + // pins the exact version, so the dist-tag value is not significant. + `npm publish --registry ${VERDACCIO_SERVER_URL} --access public --tag react-native-e2e`, { cwd: packagePath, stdio: verbose ? 'inherit' : [process.stderr], From 020512bf1581136854ff9311e0c58c044e73332c Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 15 Jul 2026 03:50:39 -0700 Subject: [PATCH 063/834] Add CDP support for WebSocket events (iOS) (#57543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/react-native/pull/57543 **Context** This stack implements WebSocket event debugging for the Network panel in React Native DevTools. Discussed with Expo earlier this year, we want upstream parity before Expo adopts the 1P Network panel. This is a comparatively small surface: 6 CDP events, no CDP methods. **Minimum goal**: Parity with Expo's current WS event coverage. We improve on this with Request Initiator support in D111561995. **This diff** Adds first-party reporting for six of the seven WebSocket CDP events (`webSocketCreated`, `webSocketWillSendHandshakeRequest`, `webSocketHandshakeResponseReceived`, `webSocketFrameSent`, `webSocketFrameReceived`, `webSocketClosed`), mirroring the layering of the existing HTTP pipeline: - **C++ core**: WebSocket CDP types and reporting APIs through `CdpNetwork` → `NetworkHandler` → `NetworkReporter`. Compiled to no-ops in production builds; inert unless the Network domain is enabled. - **iOS**: A new `RCTInspectorWebSocketReporter` bridges `RCTWebSocketModule` to `NetworkReporter`, reporting connection lifecycle, real handshake headers, and sent/received messages. Android follows separately. - **Gating**: A new `fuseboxWebSocketEventsEnabled` feature flag (experimentation, default off), checked alongside `enableNetworkEventReporting`. **Notes** - **Omitted**: [`Network.webSocketFrameError`](https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketFrameError). Neither SocketRocket nor OkHttp exposes a frame-scoped error; connection failures are terminal and already reported via `webSocketClosed`. Cheap to bolt on once a genuine per-message error source exists (e.g. OkHttp `send()` returning false). - **Omitted**: Optional [`WebSocketResponse`](https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-WebSocketResponse) fields (`headersText`, `requestHeaders`, `requestHeadersText`) — we report structured headers only; raw handshake text isn't available from OkHttp on Android. - No `performance-timeline` integration — WebSocket events report to CDP only; unlike HTTP, there is no `PerformanceResourceTiming` counterpart. **Rollout plan** (New `enableNetworkEventReporting` flag) - 0.88 - Canary channel - 0.89 - Stable channel Changelog: [Internal] Reviewed By: GijsWeterings, javache Differential Revision: D111561998 fbshipit-source-id: ecd46ddb6baa91f1c1d77598ee62b2e410b45bd9 --- .../RCTInspectorWebSocketReporter.h | 76 +++++++++ .../RCTInspectorWebSocketReporter.mm | 156 ++++++++++++++++++ .../React/CoreModules/RCTWebSocketModule.mm | 39 ++++- .../CoreModules/React-CoreModules.podspec | 1 + .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../jsinspector-modern/network/CdpNetwork.cpp | 81 +++++++++ .../jsinspector-modern/network/CdpNetwork.h | 93 +++++++++++ .../network/NetworkHandler.cpp | 135 +++++++++++++++ .../network/NetworkHandler.h | 38 +++++ .../tests/NetworkReporterTest.cpp | 120 ++++++++++++++ .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 72 +++++--- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../react/networking/NetworkReporter.cpp | 62 +++++++ .../react/networking/NetworkReporter.h | 60 +++++++ .../ReactNativeFeatureFlags.config.js | 11 ++ .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- .../api-snapshots/ReactAndroidDebugCxx.api | 62 +++++++ .../api-snapshots/ReactAndroidNewarchCxx.api | 62 +++++++ .../api-snapshots/ReactAndroidReleaseCxx.api | 62 +++++++ .../api-snapshots/ReactAppleDebugCxx.api | 71 ++++++++ .../api-snapshots/ReactAppleNewarchCxx.api | 71 ++++++++ .../api-snapshots/ReactAppleReleaseCxx.api | 71 ++++++++ .../api-snapshots/ReactCommonDebugCxx.api | 62 +++++++ .../api-snapshots/ReactCommonNewarchCxx.api | 62 +++++++ .../api-snapshots/ReactCommonReleaseCxx.api | 62 +++++++ 40 files changed, 1607 insertions(+), 48 deletions(-) create mode 100644 packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.h create mode 100644 packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.mm diff --git a/packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.h b/packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.h new file mode 100644 index 000000000000..474085f859bb --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.h @@ -0,0 +1,76 @@ +/* + * 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. + */ + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * [Experimental] An interface for reporting WebSocket events to the modern + * debugger server. + * + * In a production (non dev or profiling) build, CDP reporting is disabled + * and all methods are a no-op. + * + * This is a helper class wrapping `facebook::react::NetworkReporter`. + */ +@interface RCTInspectorWebSocketReporter : NSObject + +/** + * Report that a WebSocket connection is about to be created. + * + * Corresponds to `Network.webSocketCreated` in CDP. + */ ++ (void)reportWebSocketCreated:(nullable NSString *)requestId url:(NSURL *)url; + +/** + * Report that a WebSocket handshake (HTTP upgrade) request is about to be + * sent, along with its final request headers. + * + * Corresponds to `Network.webSocketWillSendHandshakeRequest` in CDP. + */ ++ (void)reportWillSendHandshakeRequest:(nullable NSString *)requestId request:(NSURLRequest *)request; + +/** + * Report that the WebSocket handshake response was received and the + * connection is established. `httpMessage` is the raw handshake response, + * e.g. `SRWebSocket.receivedHTTPHeaders`. If NULL or incomplete, a minimal + * `101 Switching Protocols` response is reported instead. + * + * Corresponds to `Network.webSocketHandshakeResponseReceived` in CDP. + */ ++ (void)reportHandshakeResponseReceived:(nullable NSString *)requestId + httpMessage:(nullable CFHTTPMessageRef)httpMessage; + +/** + * Report a WebSocket message sent over an open connection. `message` must be + * an `NSString` (text message) or `NSData` (binary message). + * + * Corresponds to `Network.webSocketFrameSent` in CDP. + */ ++ (void)reportMessageSent:(nullable NSString *)requestId message:(nullable id)message; + +/** + * Report a WebSocket message received over an open connection. `message` + * must be an `NSString` (text message) or `NSData` (binary message). + * + * Corresponds to `Network.webSocketFrameReceived` in CDP. + */ ++ (void)reportMessageReceived:(nullable NSString *)requestId message:(nullable id)message; + +/** + * Report that a WebSocket connection was closed, whether cleanly or due to + * an error. + * + * Corresponds to `Network.webSocketClosed` in CDP. + */ ++ (void)reportWebSocketClosed:(nullable NSString *)requestId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.mm b/packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.mm new file mode 100644 index 000000000000..511032ab25fb --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTInspectorWebSocketReporter.mm @@ -0,0 +1,156 @@ +/* + * 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. + */ + +#import "RCTInspectorWebSocketReporter.h" + +#import +#import + +using facebook::react::Headers; +using facebook::react::NetworkReporter; +using facebook::react::ReactNativeFeatureFlags; + +namespace { + +/** + * Convert an `NSString` to a `std::string`, mapping `nil` (and any string whose + * UTF-8 representation is unavailable) to an empty string. + */ +std::string toStdString(NSString *string) +{ + const char *utf8 = string.UTF8String; + return utf8 != nullptr ? std::string(utf8) : std::string(); +} + +/** + * Returns whether WebSocket events should be reported for the given request + * ID. Reporting requires the `enableNetworkEventReporting` and + * `fuseboxWebSocketEventsEnabled` feature flags, and a connected CDP debugger + * with the Network domain enabled (dev or profiling builds only). + */ +BOOL isReportingEnabled(NSString *requestId) +{ + return requestId != nil && ReactNativeFeatureFlags::enableNetworkEventReporting() && + ReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled() && NetworkReporter::getInstance().isDebuggingEnabled(); +} + +Headers convertNSDictionaryToHeaders(const NSDictionary *headers) +{ + Headers result; + for (NSString *key in headers) { + result[toStdString(key)] = toStdString(headers[key]); + } + return result; +} + +/** + * Convert an `NSString` (text) or `NSData` (binary) WebSocket message to a + * CDP `payloadData` string — UTF-8 for text messages, base64 for binary. + * \returns Whether the message was a supported type, writing to the out + * params on success. + */ +bool convertMessageToPayloadData(id message, std::string &payloadData, bool &isBinary) +{ + if ([message isKindOfClass:[NSString class]]) { + payloadData = toStdString((NSString *)message); + isBinary = false; + return true; + } + + if ([message isKindOfClass:[NSData class]]) { + payloadData = toStdString([(NSData *)message base64EncodedStringWithOptions:0]); + isBinary = true; + return true; + } + + return false; +} + +} // namespace + +@implementation RCTInspectorWebSocketReporter + ++ (void)reportWebSocketCreated:(NSString *)requestId url:(NSURL *)url +{ + if (!isReportingEnabled(requestId)) { + return; + } + + NetworkReporter::getInstance().reportWebSocketCreated(toStdString(requestId), toStdString(url.absoluteString)); +} + ++ (void)reportWillSendHandshakeRequest:(NSString *)requestId request:(NSURLRequest *)request +{ + if (!isReportingEnabled(requestId)) { + return; + } + + NetworkReporter::getInstance().reportWebSocketWillSendHandshakeRequest( + toStdString(requestId), convertNSDictionaryToHeaders(request.allHTTPHeaderFields)); +} + ++ (void)reportHandshakeResponseReceived:(NSString *)requestId httpMessage:(nullable CFHTTPMessageRef)httpMessage +{ + if (!isReportingEnabled(requestId)) { + return; + } + + // Fall back to a minimal `101 Switching Protocols` response (guaranteed by + // RFC 6455 for an open connection) if the handshake response is unavailable. + uint16_t statusCode = 101; + Headers headers; + + if (httpMessage != NULL && CFHTTPMessageIsHeaderComplete(httpMessage) != 0) { + statusCode = (uint16_t)CFHTTPMessageGetResponseStatusCode(httpMessage); + NSDictionary *responseHeaders = + (NSDictionary *)CFBridgingRelease(CFHTTPMessageCopyAllHeaderFields(httpMessage)); + headers = convertNSDictionaryToHeaders(responseHeaders); + } + + NetworkReporter::getInstance().reportWebSocketHandshakeResponseReceived(toStdString(requestId), statusCode, headers); +} + ++ (void)reportMessageSent:(NSString *)requestId message:(id)message +{ + if (!isReportingEnabled(requestId)) { + return; + } + + std::string payloadData; + bool isBinary = false; + if (!convertMessageToPayloadData(message, payloadData, isBinary)) { + return; + } + + NetworkReporter::getInstance().reportWebSocketMessageSent(toStdString(requestId), payloadData, isBinary); +} + ++ (void)reportMessageReceived:(NSString *)requestId message:(id)message +{ + if (!isReportingEnabled(requestId)) { + return; + } + + std::string payloadData; + bool isBinary = false; + if (!convertMessageToPayloadData(message, payloadData, isBinary)) { + return; + } + + NetworkReporter::getInstance().reportWebSocketMessageReceived(toStdString(requestId), payloadData, isBinary); +} + ++ (void)reportWebSocketClosed:(NSString *)requestId +{ + if (!isReportingEnabled(requestId)) { + return; + } + + NetworkReporter::getInstance().reportWebSocketClosed(toStdString(requestId)); +} + +@end diff --git a/packages/react-native/React/CoreModules/RCTWebSocketModule.mm b/packages/react-native/React/CoreModules/RCTWebSocketModule.mm index bd95c32accfc..175a5b2eb2b8 100644 --- a/packages/react-native/React/CoreModules/RCTWebSocketModule.mm +++ b/packages/react-native/React/CoreModules/RCTWebSocketModule.mm @@ -16,6 +16,17 @@ #import #import "CoreModulesPlugins.h" +#import "RCTInspectorWebSocketReporter.h" + +@interface SRWebSocket (React) + +/** + * The CDP request ID used to report this connection's events to the modern + * debugger server, via `RCTInspectorWebSocketReporter`. + */ +@property (nonatomic, copy) NSString *inspectorRequestId; + +@end @implementation SRWebSocket (React) @@ -29,6 +40,16 @@ - (void)setReactTag:(NSNumber *)reactTag objc_setAssociatedObject(self, @selector(reactTag), reactTag, OBJC_ASSOCIATION_COPY_NONATOMIC); } +- (NSString *)inspectorRequestId +{ + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setInspectorRequestId:(NSString *)inspectorRequestId +{ + objc_setAssociatedObject(self, @selector(inspectorRequestId), inspectorRequestId, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + @end @interface RCTWebSocketModule () @@ -131,16 +152,23 @@ - (void)invalidate [webSocket setDelegateDispatchQueue:[self methodQueue]]; webSocket.delegate = self; webSocket.reactTag = @(socketID); + webSocket.inspectorRequestId = [[NSUUID UUID] UUIDString]; if (!_sockets) { _sockets = [NSMutableDictionary new]; } _sockets[@(socketID)] = webSocket; + + [RCTInspectorWebSocketReporter reportWebSocketCreated:webSocket.inspectorRequestId url:URL]; + [RCTInspectorWebSocketReporter reportWillSendHandshakeRequest:webSocket.inspectorRequestId request:request]; + [webSocket open]; } RCT_EXPORT_METHOD(send : (NSString *)message forSocketID : (double)socketID) { - [_sockets[@(socketID)] sendString:message error:nil]; + SRWebSocket *webSocket = _sockets[@(socketID)]; + [RCTInspectorWebSocketReporter reportMessageSent:webSocket.inspectorRequestId message:message]; + [webSocket sendString:message error:nil]; } RCT_EXPORT_METHOD(sendBinary : (NSString *)base64String forSocketID : (double)socketID) @@ -150,7 +178,9 @@ - (void)invalidate - (void)sendData:(NSData *)data forSocketID:(NSNumber *__nonnull)socketID { - [_sockets[socketID] sendData:data error:nil]; + SRWebSocket *webSocket = _sockets[socketID]; + [RCTInspectorWebSocketReporter reportMessageSent:webSocket.inspectorRequestId message:data]; + [webSocket sendData:data error:nil]; } RCT_EXPORT_METHOD(ping : (double)socketID) @@ -182,6 +212,7 @@ - (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message if (!socketID) { return; } + [RCTInspectorWebSocketReporter reportMessageReceived:webSocket.inspectorRequestId message:message]; id contentHandler = _contentHandlers[socketID]; if (contentHandler) { message = [contentHandler processWebsocketMessage:message forSocketID:socketID withType:&type]; @@ -203,6 +234,8 @@ - (void)webSocketDidOpen:(SRWebSocket *)webSocket if (!socketID) { return; } + [RCTInspectorWebSocketReporter reportHandshakeResponseReceived:webSocket.inspectorRequestId + httpMessage:webSocket.receivedHTTPHeaders]; [self sendEventWithName:@"websocketOpen" body:@{@"id" : socketID, @"protocol" : webSocket.protocol ? webSocket.protocol : @""}]; } @@ -213,6 +246,7 @@ - (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error if (!socketID) { return; } + [RCTInspectorWebSocketReporter reportWebSocketClosed:webSocket.inspectorRequestId]; _contentHandlers[socketID] = nil; _sockets[socketID] = nil; NSDictionary *body = @@ -229,6 +263,7 @@ - (void)webSocket:(SRWebSocket *)webSocket if (!socketID) { return; } + [RCTInspectorWebSocketReporter reportWebSocketClosed:webSocket.inspectorRequestId]; _contentHandlers[socketID] = nil; _sockets[socketID] = nil; [self sendEventWithName:@"websocketClosed" diff --git a/packages/react-native/React/CoreModules/React-CoreModules.podspec b/packages/react-native/React/CoreModules/React-CoreModules.podspec index f98c195770c6..2d8b9aa21d12 100644 --- a/packages/react-native/React/CoreModules/React-CoreModules.podspec +++ b/packages/react-native/React/CoreModules/React-CoreModules.podspec @@ -58,6 +58,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern') add_dependency(s, "React-jsinspectorcdp", :framework_name => 'jsinspector_moderncdp') add_dependency(s, "React-jsinspectortracing", :framework_name => 'jsinspector_moderntracing') + add_dependency(s, "React-networking", :framework_name => 'React_networking') add_dependency(s, "React-RCTFBReactNativeSpec") add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 04d0b80b3c64..f028b7e4f7da 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -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<<854bb2a573f2c69d495aa1721f903ab0>> + * @generated SignedSource<> */ /** @@ -390,6 +390,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun fuseboxScreenshotCaptureEnabled(): Boolean = accessor.fuseboxScreenshotCaptureEnabled() + /** + * Enable reporting of WebSocket network events (`Network.webSocket*` CDP events) to the React Native DevTools CDP backend. Requires `fuseboxNetworkInspectionEnabled`. + */ + @JvmStatic + public fun fuseboxWebSocketEventsEnabled(): Boolean = accessor.fuseboxWebSocketEventsEnabled() + /** * When enabled, uses optimized platform-specific paths to apply animated props synchronously. On Android, this uses a batched int/double buffer protocol with a single JNI call. On iOS, this passes AnimatedProps directly through the delegate chain and applies them via cloneProps, avoiding the folly::dynamic round-trip. */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 624e509c5034..0e359509c73c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -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<<5ae6b1a0bfa9d680aa61685dbacd69d2>> + * @generated SignedSource<> */ /** @@ -80,6 +80,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var fuseboxFrameRecordingEnabledCache: Boolean? = null private var fuseboxNetworkInspectionEnabledCache: Boolean? = null private var fuseboxScreenshotCaptureEnabledCache: Boolean? = null + private var fuseboxWebSocketEventsEnabledCache: Boolean? = null private var optimizedAnimatedPropUpdatesCache: Boolean? = null private var overrideBySynchronousMountPropsAtMountingAndroidCache: Boolean? = null private var perfIssuesEnabledCache: Boolean? = null @@ -647,6 +648,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun fuseboxWebSocketEventsEnabled(): Boolean { + var cached = fuseboxWebSocketEventsEnabledCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.fuseboxWebSocketEventsEnabled() + fuseboxWebSocketEventsEnabledCache = cached + } + return cached + } + override fun optimizedAnimatedPropUpdates(): Boolean { var cached = optimizedAnimatedPropUpdatesCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index 2f1a0cfc504d..dd081be14a65 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -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<<8b1c3206f7e63790e411f57c2dd64076>> + * @generated SignedSource<<87b76f5c2af82462dc66c7721cd66ac4>> */ /** @@ -148,6 +148,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun fuseboxScreenshotCaptureEnabled(): Boolean + @DoNotStrip @JvmStatic public external fun fuseboxWebSocketEventsEnabled(): Boolean + @DoNotStrip @JvmStatic public external fun optimizedAnimatedPropUpdates(): Boolean @DoNotStrip @JvmStatic public external fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index 91f9ac37c7ec..7b9062eb3ca9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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<<9b295106ef57d7fa85846c57bb74a090>> + * @generated SignedSource<> */ /** @@ -143,6 +143,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun fuseboxScreenshotCaptureEnabled(): Boolean = false + override fun fuseboxWebSocketEventsEnabled(): Boolean = false + override fun optimizedAnimatedPropUpdates(): Boolean = false override fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean = true diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index b274311cd9d2..c5786d397193 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -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<<98a6085638c86aaa012c8bd972627ad4>> + * @generated SignedSource<<9a2806b3934d130010954a92a7b48e0f>> */ /** @@ -84,6 +84,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var fuseboxFrameRecordingEnabledCache: Boolean? = null private var fuseboxNetworkInspectionEnabledCache: Boolean? = null private var fuseboxScreenshotCaptureEnabledCache: Boolean? = null + private var fuseboxWebSocketEventsEnabledCache: Boolean? = null private var optimizedAnimatedPropUpdatesCache: Boolean? = null private var overrideBySynchronousMountPropsAtMountingAndroidCache: Boolean? = null private var perfIssuesEnabledCache: Boolean? = null @@ -711,6 +712,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun fuseboxWebSocketEventsEnabled(): Boolean { + var cached = fuseboxWebSocketEventsEnabledCache + if (cached == null) { + cached = currentProvider.fuseboxWebSocketEventsEnabled() + accessedFeatureFlags.add("fuseboxWebSocketEventsEnabled") + fuseboxWebSocketEventsEnabledCache = cached + } + return cached + } + override fun optimizedAnimatedPropUpdates(): Boolean { var cached = optimizedAnimatedPropUpdatesCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index c611198dac44..2a6da8129f50 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -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<<8b5a9f8966d9f096b7fa4f7020db1f34>> + * @generated SignedSource<<49c76b8072c1bd29135086c9e0a5da2f>> */ /** @@ -143,6 +143,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun fuseboxScreenshotCaptureEnabled(): Boolean + @DoNotStrip public fun fuseboxWebSocketEventsEnabled(): Boolean + @DoNotStrip public fun optimizedAnimatedPropUpdates(): Boolean @DoNotStrip public fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index 09be85cdc306..349c5ad6a541 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -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<<42f1901c8b7cb568cc761a791991a333>> */ /** @@ -399,6 +399,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool fuseboxWebSocketEventsEnabled() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("fuseboxWebSocketEventsEnabled"); + return method(javaProvider_); + } + bool optimizedAnimatedPropUpdates() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("optimizedAnimatedPropUpdates"); @@ -859,6 +865,11 @@ bool JReactNativeFeatureFlagsCxxInterop::fuseboxScreenshotCaptureEnabled( return ReactNativeFeatureFlags::fuseboxScreenshotCaptureEnabled(); } +bool JReactNativeFeatureFlagsCxxInterop::fuseboxWebSocketEventsEnabled( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled(); +} + bool JReactNativeFeatureFlagsCxxInterop::optimizedAnimatedPropUpdates( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::optimizedAnimatedPropUpdates(); @@ -1200,6 +1211,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "fuseboxScreenshotCaptureEnabled", JReactNativeFeatureFlagsCxxInterop::fuseboxScreenshotCaptureEnabled), + makeNativeMethod( + "fuseboxWebSocketEventsEnabled", + JReactNativeFeatureFlagsCxxInterop::fuseboxWebSocketEventsEnabled), makeNativeMethod( "optimizedAnimatedPropUpdates", JReactNativeFeatureFlagsCxxInterop::optimizedAnimatedPropUpdates), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index 2de55550ee3a..6ffc3146e658 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -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<<9967d87b794ba46797ed78a845683dc7>> */ /** @@ -210,6 +210,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool fuseboxScreenshotCaptureEnabled( facebook::jni::alias_ref); + static bool fuseboxWebSocketEventsEnabled( + facebook::jni::alias_ref); + static bool optimizedAnimatedPropUpdates( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.cpp b/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.cpp index 916103327b3a..77dbe081848c 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.cpp @@ -141,6 +141,87 @@ folly::dynamic LoadingFinishedParams::toDynamic() const { return params; } +/* static */ WebSocketResponse WebSocketResponse::fromInputParams( + uint16_t status, + const Headers& headers) { + return { + .status = status, + .statusText = httpReasonPhrase(status), + .headers = headers, + }; +} + +folly::dynamic WebSocketResponse::toDynamic() const { + folly::dynamic result = folly::dynamic::object; + + result["status"] = status; + result["statusText"] = statusText; + result["headers"] = headersToDynamic(headers); + + return result; +} + +folly::dynamic WebSocketFrame::toDynamic() const { + folly::dynamic result = folly::dynamic::object; + + result["opcode"] = opcode; + result["mask"] = mask; + result["payloadData"] = payloadData; + + return result; +} + +folly::dynamic WebSocketCreatedParams::toDynamic() const { + folly::dynamic params = folly::dynamic::object; + + params["requestId"] = requestId; + params["url"] = url; + params["initiator"] = initiator; + + return params; +} + +folly::dynamic WebSocketWillSendHandshakeRequestParams::toDynamic() const { + folly::dynamic params = folly::dynamic::object; + + params["requestId"] = requestId; + params["timestamp"] = timestamp; + params["wallTime"] = wallTime; + params["request"] = + folly::dynamic::object("headers", headersToDynamic(headers)); + + return params; +} + +folly::dynamic WebSocketHandshakeResponseReceivedParams::toDynamic() const { + folly::dynamic params = folly::dynamic::object; + + params["requestId"] = requestId; + params["timestamp"] = timestamp; + params["response"] = response.toDynamic(); + + return params; +} + +folly::dynamic WebSocketFrameParams::toDynamic() const { + folly::dynamic params = folly::dynamic::object; + + params["requestId"] = requestId; + params["timestamp"] = timestamp; + params["response"] = response.toDynamic(); + + return params; +} + +folly::dynamic WebSocketClosedParams::toDynamic() const { + folly::dynamic params = folly::dynamic::object; + + params["requestId"] = requestId; + params["timestamp"] = timestamp; + + return params; +} + std::string resourceTypeFromMimeType(const std::string& mimeType) { if (mimeType.find("image/") == 0) { return "Image"; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.h b/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.h index 2246057ac46a..e017d3a10d6f 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/CdpNetwork.h @@ -136,6 +136,99 @@ struct LoadingFinishedParams { folly::dynamic toDynamic() const; }; +/** + * HTTP response data for a WebSocket handshake (upgrade) request. + * + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-WebSocketResponse + */ +struct WebSocketResponse { + uint16_t status; + std::string statusText; + Headers headers; + + /** + * Convenience function to construct a `WebSocketResponse` from generic + * input params, deriving the status text. + */ + static WebSocketResponse fromInputParams(uint16_t status, const Headers &headers); + + folly::dynamic toDynamic() const; +}; + +/** + * A WebSocket message payload. NOTE: Despite the CDP type name, this + * represents a complete WebSocket message, not a wire-level frame. + * + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-WebSocketFrame + */ +struct WebSocketFrame { + int opcode; + bool mask; + std::string payloadData; + + folly::dynamic toDynamic() const; +}; + +/** + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketCreated + */ +struct WebSocketCreatedParams { + std::string requestId; + std::string url; + folly::dynamic initiator; + + folly::dynamic toDynamic() const; +}; + +/** + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketWillSendHandshakeRequest + */ +struct WebSocketWillSendHandshakeRequestParams { + std::string requestId; + double timestamp; + double wallTime; + /** Serialized as the `headers` field of the CDP `WebSocketRequest` type. */ + Headers headers; + + folly::dynamic toDynamic() const; +}; + +/** + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketHandshakeResponseReceived + */ +struct WebSocketHandshakeResponseReceivedParams { + std::string requestId; + double timestamp; + WebSocketResponse response; + + folly::dynamic toDynamic() const; +}; + +/** + * Shared params type for the `Network.webSocketFrameSent` and + * `Network.webSocketFrameReceived` events. + * + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketFrameSent + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketFrameReceived + */ +struct WebSocketFrameParams { + std::string requestId; + double timestamp; + WebSocketFrame response; + + folly::dynamic toDynamic() const; +}; + +/** + * https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketClosed + */ +struct WebSocketClosedParams { + std::string requestId; + double timestamp; + + folly::dynamic toDynamic() const; +}; + /** * Get the CDP `ResourceType` for a given MIME type. * diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.cpp b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.cpp index 9dbe9bfbaa8a..632a8ad25c01 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.cpp @@ -16,6 +16,12 @@ namespace facebook::react::jsinspector_modern { namespace { +/** + * WebSocket frame opcodes for data messages (RFC 6455). + */ +constexpr int kWebSocketOpcodeText = 1; +constexpr int kWebSocketOpcodeBinary = 2; + /** * Get the current Unix timestamp in seconds (µs precision, CDP format). */ @@ -200,6 +206,135 @@ void NetworkHandler::onLoadingFailed( } } +void NetworkHandler::onWebSocketCreated( + const std::string& requestId, + const std::string& url) { + if (!isEnabledNoSync()) { + return; + } + + std::optional initiator = + consumeStoredRequestInitiator(requestId); + auto params = cdp::network::WebSocketCreatedParams{ + .requestId = requestId, + .url = url, + .initiator = initiator.has_value() + ? std::move(initiator.value()) + : folly::dynamic::object("type", "script"), + }; + + sendToAllAgents( + cdp::jsonNotification("Network.webSocketCreated", params.toDynamic())); +} + +void NetworkHandler::onWebSocketWillSendHandshakeRequest( + const std::string& requestId, + const Headers& headers) { + if (!isEnabledNoSync()) { + return; + } + + double timestamp = getCurrentUnixTimestampSeconds(); + auto params = cdp::network::WebSocketWillSendHandshakeRequestParams{ + .requestId = requestId, + // NOTE: Both timestamp and wallTime use the same unit, however wallTime + // is relative to an "arbitrary epoch". In our implementation, use the + // Unix epoch for both. + .timestamp = timestamp, + .wallTime = timestamp, + .headers = headers, + }; + + sendToAllAgents( + cdp::jsonNotification( + "Network.webSocketWillSendHandshakeRequest", params.toDynamic())); +} + +void NetworkHandler::onWebSocketHandshakeResponseReceived( + const std::string& requestId, + uint16_t status, + const Headers& headers) { + if (!isEnabledNoSync()) { + return; + } + + auto params = cdp::network::WebSocketHandshakeResponseReceivedParams{ + .requestId = requestId, + .timestamp = getCurrentUnixTimestampSeconds(), + .response = + cdp::network::WebSocketResponse::fromInputParams(status, headers), + }; + + sendToAllAgents( + cdp::jsonNotification( + "Network.webSocketHandshakeResponseReceived", params.toDynamic())); +} + +void NetworkHandler::onWebSocketFrameSent( + const std::string& requestId, + const std::string& payloadData, + bool isBinary) { + if (!isEnabledNoSync()) { + return; + } + + auto params = cdp::network::WebSocketFrameParams{ + .requestId = requestId, + .timestamp = getCurrentUnixTimestampSeconds(), + .response = + { + .opcode = + isBinary ? kWebSocketOpcodeBinary : kWebSocketOpcodeText, + // Client-to-server messages are always masked (RFC 6455). + .mask = true, + .payloadData = payloadData, + }, + }; + + sendToAllAgents( + cdp::jsonNotification("Network.webSocketFrameSent", params.toDynamic())); +} + +void NetworkHandler::onWebSocketFrameReceived( + const std::string& requestId, + const std::string& payloadData, + bool isBinary) { + if (!isEnabledNoSync()) { + return; + } + + auto params = cdp::network::WebSocketFrameParams{ + .requestId = requestId, + .timestamp = getCurrentUnixTimestampSeconds(), + .response = + { + .opcode = + isBinary ? kWebSocketOpcodeBinary : kWebSocketOpcodeText, + // Server-to-client messages are never masked (RFC 6455). + .mask = false, + .payloadData = payloadData, + }, + }; + + sendToAllAgents( + cdp::jsonNotification( + "Network.webSocketFrameReceived", params.toDynamic())); +} + +void NetworkHandler::onWebSocketClosed(const std::string& requestId) { + if (!isEnabledNoSync()) { + return; + } + + auto params = cdp::network::WebSocketClosedParams{ + .requestId = requestId, + .timestamp = getCurrentUnixTimestampSeconds(), + }; + + sendToAllAgents( + cdp::jsonNotification("Network.webSocketClosed", params.toDynamic())); +} + void NetworkHandler::storeResponseBody( const std::string& requestId, std::string_view body, diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.h b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.h index aaa43aad2b47..b71890f495fc 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkHandler.h @@ -90,6 +90,44 @@ class NetworkHandler { */ void onLoadingFailed(const std::string &requestId, bool cancelled); + /** + * @cdp Network.webSocketCreated + */ + void onWebSocketCreated(const std::string &requestId, const std::string &url); + + /** + * @cdp Network.webSocketWillSendHandshakeRequest + */ + void onWebSocketWillSendHandshakeRequest(const std::string &requestId, const Headers &headers); + + /** + * @cdp Network.webSocketHandshakeResponseReceived + */ + void onWebSocketHandshakeResponseReceived(const std::string &requestId, uint16_t status, const Headers &headers); + + /** + * @cdp Network.webSocketFrameSent + * + * \param payloadData The message payload. A UTF-8 string for text messages, + * or a base64-encoded string for binary messages. + * \param isBinary Whether this is a binary message. + */ + void onWebSocketFrameSent(const std::string &requestId, const std::string &payloadData, bool isBinary); + + /** + * @cdp Network.webSocketFrameReceived + * + * \param payloadData The message payload. A UTF-8 string for text messages, + * or a base64-encoded string for binary messages. + * \param isBinary Whether this is a binary message. + */ + void onWebSocketFrameReceived(const std::string &requestId, const std::string &payloadData, bool isBinary); + + /** + * @cdp Network.webSocketClosed + */ + void onWebSocketClosed(const std::string &requestId); + /** * Store the fetched response body for a text or image network response. * diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp index 678a8c164533..f18935dba169 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp @@ -464,6 +464,114 @@ TEST_P(NetworkReporterTest, testGetResponseBodyWithBase64) { })"); } +TEST_P(NetworkReporterTest, testCompleteWebSocketFlow) { + InSequence s; + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.enable" + })"); + + const std::string requestId = "websocket-flow-request"; + + // Step 1: WebSocket created + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketCreated"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/url", "wss://echo.example.com/socket"), + AtJsonPtr("/params/initiator/type", "script")))); + + NetworkReporter::getInstance().reportWebSocketCreated( + requestId, "wss://echo.example.com/socket"); + + // Step 2: Handshake request will be sent + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketWillSendHandshakeRequest"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/timestamp", Gt(0)), + AtJsonPtr("/params/wallTime", Gt(0)), + AtJsonPtr("/params/request/headers/Origin", "https://example.com"), + AtJsonPtr( + "/params/request/headers/Sec-WebSocket-Protocol", "graphql-ws")))); + + NetworkReporter::getInstance().reportWebSocketWillSendHandshakeRequest( + requestId, + Headers{ + {"Origin", "https://example.com"}, + {"Sec-WebSocket-Protocol", "graphql-ws"}}); + + // Step 3: Handshake response received + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketHandshakeResponseReceived"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/timestamp", Gt(0)), + AtJsonPtr("/params/response/status", 101), + AtJsonPtr("/params/response/statusText", "Switching Protocols"), + AtJsonPtr("/params/response/headers/Upgrade", "websocket"), + AtJsonPtr("/params/response/headers/Connection", "Upgrade")))); + + NetworkReporter::getInstance().reportWebSocketHandshakeResponseReceived( + requestId, + 101, + Headers{{"Upgrade", "websocket"}, {"Connection", "Upgrade"}}); + + // Step 4: Text message sent + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketFrameSent"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/timestamp", Gt(0)), + AtJsonPtr("/params/response/opcode", 1), + AtJsonPtr("/params/response/mask", true), + AtJsonPtr("/params/response/payloadData", R"({"type": "ping"})")))); + + NetworkReporter::getInstance().reportWebSocketMessageSent( + requestId, R"({"type": "ping"})", false); + + // Step 5: Binary message sent (payload is base64-encoded) + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketFrameSent"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/timestamp", Gt(0)), + AtJsonPtr("/params/response/opcode", 2), + AtJsonPtr("/params/response/mask", true), + AtJsonPtr("/params/response/payloadData", "aGVsbG8=")))); + + NetworkReporter::getInstance().reportWebSocketMessageSent( + requestId, "aGVsbG8=", true); + + // Step 6: Text message received + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketFrameReceived"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/timestamp", Gt(0)), + AtJsonPtr("/params/response/opcode", 1), + AtJsonPtr("/params/response/mask", false), + AtJsonPtr("/params/response/payloadData", R"({"type": "pong"})")))); + + NetworkReporter::getInstance().reportWebSocketMessageReceived( + requestId, R"({"type": "pong"})", false); + + // Step 7: WebSocket closed + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketClosed"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/timestamp", Gt(0))))); + + NetworkReporter::getInstance().reportWebSocketClosed(requestId); + + this->expectMessageFromPage(JsonEq(R"({ + "id": 2, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 2, + "method": "Network.disable" + })"); +} + TEST_P(NetworkReporterTest, testNetworkEventsWhenDisabled) { EXPECT_FALSE(NetworkReporter::getInstance().isDebuggingEnabled()); @@ -488,6 +596,18 @@ TEST_P(NetworkReporterTest, testNetworkEventsWhenDisabled) { "disabled-request", 512, 512); NetworkReporter::getInstance().reportResponseEnd("disabled-request", 1024); NetworkReporter::getInstance().reportRequestFailed("disabled-request", false); + + NetworkReporter::getInstance().reportWebSocketCreated( + "disabled-websocket", "wss://example.com/disabled"); + NetworkReporter::getInstance().reportWebSocketWillSendHandshakeRequest( + "disabled-websocket", {}); + NetworkReporter::getInstance().reportWebSocketHandshakeResponseReceived( + "disabled-websocket", 101, {}); + NetworkReporter::getInstance().reportWebSocketMessageSent( + "disabled-websocket", "hello", false); + NetworkReporter::getInstance().reportWebSocketMessageReceived( + "disabled-websocket", "world", false); + NetworkReporter::getInstance().reportWebSocketClosed("disabled-websocket"); } TEST_P(NetworkReporterTest, testRequestWillBeSentWithInitiator) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index 8e65a9654b44..31d61b5cb247 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -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<<6f2362e9844cab731ca01e4af563fde9>> */ /** @@ -266,6 +266,10 @@ bool ReactNativeFeatureFlags::fuseboxScreenshotCaptureEnabled() { return getAccessor().fuseboxScreenshotCaptureEnabled(); } +bool ReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled() { + return getAccessor().fuseboxWebSocketEventsEnabled(); +} + bool ReactNativeFeatureFlags::optimizedAnimatedPropUpdates() { return getAccessor().optimizedAnimatedPropUpdates(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index a2ca86c275a7..344997745dac 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -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<<4276ae19d55acb679c9e635122e813b3>> + * @generated SignedSource<> */ /** @@ -339,6 +339,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool fuseboxScreenshotCaptureEnabled(); + /** + * Enable reporting of WebSocket network events (`Network.webSocket*` CDP events) to the React Native DevTools CDP backend. Requires `fuseboxNetworkInspectionEnabled`. + */ + RN_EXPORT static bool fuseboxWebSocketEventsEnabled(); + /** * When enabled, uses optimized platform-specific paths to apply animated props synchronously. On Android, this uses a batched int/double buffer protocol with a single JNI call. On iOS, this passes AnimatedProps directly through the delegate chain and applies them via cloneProps, avoiding the folly::dynamic round-trip. */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 041bc351eac9..eef175da896e 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -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<<2aee1d5b6266189201bb6f5f6c0ceb4c>> + * @generated SignedSource<<4c6a965958d151f22574bc85a6714c11>> */ /** @@ -1109,6 +1109,24 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { + auto flagValue = fuseboxWebSocketEventsEnabled_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(60, "fuseboxWebSocketEventsEnabled"); + + flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); + fuseboxWebSocketEventsEnabled_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { auto flagValue = optimizedAnimatedPropUpdates_.load(); @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(61, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(62, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "perfIssuesEnabled"); + markFlagAsAccessed(63, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "perfMonitorV2Enabled"); + markFlagAsAccessed(64, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1190,7 +1208,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "preparedTextCacheSize"); + markFlagAsAccessed(65, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1208,7 +1226,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(66, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "redBoxV2Android"); + markFlagAsAccessed(67, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "redBoxV2IOS"); + markFlagAsAccessed(68, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(69, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1280,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(70, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(71, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(72, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(73, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(74, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(75, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(76, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "useFabricInterop"); + markFlagAsAccessed(77, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(78, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "useNestedScrollViewAndroid"); + markFlagAsAccessed(79, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "useSharedAnimatedBackend"); + markFlagAsAccessed(80, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(81, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "useTurboModuleInterop"); + markFlagAsAccessed(82, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1514,7 +1532,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "viewCullingOutsetRatio"); + markFlagAsAccessed(83, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1532,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "viewTransitionEnabled"); + markFlagAsAccessed(84, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(85, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1568,7 +1586,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "virtualViewPrerenderRatio"); + markFlagAsAccessed(86, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index cf3369c9aa0f..6a67566e46cc 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -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<> */ /** @@ -92,6 +92,7 @@ class ReactNativeFeatureFlagsAccessor { bool fuseboxFrameRecordingEnabled(); bool fuseboxNetworkInspectionEnabled(); bool fuseboxScreenshotCaptureEnabled(); + bool fuseboxWebSocketEventsEnabled(); bool optimizedAnimatedPropUpdates(); bool overrideBySynchronousMountPropsAtMountingAndroid(); bool perfIssuesEnabled(); @@ -129,7 +130,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 86> accessedFeatureFlags_; + std::array, 87> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -191,6 +192,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> fuseboxFrameRecordingEnabled_; std::atomic> fuseboxNetworkInspectionEnabled_; std::atomic> fuseboxScreenshotCaptureEnabled_; + std::atomic> fuseboxWebSocketEventsEnabled_; std::atomic> optimizedAnimatedPropUpdates_; std::atomic> overrideBySynchronousMountPropsAtMountingAndroid_; std::atomic> perfIssuesEnabled_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index ca9ab82ce1ec..aa36d5cfa7d5 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<2c09412cdf8edaf4f7d6d90a446ce6fa>> + * @generated SignedSource<<2e19fd4b007d0ae7397babcc605b5783>> */ /** @@ -267,6 +267,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool fuseboxWebSocketEventsEnabled() override { + return false; + } + bool optimizedAnimatedPropUpdates() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 3fb33d362dac..940811f0174c 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -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<> */ /** @@ -585,6 +585,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::fuseboxScreenshotCaptureEnabled(); } + bool fuseboxWebSocketEventsEnabled() override { + auto value = values_["fuseboxWebSocketEventsEnabled"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::fuseboxWebSocketEventsEnabled(); + } + bool optimizedAnimatedPropUpdates() override { auto value = values_["optimizedAnimatedPropUpdates"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index 93d5f232ac49..d80700290554 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -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<<14cafde581fa0fb72ffebb5e192040e8>> + * @generated SignedSource<<2d4757d38664169286dacd779b0e267a>> */ /** @@ -85,6 +85,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool fuseboxFrameRecordingEnabled() = 0; virtual bool fuseboxNetworkInspectionEnabled() = 0; virtual bool fuseboxScreenshotCaptureEnabled() = 0; + virtual bool fuseboxWebSocketEventsEnabled() = 0; virtual bool optimizedAnimatedPropUpdates() = 0; virtual bool overrideBySynchronousMountPropsAtMountingAndroid() = 0; virtual bool perfIssuesEnabled() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index 8e627b8ace38..2490fdbd5be5 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -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<<1e26f8dc1f474475c0a4d6be79aedb24>> + * @generated SignedSource<<2a0a030bafa14c6f20691f029703a642>> */ /** @@ -344,6 +344,11 @@ bool NativeReactNativeFeatureFlags::fuseboxScreenshotCaptureEnabled( return ReactNativeFeatureFlags::fuseboxScreenshotCaptureEnabled(); } +bool NativeReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled(); +} + bool NativeReactNativeFeatureFlags::optimizedAnimatedPropUpdates( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::optimizedAnimatedPropUpdates(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index d0acb696863d..7d07e49ad1ba 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -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<<86a4b76371a4005f7083c3806d53cc87>> + * @generated SignedSource<<206f5586c0ea22387a48a484d6d96f54>> */ /** @@ -156,6 +156,8 @@ class NativeReactNativeFeatureFlags bool fuseboxScreenshotCaptureEnabled(jsi::Runtime& runtime); + bool fuseboxWebSocketEventsEnabled(jsi::Runtime& runtime); + bool optimizedAnimatedPropUpdates(jsi::Runtime& runtime); bool overrideBySynchronousMountPropsAtMountingAndroid(jsi::Runtime& runtime); diff --git a/packages/react-native/ReactCommon/react/networking/NetworkReporter.cpp b/packages/react-native/ReactCommon/react/networking/NetworkReporter.cpp index 458afb1fa6f7..d74c2e1e291a 100644 --- a/packages/react-native/ReactCommon/react/networking/NetworkReporter.cpp +++ b/packages/react-native/ReactCommon/react/networking/NetworkReporter.cpp @@ -241,6 +241,68 @@ void NetworkReporter::reportRequestFailed( #endif } +void NetworkReporter::reportWebSocketCreated( + const std::string& requestId, + const std::string& url) const { +#ifdef REACT_NATIVE_DEBUGGER_ENABLED + // Debugger enabled: CDP event handling + jsinspector_modern::NetworkHandler::getInstance().onWebSocketCreated( + requestId, url); +#endif +} + +void NetworkReporter::reportWebSocketWillSendHandshakeRequest( + const std::string& requestId, + const Headers& headers) const { +#ifdef REACT_NATIVE_DEBUGGER_ENABLED + // Debugger enabled: CDP event handling + jsinspector_modern::NetworkHandler::getInstance() + .onWebSocketWillSendHandshakeRequest(requestId, headers); +#endif +} + +void NetworkReporter::reportWebSocketHandshakeResponseReceived( + const std::string& requestId, + uint16_t statusCode, + const Headers& headers) const { +#ifdef REACT_NATIVE_DEBUGGER_ENABLED + // Debugger enabled: CDP event handling + jsinspector_modern::NetworkHandler::getInstance() + .onWebSocketHandshakeResponseReceived(requestId, statusCode, headers); +#endif +} + +void NetworkReporter::reportWebSocketMessageSent( + const std::string& requestId, + const std::string& payloadData, + bool isBinary) const { +#ifdef REACT_NATIVE_DEBUGGER_ENABLED + // Debugger enabled: CDP event handling + jsinspector_modern::NetworkHandler::getInstance().onWebSocketFrameSent( + requestId, payloadData, isBinary); +#endif +} + +void NetworkReporter::reportWebSocketMessageReceived( + const std::string& requestId, + const std::string& payloadData, + bool isBinary) const { +#ifdef REACT_NATIVE_DEBUGGER_ENABLED + // Debugger enabled: CDP event handling + jsinspector_modern::NetworkHandler::getInstance().onWebSocketFrameReceived( + requestId, payloadData, isBinary); +#endif +} + +void NetworkReporter::reportWebSocketClosed( + const std::string& requestId) const { +#ifdef REACT_NATIVE_DEBUGGER_ENABLED + // Debugger enabled: CDP event handling + jsinspector_modern::NetworkHandler::getInstance().onWebSocketClosed( + requestId); +#endif +} + void NetworkReporter::storeResponseBody( const std::string& requestId, std::string_view body, diff --git a/packages/react-native/ReactCommon/react/networking/NetworkReporter.h b/packages/react-native/ReactCommon/react/networking/NetworkReporter.h index e76adbf6afe6..841a592c1c6d 100644 --- a/packages/react-native/ReactCommon/react/networking/NetworkReporter.h +++ b/packages/react-native/ReactCommon/react/networking/NetworkReporter.h @@ -122,6 +122,66 @@ class NetworkReporter { */ void reportRequestFailed(const std::string &requestId, bool cancelled) const; + /** + * Report that a WebSocket connection is about to be created. + * + * Corresponds to `Network.webSocketCreated` in CDP. WebSocket events are + * reported to CDP only and have no Web Performance counterpart — in a + * production (non dev or profiling) build, this method is a no-op. + */ + void reportWebSocketCreated(const std::string &requestId, const std::string &url) const; + + /** + * Report that a WebSocket handshake (HTTP upgrade) request is about to be + * sent, along with its final request headers. + * + * Corresponds to `Network.webSocketWillSendHandshakeRequest` in CDP. In a + * production (non dev or profiling) build, this method is a no-op. + */ + void reportWebSocketWillSendHandshakeRequest(const std::string &requestId, const Headers &headers) const; + + /** + * Report that a WebSocket handshake response was received and the + * connection is established. + * + * Corresponds to `Network.webSocketHandshakeResponseReceived` in CDP. In a + * production (non dev or profiling) build, this method is a no-op. + */ + void reportWebSocketHandshakeResponseReceived( + const std::string &requestId, + uint16_t statusCode, + const Headers &headers) const; + + /** + * Report a WebSocket message sent over an open connection. `payloadData` + * must be a UTF-8 string for text messages, or a base64-encoded string for + * binary messages (`isBinary`). + * + * Corresponds to `Network.webSocketFrameSent` in CDP. In a production + * (non dev or profiling) build, this method is a no-op. + */ + void reportWebSocketMessageSent(const std::string &requestId, const std::string &payloadData, bool isBinary) const; + + /** + * Report a WebSocket message received over an open connection. + * `payloadData` must be a UTF-8 string for text messages, or a + * base64-encoded string for binary messages (`isBinary`). + * + * Corresponds to `Network.webSocketFrameReceived` in CDP. In a production + * (non dev or profiling) build, this method is a no-op. + */ + void reportWebSocketMessageReceived(const std::string &requestId, const std::string &payloadData, bool isBinary) + const; + + /** + * Report that a WebSocket connection was closed, whether cleanly or due to + * an error. + * + * Corresponds to `Network.webSocketClosed` in CDP. In a production (non dev + * or profiling) build, this method is a no-op. + */ + void reportWebSocketClosed(const std::string &requestId) const; + /** * Store the fetched response body for a text or image network response. * These may be retrieved by CDP clients to to render a response preview via diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 848f7222d711..c6da913f9d90 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -690,6 +690,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'experimental', }, + fuseboxWebSocketEventsEnabled: { + defaultValue: false, + metadata: { + dateAdded: '2026-07-11', + description: + 'Enable reporting of WebSocket network events (`Network.webSocket*` CDP events) to the React Native DevTools CDP backend. Requires `fuseboxNetworkInspectionEnabled`.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, optimizedAnimatedPropUpdates: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 0b7d259dcf67..9ce992567e5d 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<81820f6c8f87bd90ed861ad9ddb153bb>> + * @generated SignedSource<<040d7a7365dfe19ee8721892e5e5551d>> * @flow strict * @noformat */ @@ -108,6 +108,7 @@ export type ReactNativeFeatureFlags = Readonly<{ fuseboxFrameRecordingEnabled: Getter, fuseboxNetworkInspectionEnabled: Getter, fuseboxScreenshotCaptureEnabled: Getter, + fuseboxWebSocketEventsEnabled: Getter, optimizedAnimatedPropUpdates: Getter, overrideBySynchronousMountPropsAtMountingAndroid: Getter, perfIssuesEnabled: Getter, @@ -445,6 +446,10 @@ export const fuseboxNetworkInspectionEnabled: Getter = createNativeFlag * Enable Page.captureScreenshot CDP method support in the React Native DevTools CDP backend. This flag is global and should not be changed across React Host lifetimes. */ export const fuseboxScreenshotCaptureEnabled: Getter = createNativeFlagGetter('fuseboxScreenshotCaptureEnabled', false); +/** + * Enable reporting of WebSocket network events (`Network.webSocket*` CDP events) to the React Native DevTools CDP backend. Requires `fuseboxNetworkInspectionEnabled`. + */ +export const fuseboxWebSocketEventsEnabled: Getter = createNativeFlagGetter('fuseboxWebSocketEventsEnabled', false); /** * When enabled, uses optimized platform-specific paths to apply animated props synchronously. On Android, this uses a batched int/double buffer protocol with a single JNI call. On iOS, this passes AnimatedProps directly through the delegate chain and applies them via cloneProps, avoiding the folly::dynamic round-trip. */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index a4145d59c8c2..67ae1cb7d8a1 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -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<<68ccb8d73a8f29fbf88f15a183bd910b>> + * @generated SignedSource<<4dd4bbc51723d64c94a8e7ba1bf67bf1>> * @flow strict * @noformat */ @@ -85,6 +85,7 @@ export interface Spec extends TurboModule { readonly fuseboxFrameRecordingEnabled?: () => boolean; readonly fuseboxNetworkInspectionEnabled?: () => boolean; readonly fuseboxScreenshotCaptureEnabled?: () => boolean; + readonly fuseboxWebSocketEventsEnabled?: () => boolean; readonly optimizedAnimatedPropUpdates?: () => boolean; readonly overrideBySynchronousMountPropsAtMountingAndroid?: () => boolean; readonly perfIssuesEnabled?: () => boolean; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index 0aaf2efc5caa..a11e7ec3c52f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -3936,6 +3936,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -10819,6 +10825,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -11538,6 +11550,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index be2958293dad..592a14fc9275 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -3790,6 +3790,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -10442,6 +10448,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -11161,6 +11173,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index faf7b0b6db37..33e10869b39d 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -3933,6 +3933,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -10672,6 +10678,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -11391,6 +11403,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 2ec052291919..de18e199e774 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1254,6 +1254,15 @@ interface RCTInspectorUtils : public NSObject { public virtual static CommonHostMetadata* getHostMetadata(); } +interface RCTInspectorWebSocketReporter : public NSObject { + public virtual static void reportHandshakeResponseReceived:httpMessage:(_Nullable NSString* requestId, _Nullable CFHTTPMessageRef httpMessage); + public virtual static void reportMessageReceived:message:(_Nullable NSString* requestId, _Nullable id message); + public virtual static void reportMessageSent:message:(_Nullable NSString* requestId, _Nullable id message); + public virtual static void reportWebSocketClosed:(_Nullable NSString* requestId); + public virtual static void reportWebSocketCreated:url:(_Nullable NSString* requestId, NSURL* url); + public virtual static void reportWillSendHandshakeRequest:request:(_Nullable NSString* requestId, NSURLRequest* request); +} + interface RCTInstance : public NSObject { public @property (strong, readonly) RCTSurfacePresenter* surfacePresenter; public virtual instancetype initWithDelegate:jsRuntimeFactory:bundleManager:turboModuleManagerDelegate:moduleRegistry:parentInspectorTarget:launchOptions:(id delegate, std::shared_ptr jsRuntimeFactory, RCTBundleManager* bundleManager, id turboModuleManagerDelegate, RCTModuleRegistry* moduleRegistry, facebook::react::jsinspector_modern::HostTarget* parentInspectorTarget, _Nullable NSDictionary* launchOptions); @@ -6111,6 +6120,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -12666,6 +12681,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -13372,6 +13393,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 319fba1e0d52..3d2428683ae3 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1252,6 +1252,15 @@ interface RCTInspectorUtils : public NSObject { public virtual static CommonHostMetadata* getHostMetadata(); } +interface RCTInspectorWebSocketReporter : public NSObject { + public virtual static void reportHandshakeResponseReceived:httpMessage:(_Nullable NSString* requestId, _Nullable CFHTTPMessageRef httpMessage); + public virtual static void reportMessageReceived:message:(_Nullable NSString* requestId, _Nullable id message); + public virtual static void reportMessageSent:message:(_Nullable NSString* requestId, _Nullable id message); + public virtual static void reportWebSocketClosed:(_Nullable NSString* requestId); + public virtual static void reportWebSocketCreated:url:(_Nullable NSString* requestId, NSURL* url); + public virtual static void reportWillSendHandshakeRequest:request:(_Nullable NSString* requestId, NSURLRequest* request); +} + interface RCTInstance : public NSObject { public @property (strong, readonly) RCTSurfacePresenter* surfacePresenter; public virtual instancetype initWithDelegate:jsRuntimeFactory:bundleManager:turboModuleManagerDelegate:moduleRegistry:parentInspectorTarget:launchOptions:(id delegate, std::shared_ptr jsRuntimeFactory, RCTBundleManager* bundleManager, id turboModuleManagerDelegate, RCTModuleRegistry* moduleRegistry, facebook::react::jsinspector_modern::HostTarget* parentInspectorTarget, _Nullable NSDictionary* launchOptions); @@ -5993,6 +6002,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -12351,6 +12366,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -13057,6 +13078,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 3ec823148f22..a24cb26ab17a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1254,6 +1254,15 @@ interface RCTInspectorUtils : public NSObject { public virtual static CommonHostMetadata* getHostMetadata(); } +interface RCTInspectorWebSocketReporter : public NSObject { + public virtual static void reportHandshakeResponseReceived:httpMessage:(_Nullable NSString* requestId, _Nullable CFHTTPMessageRef httpMessage); + public virtual static void reportMessageReceived:message:(_Nullable NSString* requestId, _Nullable id message); + public virtual static void reportMessageSent:message:(_Nullable NSString* requestId, _Nullable id message); + public virtual static void reportWebSocketClosed:(_Nullable NSString* requestId); + public virtual static void reportWebSocketCreated:url:(_Nullable NSString* requestId, NSURL* url); + public virtual static void reportWillSendHandshakeRequest:request:(_Nullable NSString* requestId, NSURLRequest* request); +} + interface RCTInstance : public NSObject { public @property (strong, readonly) RCTSurfacePresenter* surfacePresenter; public virtual instancetype initWithDelegate:jsRuntimeFactory:bundleManager:turboModuleManagerDelegate:moduleRegistry:parentInspectorTarget:launchOptions:(id delegate, std::shared_ptr jsRuntimeFactory, RCTBundleManager* bundleManager, id turboModuleManagerDelegate, RCTModuleRegistry* moduleRegistry, facebook::react::jsinspector_modern::HostTarget* parentInspectorTarget, _Nullable NSDictionary* launchOptions); @@ -6108,6 +6117,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -12529,6 +12544,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -13235,6 +13256,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api index 64301436eeda..56d20f0b002e 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonDebugCxx.api @@ -2573,6 +2573,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -7830,6 +7836,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -8536,6 +8548,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api index eda51abd3696..d898e370e8fa 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonNewarchCxx.api @@ -2467,6 +2467,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -7655,6 +7661,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -8361,6 +8373,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api index 3c9c5e8ab249..d2eb086c932c 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonReleaseCxx.api @@ -2570,6 +2570,12 @@ class facebook::react::NetworkReporter { public void reportRequestStart(const std::string& requestId, const facebook::react::RequestInfo& requestInfo, int encodedDataLength, const std::optional& redirectResponse); public void reportResponseEnd(const std::string& requestId, int encodedDataLength); public void reportResponseStart(const std::string& requestId, const facebook::react::ResponseInfo& responseInfo, int encodedDataLength); + public void reportWebSocketClosed(const std::string& requestId) const; + public void reportWebSocketCreated(const std::string& requestId, const std::string& url) const; + public void reportWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t statusCode, const facebook::react::Headers& headers) const; + public void reportWebSocketMessageReceived(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketMessageSent(const std::string& requestId, const std::string& payloadData, bool isBinary) const; + public void reportWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::Headers& headers) const; public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -7821,6 +7827,12 @@ class facebook::react::jsinspector_modern::NetworkHandler { public void onRequestWillBeSent(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Request& request, const std::optional& redirectResponse); public void onRequestWillBeSentExtraInfo(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void onResponseReceived(const std::string& requestId, const facebook::react::jsinspector_modern::cdp::network::Response& response); + public void onWebSocketClosed(const std::string& requestId); + public void onWebSocketCreated(const std::string& requestId, const std::string& url); + public void onWebSocketFrameReceived(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketFrameSent(const std::string& requestId, const std::string& payloadData, bool isBinary); + public void onWebSocketHandshakeResponseReceived(const std::string& requestId, uint16_t status, const facebook::react::jsinspector_modern::Headers& headers); + public void onWebSocketWillSendHandshakeRequest(const std::string& requestId, const facebook::react::jsinspector_modern::Headers& headers); public void recordRequestInitiatorStack(const std::string& requestId, folly::dynamic stackTrace); public void storeResponseBody(const std::string& requestId, std::string_view body, bool base64Encoded); } @@ -8527,6 +8539,56 @@ struct facebook::react::jsinspector_modern::cdp::network::ResponseReceivedParams public std::string type; } +struct facebook::react::jsinspector_modern::cdp::network::WebSocketClosedParams { + public double timestamp; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketCreatedParams { + public folly::dynamic initiator; + public folly::dynamic toDynamic() const; + public std::string requestId; + public std::string url; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrame { + public bool mask; + public folly::dynamic toDynamic() const; + public int opcode; + public std::string payloadData; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketFrameParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketFrame response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketHandshakeResponseReceivedParams { + public double timestamp; + public facebook::react::jsinspector_modern::cdp::network::WebSocketResponse response; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketResponse { + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public static facebook::react::jsinspector_modern::cdp::network::WebSocketResponse fromInputParams(uint16_t status, const facebook::react::jsinspector_modern::cdp::network::Headers& headers); + public std::string statusText; + public uint16_t status; +} + +struct facebook::react::jsinspector_modern::cdp::network::WebSocketWillSendHandshakeRequestParams { + public double timestamp; + public double wallTime; + public facebook::react::jsinspector_modern::cdp::network::Headers headers; + public folly::dynamic toDynamic() const; + public std::string requestId; +} + constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINED_BY; constexpr uint_fast16_t facebook::react::dom::DOCUMENT_POSITION_CONTAINS; From cfd0e359a6747de4266c003099ddacebb8f89a50 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 15 Jul 2026 03:50:39 -0700 Subject: [PATCH 064/834] Add CDP support for WebSocket events (Android) (#57541) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57541 **Context** This stack implements WebSocket event debugging for the Network panel in React Native DevTools. **This diff** Follows the iOS implementation: reports the same six `Network.webSocket*` CDP events through the shared `NetworkReporter` C++ core, gated behind the same `fuseboxWebSocketEventsEnabled` feature flag. **Changes** - `InspectorNetworkReporter` + `JInspectorNetworkReporter`: New JNI-backed `reportWebSocket*` methods bridging to the shared `NetworkReporter` C++ core. Payload conversion costs (base64) are deferred until a debugger is attached. - `WebSocketModule`: Reports connection lifecycle, real handshake headers (from the OkHttp `Request`/`Response`), and sent/received messages, gated behind the same feature flags as iOS. Changelog: [Internal] Reviewed By: GijsWeterings Differential Revision: D111561994 fbshipit-source-id: 735ad0e47fc4e9e943b1092fbc23846ff9baf963 --- .../network/InspectorNetworkReporter.kt | 105 ++++++++++++++++++ .../modules/websocket/WebSocketModule.kt | 71 +++++++++++- .../devsupport/JInspectorNetworkReporter.cpp | 72 ++++++++++++ .../devsupport/JInspectorNetworkReporter.h | 30 +++++ .../api-snapshots/ReactAndroidDebugCxx.api | 6 + .../api-snapshots/ReactAndroidNewarchCxx.api | 6 + .../api-snapshots/ReactAndroidReleaseCxx.api | 6 + 7 files changed, 295 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/InspectorNetworkReporter.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/InspectorNetworkReporter.kt index 061774eafeec..653da920f6ac 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/InspectorNetworkReporter.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/InspectorNetworkReporter.kt @@ -5,10 +5,13 @@ * LICENSE file in the root directory of this source tree. */ +@file:Suppress("DEPRECATION_ERROR") // Conflicting okhttp versions + package com.facebook.react.modules.network import com.facebook.proguard.annotations.DoNotStripAny import com.facebook.soloader.SoLoader +import okio.ByteString /** * [Experimental] An interface for reporting network events to the modern debugger server and Web @@ -125,4 +128,106 @@ internal object InspectorNetworkReporter { } @JvmStatic external fun maybeStoreResponseBodyIncrementalImpl(requestId: String, data: String) + + /** + * Report that a WebSocket connection is about to be created. + * + * Corresponds to `Network.webSocketCreated` in CDP. + */ + @JvmStatic external fun reportWebSocketCreated(requestId: String, url: String) + + /** + * Report that a WebSocket handshake (HTTP upgrade) request is about to be sent, along with its + * final request headers. + * + * Corresponds to `Network.webSocketWillSendHandshakeRequest` in CDP. + */ + @JvmStatic + external fun reportWebSocketWillSendHandshakeRequest( + requestId: String, + headers: Map, + ) + + /** + * Report that a WebSocket handshake response was received and the connection is established. + * + * Corresponds to `Network.webSocketHandshakeResponseReceived` in CDP. + */ + @JvmStatic + external fun reportWebSocketHandshakeResponseReceived( + requestId: String, + statusCode: Int, + headers: Map, + ) + + /** + * Report a text WebSocket message sent over an open connection. + * + * Corresponds to `Network.webSocketFrameSent` in CDP. + */ + @JvmStatic + fun reportWebSocketMessageSent(requestId: String, message: String) { + if (isDebuggingEnabled()) { + reportWebSocketMessageSentImpl(requestId, message, false) + } + } + + /** + * Report a binary WebSocket message sent over an open connection. + * + * Corresponds to `Network.webSocketFrameSent` in CDP. + */ + @JvmStatic + fun reportWebSocketMessageSent(requestId: String, message: ByteString) { + // Guard call to CDP-only reporting method (avoid base64 encoding) + if (isDebuggingEnabled()) { + reportWebSocketMessageSentImpl(requestId, message.base64(), true) + } + } + + @JvmStatic + external fun reportWebSocketMessageSentImpl( + requestId: String, + payloadData: String, + isBinary: Boolean, + ) + + /** + * Report a text WebSocket message received over an open connection. + * + * Corresponds to `Network.webSocketFrameReceived` in CDP. + */ + @JvmStatic + fun reportWebSocketMessageReceived(requestId: String, message: String) { + if (isDebuggingEnabled()) { + reportWebSocketMessageReceivedImpl(requestId, message, false) + } + } + + /** + * Report a binary WebSocket message received over an open connection. + * + * Corresponds to `Network.webSocketFrameReceived` in CDP. + */ + @JvmStatic + fun reportWebSocketMessageReceived(requestId: String, message: ByteString) { + // Guard call to CDP-only reporting method (avoid base64 encoding) + if (isDebuggingEnabled()) { + reportWebSocketMessageReceivedImpl(requestId, message.base64(), true) + } + } + + @JvmStatic + external fun reportWebSocketMessageReceivedImpl( + requestId: String, + payloadData: String, + isBinary: Boolean, + ) + + /** + * Report that a WebSocket connection was closed, whether cleanly or due to an error. + * + * Corresponds to `Network.webSocketClosed` in CDP. + */ + @JvmStatic external fun reportWebSocketClosed(requestId: String) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt index ab9a036d59ec..025501bec436 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt @@ -19,14 +19,18 @@ import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.buildReadableMap import com.facebook.react.common.ReactConstants +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.module.annotations.ReactModule import com.facebook.react.modules.network.CustomClientBuilder import com.facebook.react.modules.network.ForwardingCookieHandler +import com.facebook.react.modules.network.InspectorNetworkReporter +import com.facebook.react.modules.network.NetworkEventUtil import com.facebook.react.modules.network.OkHttpClientProvider import java.io.IOException import java.net.URI import java.net.URISyntaxException import java.util.HashMap +import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import okhttp3.CookieJar @@ -51,12 +55,31 @@ public class WebSocketModule(context: ReactApplicationContext) : private val contentHandlers: MutableMap = ConcurrentHashMap() private val cookieHandler: ForwardingCookieHandler = ForwardingCookieHandler() + /** CDP request IDs used to report each connection's events to the modern debugger server. */ + private val inspectorRequestIds: MutableMap = ConcurrentHashMap() + override fun invalidate() { for (socket in webSocketConnections.values) { socket.close(1_001 /* endpoint is going away */, null) } webSocketConnections.clear() contentHandlers.clear() + inspectorRequestIds.clear() + } + + /** Whether WebSocket events should be reported to the modern debugger server. */ + private fun isInspectorNetworkReportingEnabled(): Boolean = + ReactNativeFeatureFlags.enableNetworkEventReporting() && + ReactNativeFeatureFlags.fuseboxWebSocketEventsEnabled() + + /** + * Run [block] with the connection's CDP request ID, when WebSocket event reporting to the modern + * debugger server is enabled. + */ + private inline fun reportToInspector(id: Int, block: (requestId: String) -> Unit) { + if (isInspectorNetworkReportingEnabled()) { + inspectorRequestIds[id]?.let(block) + } } private fun sendEvent(eventName: String, params: ReadableMap) { @@ -142,11 +165,30 @@ public class WebSocketModule(context: ReactApplicationContext) : } } + val request = builder.build() + + if (isInspectorNetworkReportingEnabled()) { + val requestId = UUID.randomUUID().toString() + inspectorRequestIds[id] = requestId + InspectorNetworkReporter.reportWebSocketCreated(requestId, url) + InspectorNetworkReporter.reportWebSocketWillSendHandshakeRequest( + requestId, + NetworkEventUtil.okHttpHeadersToMap(request.headers()), + ) + } + client.newWebSocket( - builder.build(), + request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { webSocketConnections[id] = webSocket + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketHandshakeResponseReceived( + requestId, + response.code(), + NetworkEventUtil.okHttpHeadersToMap(response.headers()), + ) + } val params = buildReadableMap { put("id", id) put("protocol", response.header("Sec-WebSocket-Protocol", "")) @@ -159,6 +201,10 @@ public class WebSocketModule(context: ReactApplicationContext) : } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketClosed(requestId) + } + inspectorRequestIds.remove(id) val params = buildReadableMap { put("id", id) put("code", code) @@ -168,10 +214,17 @@ public class WebSocketModule(context: ReactApplicationContext) : } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketClosed(requestId) + } + inspectorRequestIds.remove(id) notifyWebSocketFailed(id, t.message) } override fun onMessage(webSocket: WebSocket, text: String) { + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketMessageReceived(requestId, text) + } val params = Arguments.createMap() params.putInt("id", id) params.putString("type", "text") @@ -186,6 +239,9 @@ public class WebSocketModule(context: ReactApplicationContext) : } override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketMessageReceived(requestId, bytes) + } val params = Arguments.createMap() params.putInt("id", id) params.putString("type", "binary") @@ -240,9 +296,13 @@ public class WebSocketModule(context: ReactApplicationContext) : sendEvent("websocketClosed", params) webSocketConnections.remove(id) contentHandlers.remove(id) + inspectorRequestIds.remove(id) return } try { + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketMessageSent(requestId, message) + } client.send(message) } catch (e: Exception) { notifyWebSocketFailed(id, e.message) @@ -267,10 +327,14 @@ public class WebSocketModule(context: ReactApplicationContext) : sendEvent("websocketClosed", params) webSocketConnections.remove(id) contentHandlers.remove(id) + inspectorRequestIds.remove(id) return } try { val decodedString = checkNotNull(ByteString.decodeBase64(base64String)) { "bytes == null" } + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketMessageSent(requestId, decodedString) + } client.send(decodedString) } catch (e: Exception) { notifyWebSocketFailed(id, e.message) @@ -294,9 +358,13 @@ public class WebSocketModule(context: ReactApplicationContext) : sendEvent("websocketClosed", params) webSocketConnections.remove(id) contentHandlers.remove(id) + inspectorRequestIds.remove(id) return } try { + reportToInspector(id) { requestId -> + InspectorNetworkReporter.reportWebSocketMessageSent(requestId, byteString) + } client.send(byteString) } catch (e: Exception) { notifyWebSocketFailed(id, e.message) @@ -321,6 +389,7 @@ public class WebSocketModule(context: ReactApplicationContext) : sendEvent("websocketClosed", params) webSocketConnections.remove(id) contentHandlers.remove(id) + inspectorRequestIds.remove(id) return } try { diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.cpp index 512eaf3811e3..d9277b5caf79 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.cpp @@ -178,6 +178,60 @@ JInspectorNetworkReporter::maybeStoreResponseBodyIncrementalImpl( #endif } +/* static */ void JInspectorNetworkReporter::reportWebSocketCreated( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref url) { + NetworkReporter::getInstance().reportWebSocketCreated( + requestId->toStdString(), url->toStdString()); +} + +/* static */ void +JInspectorNetworkReporter::reportWebSocketWillSendHandshakeRequest( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref> headers) { + NetworkReporter::getInstance().reportWebSocketWillSendHandshakeRequest( + requestId->toStdString(), convertJavaMapToHeaders(headers)); +} + +/* static */ void +JInspectorNetworkReporter::reportWebSocketHandshakeResponseReceived( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jint statusCode, + jni::alias_ref> headers) { + NetworkReporter::getInstance().reportWebSocketHandshakeResponseReceived( + requestId->toStdString(), + static_cast(statusCode), + convertJavaMapToHeaders(headers)); +} + +/* static */ void JInspectorNetworkReporter::reportWebSocketMessageSentImpl( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref payloadData, + jboolean isBinary) { + NetworkReporter::getInstance().reportWebSocketMessageSent( + requestId->toStdString(), payloadData->toStdString(), isBinary != 0u); +} + +/* static */ void JInspectorNetworkReporter::reportWebSocketMessageReceivedImpl( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref payloadData, + jboolean isBinary) { + NetworkReporter::getInstance().reportWebSocketMessageReceived( + requestId->toStdString(), payloadData->toStdString(), isBinary != 0u); +} + +/* static */ void JInspectorNetworkReporter::reportWebSocketClosed( + jni::alias_ref /*unused*/, + jni::alias_ref requestId) { + NetworkReporter::getInstance().reportWebSocketClosed( + requestId->toStdString()); +} + /* static */ void JInspectorNetworkReporter::registerNatives() { javaClassLocal()->registerNatives({ makeNativeMethod( @@ -204,6 +258,24 @@ JInspectorNetworkReporter::maybeStoreResponseBodyIncrementalImpl( makeNativeMethod( "maybeStoreResponseBodyIncrementalImpl", JInspectorNetworkReporter::maybeStoreResponseBodyIncrementalImpl), + makeNativeMethod( + "reportWebSocketCreated", + JInspectorNetworkReporter::reportWebSocketCreated), + makeNativeMethod( + "reportWebSocketWillSendHandshakeRequest", + JInspectorNetworkReporter::reportWebSocketWillSendHandshakeRequest), + makeNativeMethod( + "reportWebSocketHandshakeResponseReceived", + JInspectorNetworkReporter::reportWebSocketHandshakeResponseReceived), + makeNativeMethod( + "reportWebSocketMessageSentImpl", + JInspectorNetworkReporter::reportWebSocketMessageSentImpl), + makeNativeMethod( + "reportWebSocketMessageReceivedImpl", + JInspectorNetworkReporter::reportWebSocketMessageReceivedImpl), + makeNativeMethod( + "reportWebSocketClosed", + JInspectorNetworkReporter::reportWebSocketClosed), }); } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.h b/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.h index 3d50c5b044fa..f751dda40117 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/devsupport/JInspectorNetworkReporter.h @@ -59,6 +59,36 @@ class JInspectorNetworkReporter : public jni::HybridClass requestId, jni::alias_ref data); + static void reportWebSocketCreated( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref url); + + static void reportWebSocketWillSendHandshakeRequest( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref> headers); + + static void reportWebSocketHandshakeResponseReceived( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jint statusCode, + jni::alias_ref> headers); + + static void reportWebSocketMessageSentImpl( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref payloadData, + jboolean isBinary); + + static void reportWebSocketMessageReceivedImpl( + jni::alias_ref /*unused*/, + jni::alias_ref requestId, + jni::alias_ref payloadData, + jboolean isBinary); + + static void reportWebSocketClosed(jni::alias_ref /*unused*/, jni::alias_ref requestId); + static void registerNatives(); private: diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api index a11e7ec3c52f..dcf221dac974 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api @@ -10801,6 +10801,12 @@ class facebook::react::jsinspector_modern::JInspectorNetworkReporter : public jn public static void reportRequestStart(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref requestUrl, jni::alias_ref requestMethod, jni::alias_ref> requestHeaders, jni::alias_ref requestBody, jlong encodedDataLength); public static void reportResponseEnd(jni::alias_ref, jni::alias_ref requestId, jlong encodedDataLength); public static void reportResponseStart(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref requestUrl, jint responseStatus, jni::alias_ref> responseHeaders, jlong encodedDataLength); + public static void reportWebSocketClosed(jni::alias_ref, jni::alias_ref requestId); + public static void reportWebSocketCreated(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref url); + public static void reportWebSocketHandshakeResponseReceived(jni::alias_ref, jni::alias_ref requestId, jint statusCode, jni::alias_ref> headers); + public static void reportWebSocketMessageReceivedImpl(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref payloadData, jboolean isBinary); + public static void reportWebSocketMessageSentImpl(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref payloadData, jboolean isBinary); + public static void reportWebSocketWillSendHandshakeRequest(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref> headers); } class facebook::react::jsinspector_modern::LoadNetworkResourceDelegate { diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api index 592a14fc9275..5c35c3b0f39f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidNewarchCxx.api @@ -10424,6 +10424,12 @@ class facebook::react::jsinspector_modern::JInspectorNetworkReporter : public jn public static void reportRequestStart(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref requestUrl, jni::alias_ref requestMethod, jni::alias_ref> requestHeaders, jni::alias_ref requestBody, jlong encodedDataLength); public static void reportResponseEnd(jni::alias_ref, jni::alias_ref requestId, jlong encodedDataLength); public static void reportResponseStart(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref requestUrl, jint responseStatus, jni::alias_ref> responseHeaders, jlong encodedDataLength); + public static void reportWebSocketClosed(jni::alias_ref, jni::alias_ref requestId); + public static void reportWebSocketCreated(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref url); + public static void reportWebSocketHandshakeResponseReceived(jni::alias_ref, jni::alias_ref requestId, jint statusCode, jni::alias_ref> headers); + public static void reportWebSocketMessageReceivedImpl(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref payloadData, jboolean isBinary); + public static void reportWebSocketMessageSentImpl(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref payloadData, jboolean isBinary); + public static void reportWebSocketWillSendHandshakeRequest(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref> headers); } class facebook::react::jsinspector_modern::LoadNetworkResourceDelegate { diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api index 33e10869b39d..f0acc551bb18 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidReleaseCxx.api @@ -10654,6 +10654,12 @@ class facebook::react::jsinspector_modern::JInspectorNetworkReporter : public jn public static void reportRequestStart(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref requestUrl, jni::alias_ref requestMethod, jni::alias_ref> requestHeaders, jni::alias_ref requestBody, jlong encodedDataLength); public static void reportResponseEnd(jni::alias_ref, jni::alias_ref requestId, jlong encodedDataLength); public static void reportResponseStart(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref requestUrl, jint responseStatus, jni::alias_ref> responseHeaders, jlong encodedDataLength); + public static void reportWebSocketClosed(jni::alias_ref, jni::alias_ref requestId); + public static void reportWebSocketCreated(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref url); + public static void reportWebSocketHandshakeResponseReceived(jni::alias_ref, jni::alias_ref requestId, jint statusCode, jni::alias_ref> headers); + public static void reportWebSocketMessageReceivedImpl(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref payloadData, jboolean isBinary); + public static void reportWebSocketMessageSentImpl(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref payloadData, jboolean isBinary); + public static void reportWebSocketWillSendHandshakeRequest(jni::alias_ref, jni::alias_ref requestId, jni::alias_ref> headers); } class facebook::react::jsinspector_modern::LoadNetworkResourceDelegate { From 1475725dc75e05ddc01f85ef87e164e928cf1a53 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 15 Jul 2026 03:50:39 -0700 Subject: [PATCH 065/834] Resolve initiator for WebSocket CDP events (#57542) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57542 **Context** This stack implements WebSocket event debugging for the Network panel in React Native DevTools. **This diff** Wire up request initiator logic for `WebSocket` on the JS side, completing functionality for WebSocket network debugging. Mirrors the HTTP pipeline's `unstable_devToolsRequestId` plumbing. **Changes** - Wire up `unstable_devToolsRequestId` plumbing through `Libraries/WebSocket/WebSocket.js` and `NativeWebSocketModule`. - Adds `testWebSocketCreatedWithInitiator`, asserting the initiator stack (function name, line number, source URL) resolves through `Network.webSocketCreated`. Changelog: [Internal] Reviewed By: GijsWeterings Differential Revision: D111561995 fbshipit-source-id: 0c7e3cad028506062737ecf4fafaece06240ec57 --- .../Libraries/WebSocket/WebSocket.js | 9 ++- .../React/CoreModules/RCTWebSocketModule.mm | 5 +- .../modules/websocket/WebSocketModule.kt | 13 +++- .../tests/NetworkReporterTest.cpp | 60 +++++++++++++++++++ .../modules/NativeWebSocketModule.js | 5 +- 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/react-native/Libraries/WebSocket/WebSocket.js b/packages/react-native/Libraries/WebSocket/WebSocket.js index 36aafbbbab17..6ed5630f7438 100644 --- a/packages/react-native/Libraries/WebSocket/WebSocket.js +++ b/packages/react-native/Libraries/WebSocket/WebSocket.js @@ -145,7 +145,14 @@ class WebSocket extends EventTarget { ); this._socketId = nextWebSocketId++; this._registerEvents(); - NativeWebSocketModule.connect(url, protocols, {headers}, this._socketId); + const devToolsRequestId = + global.__NETWORK_REPORTER__?.createDevToolsRequestId(); + NativeWebSocketModule.connect( + url, + protocols, + {headers, unstable_devToolsRequestId: devToolsRequestId}, + this._socketId, + ); } get binaryType(): ?BinaryType { diff --git a/packages/react-native/React/CoreModules/RCTWebSocketModule.mm b/packages/react-native/React/CoreModules/RCTWebSocketModule.mm index 175a5b2eb2b8..d170124d40c0 100644 --- a/packages/react-native/React/CoreModules/RCTWebSocketModule.mm +++ b/packages/react-native/React/CoreModules/RCTWebSocketModule.mm @@ -152,7 +152,10 @@ - (void)invalidate [webSocket setDelegateDispatchQueue:[self methodQueue]]; webSocket.delegate = self; webSocket.reactTag = @(socketID); - webSocket.inspectorRequestId = [[NSUUID UUID] UUIDString]; + // Prefer the DevTools request ID created by the JS caller, which carries the + // request initiator stack trace for CDP reporting. + NSString *devToolsRequestId = options.unstable_devToolsRequestId(); + webSocket.inspectorRequestId = devToolsRequestId.length > 0 ? devToolsRequestId : [[NSUUID UUID] UUIDString]; if (!_sockets) { _sockets = [NSMutableDictionary new]; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt index 025501bec436..dd9ad8c57021 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/websocket/WebSocketModule.kt @@ -168,7 +168,18 @@ public class WebSocketModule(context: ReactApplicationContext) : val request = builder.build() if (isInspectorNetworkReportingEnabled()) { - val requestId = UUID.randomUUID().toString() + // Prefer the DevTools request ID created by the JS caller, which carries + // the request initiator stack trace for CDP reporting. + val devToolsRequestId = + if ( + options?.hasKey("unstable_devToolsRequestId") == true && + options.getType("unstable_devToolsRequestId") == ReadableType.String + ) { + options.getString("unstable_devToolsRequestId") + } else { + null + } + val requestId = devToolsRequestId ?: UUID.randomUUID().toString() inspectorRequestIds[id] = requestId InspectorNetworkReporter.reportWebSocketCreated(requestId, url) InspectorNetworkReporter.reportWebSocketWillSendHandshakeRequest( diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp index f18935dba169..d99fcc43261c 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/NetworkReporterTest.cpp @@ -572,6 +572,66 @@ TEST_P(NetworkReporterTest, testCompleteWebSocketFlow) { })"); } +TEST_P(NetworkReporterTest, testWebSocketCreatedWithInitiator) { + InSequence s; + + this->expectMessageFromPage(JsonEq(R"({ + "id": 0, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 0, + "method": "Debugger.enable" + })"); + + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Network.enable" + })"); + + auto& runtime = engineAdapter_->getRuntime(); + + auto requestId = this->eval(R"( // line 0 + function openSocket() { // line 1 + return globalThis.__NETWORK_REPORTER__.createDevToolsRequestId(); // line 2 + } // line 3 + openSocket(); // line 4 + + //# sourceURL=webSocketInitiatorTest.js + )") + .asString(runtime) + .utf8(runtime); + + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Network.webSocketCreated"), + AtJsonPtr("/params/requestId", requestId), + AtJsonPtr("/params/url", "wss://example.com/initiator"), + AtJsonPtr("/params/initiator/type", "script"), + AtJsonPtr( + "/params/initiator/stack/callFrames", + AllOf( + Each(AtJsonPtr("/url", "webSocketInitiatorTest.js")), + Contains(AllOf( + AtJsonPtr("/functionName", "openSocket"), + AtJsonPtr("/lineNumber", 2)))))))); + + NetworkReporter::getInstance().reportWebSocketCreated( + requestId, "wss://example.com/initiator"); + + this->expectMessageFromPage(JsonEq(R"({ + "id": 2, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 2, + "method": "Network.disable" + })"); +} + TEST_P(NetworkReporterTest, testNetworkEventsWhenDisabled) { EXPECT_FALSE(NetworkReporter::getInstance().isDebuggingEnabled()); diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeWebSocketModule.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeWebSocketModule.js index 7feb712212a8..f6f0cd9ddfcd 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeWebSocketModule.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeWebSocketModule.js @@ -16,7 +16,10 @@ export interface Spec extends TurboModule { readonly connect: ( url: string, protocols: ?Array, - options: {headers?: Object}, + options: { + headers?: Object, + unstable_devToolsRequestId?: string, + }, socketID: number, ) => void; readonly send: (message: string, forSocketID: number) => void; From 713ae6b1b9699ea738592a27f0f75d931c27b443 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 15 Jul 2026 03:50:39 -0700 Subject: [PATCH 066/834] Promote fuseboxWebSocketEventsEnabled to canary (#57544) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57544 See rollout plan described on D111561998. Changelog: [General][Added] - **React Native DevTools**: Support for inspecting WebSocket events in the Network panel is added to the [`CANARY` channel](https://reactnative.dev/docs/releases/release-levels) Reviewed By: christophpurrer Differential Revision: D111892214 fbshipit-source-id: 19b94cbf0aa0cc9e0facd05b4a8704b189bc5021 --- ...ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt | 4 +++- .../ReactNativeFeatureFlagsOverridesOSSCanary.h | 6 +++++- .../scripts/featureflags/ReactNativeFeatureFlags.config.js | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt index aceff9047196..bfa926dd5369 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android.kt @@ -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<<1664bdb6cd521d608b9bab74df43eb76>> + * @generated SignedSource<<3335ab1242e91e4b85f5514a39a65002>> */ /** @@ -31,6 +31,8 @@ public open class ReactNativeFeatureFlagsOverrides_RNOSS_Canary_Android : ReactN override fun enableSwiftUIBasedFilters(): Boolean = true + override fun fuseboxWebSocketEventsEnabled(): Boolean = true + override fun useNativeViewConfigsInBridgelessMode(): Boolean = true override fun useTurboModuleInterop(): Boolean = true diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h index c12a45156f2a..d6ef238ae241 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSCanary.h @@ -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<<8f83d4d3c457e0f6acda799cfe58144a>> + * @generated SignedSource<<3c9dc8c1fa921fa0431dc043fc252760>> */ /** @@ -43,6 +43,10 @@ class ReactNativeFeatureFlagsOverridesOSSCanary : public ReactNativeFeatureFlags return true; } + bool fuseboxWebSocketEventsEnabled() override { + return true; + } + bool useNativeViewConfigsInBridgelessMode() override { return true; } diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index c6da913f9d90..15c52ac6625b 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -699,7 +699,7 @@ const definitions: FeatureFlagDefinitions = { expectedReleaseValue: true, purpose: 'experimentation', }, - ossReleaseStage: 'none', + ossReleaseStage: 'canary', }, optimizedAnimatedPropUpdates: { defaultValue: false, From 376bd0e464b7785b4bcf8673a96746ec8107f1eb Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 15 Jul 2026 05:08:52 -0700 Subject: [PATCH 067/834] refactor(ios): remove clang VFS overlay, resolve headers via new ReactNativeHeaders framework (#57285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The prebuilt `React.xcframework` previously relied on a Clang VFS overlay (`React-VFS.yaml`) to make headers importable, because the headers were laid out in CocoaPods-style namespaced folders rather than standard framework conventions. The overlay had to be generated at build time, re-resolved at pod-install time per slice, and injected as `-ivfsoverlay` flags into every Obj-C, C++, and Swift compile (including aggregate and third-party pod targets). This is fragile, hard to reason about, and incompatible with SwiftPM consumption. This PR removes the VFS overlay entirely and resolves headers through standard framework/header-search-path mechanics instead. **Headers are now emitted into the artifact according to an explicit, executable spec:** - **`React.xcframework`** — each slice's `React.framework` carries every `` header plus a framework module map, so `#import ` and `import React` resolve through `FRAMEWORK_SEARCH_PATHS` automatically. - **`ReactNativeHeaders.xcframework`** (new, headers-only) — carries every other namespace (``, ``, `folly`, `glog`, …), shipped alongside in the prebuilt tarball and exposed via a single header search path. - This makes `ReactNativeDependencies` binary-only. No clang VFS overlay, no per-target `-ivfsoverlay` flags. The layout is driven by a single source of truth (`headers-spec.js`, rules R1–R11) that both the prebuild compose step and downstream SwiftPM tooling derive from, so the shipped header set cannot drift from the spec. Source headers are byte-identical to the repo — the only consumer-facing change needed is bare-form angle includes (`#import ` → `#import `). **Consumer surfaces the flattened layout initially dropped are restored** (validated against Expo and community Fabric modules): - Private headers (`RCTBridge+Private.h` + the Fabric `RCTComponentView*` family) are exposed in the `React` module map — modular where safe, `textual` where they reach C++ — so frameworks like Expo compile unchanged, incl. Swift access to `RCTBridge.moduleRegistry`. - `React_RCTAppDelegate-umbrella.h` is re-emitted (derived from the live header set) for consumers probing it via `__has_include`. - Sources shipping under multiple include spellings (`React/X.h` + legacy `CoreModules/…`, `RCTImage/…`, bare aliases — 116 today) keep content at ONE module-owned spelling; other spellings become generated redirect shims, so `-fmodules` consumers cannot hit duplicate declarations. - The `React-RCTFabric` facade re-vends `RCTFabricComponentsPlugins.h` at `header_dir "React"`, keeping community Fabric modules' quoted `#import "RCTFabricComponentsPlugins.h"` working as with source pods. **The layout is verified at generator time** (`headers-verify.js`, runs in the prebuild compose CI job): unresolvable includes ratchet against a committed baseline, composed module maps/umbrellas must byte-match the spec render, and consumer-shaped compile smokes must pass (the `React` module, every namespace module, an Expo-shaped ObjC++ fixture, and a Swift `moduleRegistry` fixture). Fail-closed guards cover header collisions, allowlist drift, and missing OR undeclared third-party deps namespaces (the latter surfaced `SocketRocket`, which is deliberately NOT relocated — the real pod vends it, and textual copies collide under `use_frameworks`; the gate asserts its absence). **Key changes** - **New**: `headers-spec.js` (the executable layout contract, R1–R11), `headers-compose.js` (emitter for both xcframeworks), `headers-inventory.js` (podspec-driven header classifier feeding the spec + a diagnostic manifest), `headers-verify.js` (generator-time gate + CI step), `__docs__/headers-rules.md` (rules + rationale). - **Removed**: `vfs.js`, VFS types in `types.js`, and the VFS processing/flag-injection paths in `rncore.rb` and `xcframework.js`. - **Updated**: `React-Core-prebuilt.podspec` (vends both xcframeworks, flattens `ReactNativeHeaders` headers into `Headers/` via `prepare_command`, fails closed on incomplete tarballs), `rncore.rb` / `react_native_pods.rb` (header search path instead of overlay flags), `prebuild-ios-core.yml` (core tarball ships both xcframeworks; compose job verifies the composed headers), README (VFS docs replaced with the new model). - Added facades to the Podspecs that shouldn't be in use when running using precompiled frameworks to satisfy dependencies as empty pod-specs (with the single `RCTFabricComponentsPlugins.h` re-vend exception noted above). ## Changelog: [IOS] [CHANGED] - Remove the Clang VFS overlay from prebuilt React Native Core; resolve headers via React.xcframework + a new headers-only ReactNativeHeaders.xcframework Pull Request resolved: https://github.com/react/react-native/pull/57285 Test Plan: - [x] rn-tester builds against the prebuilt `React-Core-prebuilt` pod (Debug + Release) with no `-ivfsoverlay` flags present in the generated xcconfigs. - [x] rn-tester builds against React native source code (without any prebuilt artifacts) - [x] `#import `, `import React;`, and the relocated namespaces (``, ``, `folly`/`glog`) all resolve. - [x] Prebuilt tarball contains both `React.xcframework` and `ReactNativeHeaders.xcframework`; pod install flattens headers into `React-Core-prebuilt/Headers`. - [x] Switch RN-tester between Debug/Release and verify that both `React.xcframework` and `ReactNativeHeaders.xcframework` are changed between debug and release correctly. - [x] `headers-verify.js` gate green on Debug and Release composes (`-Werror=non-modular-include-in-framework-module` never trips in consumer builds). - [x] `private/helloworld` builds against the prebuilt core (CocoaPods path). - [x] Expo SDK compiles against the prebuilt artifacts (private headers, `React_RCTAppDelegate` umbrella probe, Fabric quoted imports). Reviewed By: fabriziocucci Differential Revision: D111448598 Pulled By: cipolleschi fbshipit-source-id: 72bc2f37765ad425722161a038e410ba976858a5 --- .github/workflows/prebuild-ios-core.yml | 31 +- .../react-native/React-Core-prebuilt.podspec | 58 +- .../react-native/scripts/cocoapods/fabric.rb | 2 +- .../react-native/scripts/cocoapods/rncore.rb | 111 +-- .../scripts/cocoapods/rncore_facades.rb | 285 ++++++++ .../scripts/ios-prebuild/__docs__/README.md | 159 ++--- .../ios-prebuild/__docs__/headers-rules.md | 383 +++++++++++ .../__tests__/headers-inventory-test.js | 118 ++++ .../__tests__/headers-spec-test.js | 276 ++++++++ .../scripts/ios-prebuild/headers-compose.js | 432 ++++++++++++ .../headers-include-baseline.json | 29 + .../scripts/ios-prebuild/headers-inventory.js | 630 ++++++++++++++++++ .../scripts/ios-prebuild/headers-spec.js | 484 ++++++++++++++ .../scripts/ios-prebuild/headers-verify.js | 477 +++++++++++++ .../ios-prebuild/templates/React-umbrella.h | 246 ------- .../templates/React_RCTAppDelegate-umbrella.h | 30 - .../ios-prebuild/templates/module.modulemap | 10 - .../scripts/ios-prebuild/types.js | 18 - .../react-native/scripts/ios-prebuild/vfs.js | 282 -------- .../scripts/ios-prebuild/xcframework.js | 327 +++------ .../react-native/scripts/react_native_pods.rb | 60 +- .../scripts/replace-rncore-version.js | 59 +- 22 files changed, 3433 insertions(+), 1074 deletions(-) create mode 100644 packages/react-native/scripts/cocoapods/rncore_facades.rb create mode 100644 packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-compose.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-include-baseline.json create mode 100644 packages/react-native/scripts/ios-prebuild/headers-inventory.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-spec.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-verify.js delete mode 100644 packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h delete mode 100644 packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h delete mode 100644 packages/react-native/scripts/ios-prebuild/templates/module.modulemap delete mode 100644 packages/react-native/scripts/ios-prebuild/vfs.js diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index ad47de482fef..6809e7ffceff 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -157,6 +157,22 @@ jobs: pattern: prebuild-ios-core-headers-${{ matrix.flavor }}-* path: packages/react-native/.build/headers merge-multiple: true + - name: Download ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + uses: actions/download-artifact@v7 + with: + name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + path: /tmp/third-party/ + - name: Extract ReactNativeDependencies + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + shell: bash + run: | + # ReactNativeHeaders.xcframework (built by the compose step) folds in + # the third-party deps namespaces (folly/glog/boost/...), so the deps + # headers must be staged here too — not just in build-slices. + 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: 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 @@ -173,11 +189,24 @@ jobs: run: | cd packages/react-native node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org" + - name: Verify composed headers + if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' + run: | + # Generator-time header gate: include-health ratchet, structural + # byte-compare of module maps/umbrellas against the spec render, and + # compile smokes (React module + every namespace module + the + # 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 }}" - name: Compress and Rename XCFramework if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | cd packages/react-native/.build/output/xcframeworks/${{matrix.flavor}} - tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework + # Ship BOTH xcframeworks: React-Core-prebuilt's prepare_command flattens + # ReactNativeHeaders.xcframework's Headers (incl. module.modulemap) into the + # pod. Omitting it leaves consumers without React-Core-prebuilt/Headers/module.modulemap. + tar -cz -f ../ReactCore${{matrix.flavor}}.xcframework.tar.gz React.xcframework ReactNativeHeaders.xcframework - name: Compress and Rename dSYM if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' run: | diff --git a/packages/react-native/React-Core-prebuilt.podspec b/packages/react-native/React-Core-prebuilt.podspec index 98aa6a09b1df..36d191278a08 100644 --- a/packages/react-native/React-Core-prebuilt.podspec +++ b/packages/react-native/React-Core-prebuilt.podspec @@ -17,36 +17,60 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.platforms = min_supported_versions s.source = source + + # We vend two xcframeworks that ship together in the prebuilt tarball: + # - React.xcframework: the compiled core. Its per-slice React.framework carries + # every header + the framework module map, so `#import ` + # and `@import React;` resolve through FRAMEWORK_SEARCH_PATHS automatically. + # - ReactNativeHeaders.xcframework: headers-only. Carries every other namespace + # (, , folly, glog, ...). Its headers are flattened into a + # top-level Headers/ (see prepare_command) and exposed via the standard pod + # header search path. ( is supplied by the hermes-engine pod here; + # it is folded into ReactNativeHeaders only on the SwiftPM consumer side.) + # There is no clang VFS overlay. s.vendored_frameworks = "React.xcframework" s.preserve_paths = '**/*.*' - s.header_mappings_dir = 'React.xcframework/Headers' - s.source_files = 'React.xcframework/Headers/**/*.{h,hpp}' - - s.module_name = 'React' - s.module_map = 'React.xcframework/Modules/module.modulemap' - s.public_header_files = 'React.xcframework/Headers/**/*.h' + s.header_mappings_dir = 'Headers' + s.source_files = 'Headers/**/*.{h,hpp}' + s.public_header_files = 'Headers/**/*.h' add_rn_third_party_dependencies(s) - # We need to make sure that the React.xcframework is copied correctly - in the downloaded tarball - # the root directory is the framework, but when using it we need to have it in a subdirectory - # called React.xcframework, so we need to move the contents of the tarball into that directory. - # This is done in the prepare_command. - # We need to make sure that the headers are copied to the right place - local tar.gz has a different structure - # than the one from the maven repo + # The downloaded tarball ships React.xcframework and ReactNativeHeaders.xcframework + # at its root. We make sure React.xcframework is in its own subdirectory (the Maven + # tarball lays the framework contents at the root; the local tar.gz has a different + # structure) and flatten ReactNativeHeaders' headers into a top-level Headers/ dir + # so CocoaPods exposes them on the header search path. s.prepare_command = <<~'CMD' CURRENT_PATH=$(pwd) XCFRAMEWORK_PATH="${CURRENT_PATH}/React.xcframework" - # Check if XCFRAMEWORK_PATH is empty - if [ -z "$XCFRAMEWORK_PATH" ]; then - echo "ERROR: XCFRAMEWORK_PATH is empty." - exit 0 + # Flatten ReactNativeHeaders' headers (identical across slices) into Headers/ + # BEFORE we sweep stray root entries into React.xcframework. Fail closed: + # a tarball without ReactNativeHeaders.xcframework (an artifact published + # before the headers-spec layout, or a truncated download) would otherwise + # yield a green install with an empty Headers/ and every or + # include failing much later, far from the cause. + mkdir -p Headers + RNH_XCFRAMEWORK_PATH=$(find "$CURRENT_PATH" -type d -name "ReactNativeHeaders.xcframework" | head -n 1) + if [ -z "$RNH_XCFRAMEWORK_PATH" ]; then + echo "[React-Core-prebuilt] ERROR: ReactNativeHeaders.xcframework not found in the prebuilt tarball." >&2 + echo "The artifact predates the headers-spec layout or is incomplete; use a matching react-native version." >&2 + exit 1 + fi + RNH_HEADERS_PATH=$(find "$RNH_XCFRAMEWORK_PATH" -type d -name "Headers" | head -n 1) + if [ -z "$RNH_HEADERS_PATH" ]; then + echo "[React-Core-prebuilt] ERROR: no Headers directory inside $RNH_XCFRAMEWORK_PATH." >&2 + exit 1 fi + cp -R "$RNH_HEADERS_PATH/." Headers + rm -rf "$RNH_XCFRAMEWORK_PATH" mkdir -p "${XCFRAMEWORK_PATH}" - find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 ! -name "$(basename "$XCFRAMEWORK_PATH")" -exec mv {} "$XCFRAMEWORK_PATH" \; + find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 \ + ! -name "$(basename "$XCFRAMEWORK_PATH")" ! -name "Headers" \ + -exec mv {} "$XCFRAMEWORK_PATH" \; CMD # If we are passing a local tarball, we don't want to switch between Debug and Release diff --git a/packages/react-native/scripts/cocoapods/fabric.rb b/packages/react-native/scripts/cocoapods/fabric.rb index b6c8264a3546..ccb10438940b 100644 --- a/packages/react-native/scripts/cocoapods/fabric.rb +++ b/packages/react-native/scripts/cocoapods/fabric.rb @@ -11,7 +11,7 @@ def setup_fabric!(react_native_path: "../node_modules/react-native") pod 'React-Fabric', :path => "#{react_native_path}/ReactCommon" pod 'React-FabricComponents', :path => "#{react_native_path}/ReactCommon" pod 'React-graphics', :path => "#{react_native_path}/ReactCommon/react/renderer/graphics" - pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true + rncore_pod 'React-RCTFabric', :path => "#{react_native_path}/React", :modular_headers => true pod 'React-ImageManager', :path => "#{react_native_path}/ReactCommon/react/renderer/imagemanager/platform/ios" pod 'React-FabricImage', :path => "#{react_native_path}/ReactCommon" end diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 252588c98432..1ff33b69c135 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -11,30 +11,16 @@ ### Adds ReactNativeCore-prebuilt as a dependency to the given podspec if we're not ### building ReactNativeCore from source (then this function does nothing). +### +### `` resolves through the vendored React.framework; every other namespace +### (``, ``, ``, ...) resolves through the flattened +### ReactNativeHeaders headers that React-Core-prebuilt exposes. The header search path +### and the ReactNativeHeaders module-map activation are NOT added here: they are applied +### post-install by configure_aggregate_xcconfig, which covers aggregate, third-party AND +### these pods from a single injection site. No clang VFS overlay. def add_rncore_dependency(s) if !ReactNativeCoreUtils.build_rncore_from_source() - # Add the dependency s.dependency "React-Core-prebuilt" - - current_pod_target_xcconfig = s.to_hash["pod_target_xcconfig"] || {} - current_pod_target_xcconfig = current_pod_target_xcconfig.to_h unless current_pod_target_xcconfig.is_a?(Hash) - - # Add VFS overlay flags for both Objective-C and Swift - # The VFS overlay file is pre-resolved at pod install time for each platform slice. - # We reference it directly in the xcframework using the React-VFS.yaml file that - # is written to the React-Core-prebuilt folder during setup_vfs_overlay. - # See scripts/ios-prebuild/__docs__/README.md for more details on VFS overlays. - vfs_overlay_flag = "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - current_pod_target_xcconfig["OTHER_CFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CFLAGS"] += " #{vfs_overlay_flag}" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_CPLUSPLUSFLAGS"] += " #{vfs_overlay_flag}" - # For Swift, we need to use -Xcc to pass flags to the underlying Clang compiler - # Both the flag and its argument need separate -Xcc prefixes - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] ||= "$(inherited)" - current_pod_target_xcconfig["OTHER_SWIFT_FLAGS"] += " -Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - - s.pod_target_xcconfig = current_pod_target_xcconfig end end @@ -521,71 +507,34 @@ def self.get_nightly_npm_version() return latest_nightly end - # Processes the VFS overlay file from the React.xcframework to resolve the ${ROOT_PATH} placeholder. - # This method should be called from react_native_post_install after pod install completes. + # Single post-install injection site for the prebuilt header resolution. Adds the + # ReactNativeHeaders search path + module-map activation to the aggregate (main app) + # target AND every pod target — RN core pods, third-party pods alike. (add_rncore_dependency + # only declares the React-Core-prebuilt dependency; it no longer touches xcconfigs.) # - # The VFS overlay file maps header import paths to their actual locations within the xcframework. - # Since the xcframework contains platform-specific slices, we generate a resolved VFS file for each - # slice and also create a default VFS file that can be used immediately (before script phases run). - def self.process_vfs_overlay() - return if @@build_from_source - - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - xcframework_path = File.join(prebuilt_path, "React.xcframework") - vfs_template_path = File.join(xcframework_path, "React-VFS-template.yaml") - - unless File.exist?(vfs_template_path) - rncore_log("VFS overlay template not found at #{vfs_template_path}", :error) - exit 1 - end - - rncore_log("Processing VFS overlay file...") - - # Read the template content - vfs_template_content = File.read(vfs_template_path) - - # Write the VFS file - use the top-level xcframework path - # so that ${ROOT_PATH}/Headers points to the xcframework's Headers folder - resolved_vfs_content = vfs_template_content.gsub('${ROOT_PATH}', xcframework_path) - resolved_vfs_path = File.join(prebuilt_path, "React-VFS.yaml") - File.write(resolved_vfs_path, resolved_vfs_content) - rncore_log(" Created VFS overlay at #{resolved_vfs_path}") - - rncore_log("VFS overlay setup complete") - end - - # Configures the xcconfig files for aggregate (main app) targets to enable VFS overlay for React Native Core. - # This is needed because the main app target does not go through podspec processing, - # so it won't get the VFS overlay flags from add_rncore_dependency. + # `` resolves through the vendored React.framework; this adds the search + # path to the flattened ReactNativeHeaders headers (every other namespace). There is + # no clang VFS overlay. # # Parameters: # - installer: The CocoaPods installer object def self.configure_aggregate_xcconfig(installer) return if @@build_from_source - prebuilt_path = File.join(Pod::Config.instance.project_pods_root, "React-Core-prebuilt") - vfs_overlay_path = File.join(prebuilt_path, "React-VFS.yaml") - - unless File.exist?(vfs_overlay_path) - rncore_log("VFS overlay not found at #{vfs_overlay_path}, skipping prebuilt xcconfig configuration", :error) - exit 1 - end - rncore_log("Configuring xcconfig for prebuilt React Native Core...") - vfs_overlay_flag = " -ivfsoverlay \"#{vfs_overlay_path}\"" - swift_vfs_overlay_flag = " -Xcc -ivfsoverlay -Xcc \"#{vfs_overlay_path}\"" + headers_search_path = " \"$(PODS_ROOT)/React-Core-prebuilt/Headers\"" - # Add flags to aggregate target xcconfigs (these are used by the main app target) + # Add the header search path to aggregate target xcconfigs (used by the main app target) installer.aggregate_targets.each do |aggregate_target| aggregate_target.xcconfigs.each do |config_name, config_file| - add_vfs_overlay_flags(config_file.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(config_file.attributes, headers_search_path) xcconfig_path = aggregate_target.xcconfig_path(config_name) config_file.save_as(xcconfig_path) end end - # Add flags to ALL pod targets (for third-party pods that don't call add_rncore_dependency) + # Add the header search path to ALL pod targets (for third-party pods that don't call add_rncore_dependency) installer.pod_targets.each do |pod_target| pod_target.build_settings.each do |config_name, build_settings| xcconfig_path = pod_target.xcconfig_path(config_name) @@ -593,11 +542,11 @@ def self.configure_aggregate_xcconfig(installer) xcconfig = Xcodeproj::Config.new(xcconfig_path) - # Check if VFS overlay is already present - other_cflags = xcconfig.attributes["OTHER_CFLAGS"] || "" - next if other_cflags.include?("ivfsoverlay") + # Skip if the prebuilt header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("React-Core-prebuilt/Headers") - add_vfs_overlay_flags(xcconfig.attributes, vfs_overlay_flag, swift_vfs_overlay_flag) + add_prebuilt_header_search_paths(xcconfig.attributes, headers_search_path) xcconfig.save_as(xcconfig_path) end end @@ -605,12 +554,18 @@ def self.configure_aggregate_xcconfig(installer) rncore_log("Prebuilt xcconfig configuration complete") end - # Helper method to add VFS overlay flags to an xcconfig attributes map - def self.add_vfs_overlay_flags(attributes, vfs_overlay_flag, swift_vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CPLUSPLUSFLAGS", vfs_overlay_flag) - ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", swift_vfs_overlay_flag) + # Helper method to add the prebuilt ReactNativeHeaders header search path to an xcconfig attributes map + def self.add_prebuilt_header_search_paths(attributes, headers_search_path) + ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "HEADER_SEARCH_PATHS", headers_search_path) # Suppress incomplete umbrella warnings for the prebuilt frameworks (it is expected, as our umbrella headers do not include all headers) ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc -Wno-incomplete-umbrella") + # Activate the ReactNativeHeaders module map so the relocated namespaces + # (`yoga`, `RCTDeprecation`, `ReactNativeHeaders_react`, ...) are modular — + # otherwise the React framework's clang explicit-module precompile trips + # -Wnon-modular-include-in-framework-module on `` / ``. + # 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_SWIFT_FLAGS", " -Xcc" + module_map_flag) end end diff --git a/packages/react-native/scripts/cocoapods/rncore_facades.rb b/packages/react-native/scripts/cocoapods/rncore_facades.rb new file mode 100644 index 000000000000..fa3b935aed48 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/rncore_facades.rb @@ -0,0 +1,285 @@ +# 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 'json' +require 'fileutils' + +# Facade podspecs for the prebuilt React Native Core path. +# +# In prebuilt mode the compiled code AND headers for the React core pods live +# entirely inside React.xcframework + React-Core-prebuilt (which flattens the +# ReactNativeHeaders namespaces into its Headers/). Re-installing the SOURCE +# podspecs in that mode is what makes them ship duplicate headers that shadow the +# prebuilt artifact (via HEADER_SEARCH_PATHS, CocoaPods .hmap header maps, and +# the all-product-headers VFS overlay) and break the React framework's clang +# explicit-module precompile. +# +# Instead we install dependency-only FACADE podspecs for those names: they ship +# no source files and (with one narrow exception, see FACADE_REEXPOSED_HEADERS) +# no headers, so CocoaPods makes them PBXAggregateTarget +# placeholders (should_build? == false) and nothing is laid down to shadow. Each +# facade depends on React-Core-prebuilt so its consumers transitively pick up the +# prebuilt framework + headers. The pod NAMES still resolve, so ReactCodegen, +# third-party modules, and RN's own podspec graph keep resolving `React-Core`, +# `Yoga`, `React-Core/Default`, etc. +# +# MAINTENANCE MODEL: the set of facaded pods is explicit (FACADE_PODS) so the +# prebuilt rollout can be staged, but each facade's VERSION and SUBSPECS are +# DERIVED from the real podspec at `pod install` time (Pod::Specification.from_file). +# That removes the drift risk that would otherwise bite third-party libraries: +# if React adds/renames `React-Core/`, the facade exposes it +# automatically — nobody has to hand-maintain a parallel subspec list. +# +# This is staged: phase 1 facades a small set and KEEPS the existing +# podspec_sources / add_rncore_dependency / configure_aggregate_xcconfig / +# -fmodule-map-file machinery. The set is expanded until the cold prebuilt +# build passes; the distributed prebuilt helpers are only deleted afterwards. +module RNCoreFacades + # pod name => podspec path (relative to the react-native package root). + # These are the React-core pods whose code + headers are fully provided by + # the prebuilt React.xcframework / React-Core-prebuilt. Start small; expand as + # the cold build surfaces more shadowing pods. (NOTE: not every caller of + # add_rncore_dependency belongs here — e.g. ReactCodegen depends on the + # prebuilt but still builds its own generated sources, so it is NOT a facade.) + FACADE_PODS = { + "React-Core" => "React-Core.podspec", + "React-RCTFabric" => "React/React-RCTFabric.podspec", + "React-RCTRuntime" => "React/Runtime/React-RCTRuntime.podspec", + "Yoga" => "ReactCommon/yoga/Yoga.podspec", + "RCTDeprecation" => "ReactApple/Libraries/RCTFoundation/RCTDeprecation/RCTDeprecation.podspec", + "FBLazyVector" => "Libraries/FBLazyVector/FBLazyVector.podspec", + "RCTRequired" => "Libraries/Required/RCTRequired.podspec", + } + + # A facade ships NO headers by default — the prebuilt React.framework / + # React-Core-prebuilt own them. NARROW EXCEPTION: a few headers live ONLY + # inside React.framework/Headers (served angle-only as , e.g. + # because they reach unguarded C++ and are excluded from the framework module + # map) and are NOT in the flattened React-Core-prebuilt/Headers. A quoted + # `#import ".h"` therefore has no resolution target in prebuilt mode. + # + # Community Fabric modules quote-import RCTFabricComponentsPlugins.h (47x in a + # full app: slider, maps, pager-view, keyboard-controller, ...). In source it + # was vended by React-RCTFabric at header_dir "React", which put it in + # dependents' CocoaPods header maps so the bare quoted name resolved. The + # facade dropped it. We re-vend JUST that header here so dependents' header + # maps carry it again, exactly as the source pod did. + # + # Re-exposing a SINGLE header (not the whole React/ namespace) does not put + # / on -I, so it does NOT reintroduce the + # -Wnon-modular-include-in-framework-module shadowing the modular prebuilt + # layout exists to eliminate. The header is core-only (matches source: it + # only ever declared RN's built-in components; third-party Fabric components + # register via the codegen RCTThirdPartyComponentsProvider, shipped by the + # non-facaded ReactCodegen pod). + # + # pod name => { "header_dir" =>

, "globs" => [] } + FACADE_REEXPOSED_HEADERS = { + "React-RCTFabric" => { + "header_dir" => "React", + "globs" => ["Fabric/Mounting/ComponentViews/RCTFabricComponentsPlugins.h"], + }, + } + + # Sub-directory (relative to the install root) that holds the generated facades. + FACADE_RELDIR = File.join("build", "rncore-facades") + + @@install_root = nil + + # True when `name` should be installed as a facade instead of its source podspec. + def self.facade?(name) + FACADE_PODS.key?(name) + end + + # Generates the facade podspecs and returns the base directory holding them. + # Each facade gets its OWN sub-directory containing a single + # `.podspec.json`, so it can be installed as a LOCAL pod via + # `:path => `. `:path` (PathSource) uses the spec in place and never + # downloads `spec.source` — unlike `:podspec` (PodspecSource), which is an + # *external* source whose `root_spec.source` CocoaPods would actually fetch + # (i.e. git-clone react-native for every empty facade). Idempotent; safe to + # call once per `pod install`. + # + # `react_native_path` locates the real podspecs we mirror. version + subspecs + + # default_subspecs + resources are all DERIVED from the real spec so the facade + # stays graph- and resource-equivalent to the source pod. A facaded pod whose + # real podspec can't be read is a hard error (see load_real_spec) — silently + # shipping an empty facade would hide exactly the drift this guards against. + def self.generate(react_native_path, install_root, version, ios_version) + @@install_root = install_root.to_s + abs_base = File.join(@@install_root, FACADE_RELDIR) + FileUtils.mkdir_p(abs_base) + FACADE_PODS.each do |name, podspec_rel_path| + podspec_path = File.join(react_native_path.to_s, podspec_rel_path) + real = load_real_spec(podspec_path, name) + podspec_dir = File.dirname(podspec_path) + dir = File.join(abs_base, name) + FileUtils.mkdir_p(dir) + + spec = { + "name" => name, + "version" => real.version.to_s, + "summary" => "Prebuilt facade for #{name} (code + headers live in React-Core-prebuilt).", + "homepage" => "https://reactnative.dev/", + "license" => "MIT", + "authors" => "Meta Platforms, Inc. and its affiliates", + "platforms" => { "ios" => ios_version }, + # Required podspec attribute, but never fetched: the pod is installed + # as a LOCAL pod (`:path => `), which uses this spec in place and + # ships no source_files. Placeholder only. + "source" => { "git" => "https://github.com/facebook/react-native.git" }, + "dependencies" => { "React-Core-prebuilt" => [] }, + } + + # Preserve non-code RESOURCES (privacy manifest, i18n bundles, ...). They + # don't shadow headers, and React-Core-prebuilt doesn't vend them, so the + # facade must carry them or prebuilt installs lose them. The matched + # files are COPIED into the facade dir (like the re-exposed headers): + # CocoaPods file accessors only match globs against files under the pod + # root, so a `..`-escaping glob back into the source tree would silently + # match nothing and the bundles would ship empty. + resource_bundles = derive_resource_bundles(real, podspec_dir, dir) + spec["resource_bundles"] = resource_bundles unless resource_bundles.empty? + resources = derive_resources(real, podspec_dir, dir) + spec["resources"] = resources unless resources.empty? + + # Re-vend the narrow set of angle-only framework headers that community + # modules quote-import (see FACADE_REEXPOSED_HEADERS). The header is + # COPIED into the facade dir (a self-contained snapshot — robust, no + # `..` globs reaching out of the pod) and exposed as a public header. + # It's header-only (a `.h` has nothing to compile, so the facade stays + # a placeholder), but CocoaPods lays it into + # Pods/Headers/Public/// and dependents' .hmap, + # restoring quoted resolution exactly as the source pod did. + reexposed = FACADE_REEXPOSED_HEADERS[name] + if reexposed + copied = copy_reexposed_headers(reexposed["globs"], podspec_dir, dir, name) + unless copied.empty? + spec["source_files"] = copied + spec["public_header_files"] = copied + spec["header_dir"] = reexposed["header_dir"] if reexposed["header_dir"] + end + end + + # Preserve default_subspec so a bare `pod ''` resolves to the SAME + # subspec graph as the source pod (without it CocoaPods pulls every + # subspec, which is not graph-equivalent). + defaults = Array(real.default_subspecs) + spec["default_subspecs"] = defaults unless defaults.empty? + + subspecs = derive_subspecs(real) + unless subspecs.empty? + spec["subspecs"] = subspecs.map do |ss| + { "name" => ss, "dependencies" => { "React-Core-prebuilt" => [] } } + end + end + + File.write(File.join(dir, "#{name}.podspec.json"), JSON.pretty_generate(spec)) + end + abs_base + end + + # Facade dir for ``, RELATIVE to the install root — pass to `pod :path =>`. + # Relative (not absolute) so the path CocoaPods records in Podfile.lock is + # portable rather than machine-specific. + def self.facade_path(name) + File.join(FACADE_RELDIR, name) + end + + # Loads the real podspec so we can mirror its structure. A facaded pod MUST have + # a readable real podspec — if it's missing or unparseable we raise rather than + # ship an empty facade, since that would silently drop subspecs/resources (the + # very drift this mechanism exists to prevent). + def self.load_real_spec(path, name) + unless File.exist?(path) + raise "[RNCoreFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \ + "Update FACADE_PODS in rncore_facades.rb if the podspec moved." + end + Pod::Specification.from_file(path) + rescue => e + raise "[RNCoreFacades] Failed to read real podspec for facaded pod '#{name}' at #{path}: #{e.message}" + end + private_class_method :load_real_spec + + # Library (non-test, non-app) subspec names of the real spec, so third-party + # libs depending on `/` keep resolving. Derived, never hand-listed. + def self.derive_subspecs(real) + real.subspecs + .reject { |ss| ss.test_specification? || (ss.respond_to?(:app_specification?) && ss.app_specification?) } + .map(&:base_name) + end + private_class_method :derive_subspecs + + # Effective resource_bundles of the real spec (e.g. React-Core_privacy), + # copied into the facade dir under resources//. Unions the + # `resource_bundle` (singular) and `resource_bundles` (plural) DSL forms. + def self.derive_resource_bundles(real, podspec_dir, facade_dir) + out = {} + [real.attributes_hash["resource_bundle"], real.attributes_hash["resource_bundles"]].each do |rb| + next unless rb.is_a?(Hash) + rb.each do |bundle, globs| + copied = copy_resources(Array(globs), podspec_dir, facade_dir, File.join("resources", bundle)) + out[bundle] = copied unless copied.empty? + end + end + out + end + private_class_method :derive_resource_bundles + + # Copy the re-exposed header(s) into the facade dir (flat) and return the + # facade-relative source_files entries. `globs` are resolved against the real + # podspec dir. A glob that matches nothing is a hard error: the whole point is + # to keep a quoted-import header resolvable, so silently shipping a facade + # without it would reintroduce the exact "file not found" we're fixing. + def self.copy_reexposed_headers(globs, podspec_dir, facade_dir, name) + copied = [] + Array(globs).each do |g| + matches = Dir.glob(File.expand_path(g, podspec_dir)) + if matches.empty? + raise "[RNCoreFacades] Re-exposed header glob '#{g}' for facade '#{name}' " \ + "matched no files under #{podspec_dir}. Update FACADE_REEXPOSED_HEADERS." + end + matches.each do |src| + base = File.basename(src) + FileUtils.cp(src, File.join(facade_dir, base)) + copied << base + end + end + copied.uniq + end + private_class_method :copy_reexposed_headers + + # Loose `resources` of the real spec, copied into the facade dir. + def self.derive_resources(real, podspec_dir, facade_dir) + copy_resources(Array(real.attributes_hash["resources"]), podspec_dir, facade_dir, "resources") + end + private_class_method :derive_resources + + # Copies everything the resource globs match (files or whole directories, + # e.g. *.lproj bundles) into `//` and returns + # facade-relative paths for the copies. Globs resolve against the real + # podspec dir. A glob that matches nothing is a hard error: the real spec + # declared a resource, so silently dropping it would make the facade drift. + # rm_rf-before-cp_r keeps the snapshot fresh across repeated `pod install`s. + def self.copy_resources(globs, podspec_dir, facade_dir, subdir) + copied = [] + globs.each do |g| + matches = Dir.glob(File.expand_path(g, podspec_dir)) + if matches.empty? + raise "[RNCoreFacades] Resource glob '#{g}' matched no files under #{podspec_dir}. " \ + "Update the facade resource derivation if the podspec layout changed." + end + dest_dir = File.join(facade_dir, subdir) + FileUtils.mkdir_p(dest_dir) + matches.each do |src| + FileUtils.rm_rf(File.join(dest_dir, File.basename(src))) + FileUtils.cp_r(src, dest_dir) + copied << File.join(subdir, File.basename(src)) + end + end + copied.uniq + end + private_class_method :copy_resources +end diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/README.md b/packages/react-native/scripts/ios-prebuild/__docs__/README.md index 4d2786314714..c411934c1742 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/README.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/README.md @@ -111,123 +111,48 @@ The build process uses specific `xcodebuild` flags: - Build times vary depending on the target platform and configuration - XCFrameworks support multiple architectures in a single bundle -## Known Issues - -The generated XCFrameworks currently use CocoaPods-style header structures -rather than standard framework header conventions. This may cause modularity -issues when: - -- Consuming the XCFrameworks in projects that expect standard framework headers -- Building dependent frameworks that rely on proper module boundaries -- Integrating with Swift Package Manager projects expecting modular headers - -## VFS Overlay System - -The prebuilt XCFrameworks use Clang's Virtual File System (VFS) overlay -mechanism to enable header imports without modifying the actual header file -structure. This is necessary because React Native's headers are organized -differently than standard framework conventions. - -### Overview - -The VFS overlay creates a virtual mapping between the import paths used in code -(e.g., `#import `) and the actual physical -locations of headers within the XCFramework. This allows the prebuilt frameworks -to work seamlessly while maintaining the original import syntax. - -### Build-Time VFS Generation (`vfs.js`) - -The `vfs.js` script creates a VFS overlay template during the prebuild process: - -1. **Header Collection** (`headers.js`): Scans all podspec files in the React - Native package to discover header files and their target import paths. - -2. **VFS Structure Building**: The `buildVFSStructure()` function creates a - hierarchical directory tree representation from the header mappings. Clang's - VFS overlay requires directories to contain their children in a tree - structure. - -3. **YAML Generation**: The `generateVFSOverlayYAML()` function converts the VFS - structure into Clang's expected YAML format. - -4. **Template Creation**: The generated overlay uses `${ROOT_PATH}` as a - placeholder for the actual installation path. This template is included in - the XCFramework as `React-VFS-template.yaml`. - -#### Key Functions - -- `createVFSOverlay(rootFolder)`: Main entry point that generates the complete - VFS overlay YAML string -- `createVFSOverlayContents(rootFolder)`: Creates the VFS overlay object - structure -- `buildVFSStructure(mappings)`: Builds the hierarchical directory tree from - flat mappings -- `resolveVFSOverlay(vfsTemplate, rootPath)`: Replaces `${ROOT_PATH}` with the - actual path - -### Runtime VFS Processing (CocoaPods) - -When consuming prebuilt frameworks via CocoaPods, the VFS overlay is processed -at pod install time by `rncore.rb`: - -#### `process_vfs_overlay()` - -Called during `react_native_post_install`, this method: - -1. Reads the `React-VFS-template.yaml` from the XCFramework -2. Resolves the `${ROOT_PATH}` placeholder with the actual XCFramework path -3. Writes the resolved overlay to - `$(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml` - -#### `add_rncore_dependency(s)` - -Adds VFS overlay compiler flags to podspecs that depend on React Native: - -```ruby -# For C/C++ compilation -OTHER_CFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -OTHER_CPLUSPLUSFLAGS += "-ivfsoverlay $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" - -# For Swift compilation (flags passed to underlying Clang) -OTHER_SWIFT_FLAGS += "-Xcc -ivfsoverlay -Xcc $(PODS_ROOT)/React-Core-prebuilt/React-VFS.yaml" -``` - -#### `configure_aggregate_xcconfig(installer)` - -Configures VFS overlay flags for: - -- **Aggregate targets**: Main app targets that don't go through podspec - processing -- **All pod targets**: Third-party pods that don't explicitly call - `add_rncore_dependency` - -This ensures all compilation units in the project can resolve React Native -headers through the VFS overlay. - -### VFS Overlay Format - -The VFS overlay uses Clang's hierarchical YAML format: - -```yaml -version: 0 -case-sensitive: false -roots: - - name: '${ROOT_PATH}/Headers' - type: 'directory' - contents: - - name: 'react' - type: 'directory' - contents: - - name: 'renderer' - type: 'directory' - contents: - - name: 'Size.h' - type: 'file' - external-contents: '${ROOT_PATH}/Headers/React/react/renderer/Size.h' -``` - -The structure maps virtual paths (what the compiler sees) to physical paths -(where the files actually exist in the XCFramework). +## Header Resolution (headers-spec layout) + +The prebuilt XCFrameworks ship a **headers-spec layout** so that header imports +resolve through plain header/framework search paths — there is **no clang VFS +overlay**. The layout contract is defined and validated in code: + +- `headers-spec.js`: the executable layout contract (rules R1–R8) — which + namespaces are hoisted, which carry module maps, and how collisions are + rejected. +- `headers-inventory.js`: scans the source tree to build the live header + inventory that feeds the spec. +- `headers-compose.js`: emits the layout. `emitReactFrameworkHeaders()` writes + the `React/` and bare-aliased headers into every slice's + `React.framework/Headers`, and `buildReactNativeHeadersXcframework()` + assembles the headers-only `ReactNativeHeaders.xcframework` carrying every + other namespace (incl. `react/`) plus the third-party dependency namespaces + (`folly`, `glog`, `boost`, `fmt`, `double-conversion`, `fast_float`). The + Hermes public headers (``) are folded in only on the SwiftPM + consumer side (`ensureHeadersLayout`); the published prebuild artifact does + not yet carry them (TODO in `xcframework.js`). + +### Artifacts + +The prebuild (`xcframework.js`) always produces: + +- `React.xcframework` — the compiled React core. Each slice's `React.framework` + carries the headers-spec layout (every `` header + the framework + module map), which is what both CocoaPods and SwiftPM consume. +- `ReactNativeHeaders.xcframework` — headers-only; carries every other + namespace. Consumed by SwiftPM as a `binaryTarget` and by CocoaPods via the + `React-Core-prebuilt` pod (headers flattened onto the header search path). + +### CocoaPods consumption + +The `React-Core-prebuilt` pod vends `React.xcframework` (so `` and +`@import React;` resolve through the framework module via +`FRAMEWORK_SEARCH_PATHS`) and flattens `ReactNativeHeaders.xcframework`'s +headers into a top-level `Headers/` exposed on the pod header search path (so +``, ``, `` resolve). `rncore.rb` adds the +`HEADER_SEARCH_PATHS` entry to `React-Core-prebuilt/Headers` for podspec, +aggregate (main app), and third-party pod targets. No `-ivfsoverlay` flags are +added. ## Integrating in your project with Cocoapods diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md new file mode 100644 index 000000000000..6b58995f1696 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md @@ -0,0 +1,383 @@ +# How the prebuilt header layout is created — rules and rationale + +This documents the header-generation system introduced by the VFS-overlay +removal PR (#57285): how every header that ships in the prebuilt artifacts is +discovered, classified, placed, and made modular. The system lives in three +scripts under `scripts/ios-prebuild/`: + +| Script | Role | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `headers-inventory.js` | **Discover + classify** every shipped header (the facts) | +| `headers-spec.js` | **The rules** (R1–R11) — turns the inventory into a layout plan + module maps | +| `headers-compose.js` | **Emit** — projects the plan into `React.xcframework` and `ReactNativeHeaders.xcframework` | +| `headers-verify.js` | **Gate** — generator-time verification: include-health ratchet, structural byte-compare, consumer-shaped compile smokes (runs in the prebuild CI compose job) | + +One principle drives the whole design: **content authority = the source files, +layout authority = the spec.** No header is ever edited; only _where it goes_ +and _which module owns it_ is computed. + +## Why this exists (what replaced the VFS overlay) + +Previously a clang VFS overlay synthesized a virtual header tree at build time +so includes like ``, ``, `` +would resolve against the prebuilt artifact. That approach broke down under +clang **explicit modules** (the overlay shadowed the framework's own module), +made code-signature stability awkward, and required consumer-side machinery. + +The replacement is purely physical: two artifacts whose on-disk layout makes +every include form resolve through standard mechanisms — +`FRAMEWORK_SEARCH_PATHS` for `` and a plain header search path for +everything else. No overlay, no include rewriting, no consumer flags. + +## The pipeline + +```text +podspecs ──► headers-inventory.js ──► inventory (facts per header) + │ + ▼ + headers-spec.js (rules R1–R10) + │ plan: what goes where + module maps + ▼ + headers-compose.js (emission) + │ │ + ▼ ▼ + React.xcframework ReactNativeHeaders.xcframework + (binary + React/ ns) (headers-only, all other ns) +``` + +## Stage 1 — Inventory: discover and classify (headers-inventory.js) + +### Discovery + +Headers are enumerated through the **same podspec-driven discovery the prebuild +itself uses** (`headers.js`), so the inventory cannot drift from the shipped +set. Each header gets: + +- a **natural path** — the include path consumers actually write + (`React/RCTBridge.h`, `react/renderer/core/ShadowNode.h`, `yoga/Yoga.h`). This + is the canonical identity; the podspec `header_dir` supplies the namespace, + and header_dir-less pods get their pod name as prefix. +- one or more **identities** — the physical source file plus the pod-namespaced + path. `React_RCTAppDelegate` headers get a **second, synthetic bare-root + identity** (`RCTAppDelegate.h` with no prefix) because app templates + historically wrote `#import `. + +### Language classification + +`scanHeader()` does a guard-aware scan of each header's text: + +- Tracks a `#if/#ifdef __cplusplus` **stack**, so C++ constructs and includes + that only exist under a C++ guard don't taint the ObjC surface + (`cxxGuarded: true` edges). +- ObjC markers: `@interface/@protocol/@implementation/@class/@end`, + `NS_ASSUME_NONNULL_BEGIN`. +- C++ markers: `namespace`, `template <`, `extern "C++"`, `enum class`, + `constexpr`, `using namespace/alias` — plus one non-obvious heuristic: a **C++ + default member initializer inside a struct/class aggregate** + (`struct X { CGFloat size = NAN; };`), which is illegal in C/ObjC but has no + keyword the line scan would catch. + +Result: `lang ∈ {objc, objcxx, cxx, c}`. + +### Include classification + +Every `#include/#import` is resolved and classified: `internal` (another shipped +header — quoted includes are resolved against the source dir and mapped back to +a natural path), `thirdParty` (folly/glog/boost/fmt/ +double-conversion/fast_float), `hermes`, `system` (Apple SDK), `std`, +`metaInternal` (FB-internal, never resolvable in OSS), `otherPlatform` +(android/jni), `notShipped` (our namespace but not in the shipped set — a +flattening mismatch), `unresolved`. + +### The bucket — "can this header live in a clang module?" + +The key output. A **fixpoint over unguarded include edges** propagates two facts +through the internal include graph: _does this header transitively reach C++?_ +and _which third-party libs does it reach?_ — considering only edges an ObjC +consumer would actually follow (cxx-guarded edges are skipped). + +| Bucket | Meaning | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `objc-modular-candidate` | Pure ObjC surface; reaches no C++ and no third-party lib unguarded → **can** be a module member | +| `objc-blocked` | ObjC header, but transitively reaches C++/third-party (e.g. Fabric headers importing ``) → cannot be a clean module member | +| `objcxx` | Mixed ObjC++ in the header itself | +| `cxx` | Pure C++ (also anything `.hpp`) | + +Only `objc-modular-candidate` headers can enter module maps; everything else +stays **textual** (resolved by search path at the consumer's use site — exactly +the semantics the old VFS overlay provided). + +## Stage 2 — The rules (headers-spec.js) + +The spec's docblock is the contract; each rule exists because of a concrete +failure mode: + +**R1 — `React.framework/Headers` root = the `React/` namespace, hoisted.** The +framework _name_ supplies the `React/` prefix, so `#import ` +resolves verbatim through `FRAMEWORK_SEARCH_PATHS`. Bare root aliases (R6) also +live here. The lowercase `react/` namespace is deliberately **not** here: +resolving `` through `React.framework` requires case-folding +`react.framework` → `React.framework`, which only works on case-insensitive +filesystems. The header-search-path route (R2) is exact everywhere. + +**R2 — every other namespace ships in ONE headers-only xcframework, +`ReactNativeHeaders`.** Namespace dirs at its `Headers/` root: `react/`, +`yoga/`, `jsi/`, `cxxreact/`, `React_RCTAppDelegate/`, … **including the +third-party deps namespaces** (folly/glog/boost/fmt/double-conversion/ +fast_float, copied out of the ReactNativeDependencies artifact — which thereby +becomes binary-only). SPM/Xcode auto-serve a binaryTarget's `Headers/` on the +consumer's search path, so everything resolves with zero flags. + +**R3 — NO include rewriting, anywhere.** Shipped headers are byte-identical to +the repo. If a header's includes don't work in the packaged layout, the fix is +in the _source header_ or the _rules_ — never a packaging-time patch. This is +what keeps source builds and prebuilt builds semantically identical. + +**R4 — the React framework module map is an umbrella over the _safe_ ObjC +surface.** A header enters the umbrella iff `isUmbrellaSafe`: + +```text +bucket == objc-modular-candidate +∧ React/-namespace +∧ no '+' in the filename (category headers, e.g. +Private — see R9) +∧ no C extern-inline definition +``` + +The extern-inline exclusion is empirical: a C99 `extern inline` definition emits +a **strong symbol in every importing translation unit** → duplicate symbols at +link (found via `RCTTextInputNativeCommands.h`). In practice this yields ~225 +umbrella headers out of ~317 in the framework. + +**R5 — every ReactNativeHeaders namespace with modular candidates gets a plain +(non-framework) module** declaring exactly those candidates. Why: a framework +module (React) may not _textually_ include non-modular headers under +`-Wnon-modular-include-in-framework-module` — so when a React.framework header +imports ``, yoga's header must itself belong to a module (found +empirically via yoga + RCTDeprecation). Details: + +- Module _names_ are internal to clang's module graph — consumers never + `@import yoga`; they `#import ` and clang maps the header to its + owning module. +- The `react` namespace's module is renamed `ReactNativeHeaders_react` so it can + never alias the `React` framework module on a case-insensitive filesystem. + (Header paths are unchanged; only the module name differs.) +- Namespaces whose name isn't a valid module identifier (`jsinspector-modern`, + `double-conversion`) are exempt — they currently have no modular candidates; + the verifier asserts that stays true. + +**R6 — bare includes migrate to ``.** Bare root aliases +(`RCTAppDelegate.h` etc.) are physically placed at the framework Headers root, +so `` works; the bare angle form +(`#import `) has no framework spelling and is the one +accepted, measured ecosystem migration (~4 lines total). + +**R7 — sign AFTER composing.** The code signature pins the header manifest; +composing after signing would invalidate it. + +**R8 — collisions are hard errors.** Two different source files may never +project to the same destination path. `computeSpecPlan` throws; the artifact is +not produced. + +**R9 — private React headers, exposed in the default `React` module via a +curated allowlist.** Privileged framework consumers (Expo) import headers the +public umbrella excludes. Rather than a `React.Private` submodule (which would +force a Swift `import React.Private` in consumers) they are appended to the +module map, split by bucket: + +```text +framework module React { + umbrella header "React-umbrella.h" + header "RCTBridge+Private.h" // objc-modular-candidate → real member + textual header "RCTComponentViewFactory.h" // objc-blocked → textual only + textual header "RCTMountingManager.h" // (…6 Fabric headers total) + ... + export * + module * { export * } +} +``` + +An `objc-blocked` header **must** be `textual` — a real member would re-trip the +non-modular-include error that got it excluded from the umbrella in the first +place. Textual works because its C++ includes resolve at the consumer's use +site. "Private" is by convention (naming), not enforcement: a single binary +artifact cannot hard-gate an app from headers a framework legitimately needs. +Validation **fails closed**: an allowlisted header missing from the inventory, +or a `modular` entry whose bucket drifted, aborts the build with a targeted +message. + +**R10 — per-namespace umbrella headers, derived, for namespaces consumers +probe.** Expo's `RCTAppDelegateUmbrella.h` does +`__has_include()` — a +CocoaPods-era artifact filename. The flattened layout ships the individual +headers but no umbrella, so the probe silently failed. The fix emits +`/-umbrella.h` for each namespace in `UMBRELLA_NAMESPACES` (currently +just `React_RCTAppDelegate`), with content **derived from the namespace's +modular header set** — never hand-listed, so it can't drift (it correctly omits +e.g. the since-removed `RCTArchConfiguratorProtocol.h`). The umbrella is also +added to that namespace's R5 module so importing it stays modular. Fails closed +if the namespace loses all modular headers. + +**R11 — one source file, one content location (redirect shims).** Some sources +ship under several spellings: `React/X.h` plus a legacy pod-namespace form +(`CoreModules/X.h`, `RCTAnimation/X.h`, `RCTImage/X.h`, …), or a bare root alias +plus `React_RCTAppDelegate/X.h` — 116 sources at the time of writing. Under the +VFS overlay every spelling mapped to _one physical file_, so `#import`-once and +module ownership were coherent. A flattened layout that _copies_ the content +breaks both: any `-fmodules` consumer touching two spellings — even +transitively, e.g. importing legacy `` whose header pulls a +modular `` — hits **redefinition errors** (found by the headers gate +on its first run). The rule: the **module-owned spelling keeps the content** +(the `React/` form when it exists — umbrella/module-React owner or canonical +textual home; else the R5-module namespaced form), and every other spelling is +emitted as a one-line redirect shim (`#import `). Shims that are +namespace-module members are fine: they import the owning module, so +declarations stay single-owned. + +## Stage 3 — Emission (headers-compose.js) + +`computeSpecPlan(rnRoot)` = inventory → plan, throwing on R8 collisions. Then: + +### `emitReactFrameworkHeaders` (per slice of React.xcframework) + +1. Stage all R1 entries (byte-copies of the source files) + the generated + `React-umbrella.h`. +2. For each slice (`ios-arm64`, `ios-arm64_x86_64-simulator`, …): replace + `React.framework/Headers` with the stage, replace `Modules/` with the + generated module map (R4 + R9). +3. (Stacked PR #57305 adds resource embedding here — `PrivacyInfo.xcprivacy` and + `RCTI18nStrings.bundle` per slice — orthogonal to headers.) + +### `buildReactNativeHeadersXcframework` + +1. Stage all R2 entries, then copy the six deps namespaces from + `third-party/ReactNativeDependencies.xcframework/Headers`. A declared deps + namespace that is missing is a **hard error** — previously a warn-and-ship, + which once produced a silently deps-less artifact (1.6 MB instead of 11 MB). +2. Optionally fold in the `hermes/` public headers (consumer-side compose path). +3. Write the R10 umbrella files, then the R5 module map. +4. Compile a stub static archive per slice (headers-only artifacts still need a + library for `xcodebuild -create-xcframework`) and compose the xcframework. + +### `ensureHeadersLayout` (consumer-side) + +Applies the same emission to an already-downloaded cache slot (so any consumer +with a cached `React.xcframework` gets composed artifacts without a published +`ReactNativeHeaders`). Idempotent via a freshness marker (source realpath + +mtime + hermes presence). + +## How consumers resolve headers (why the rules are shaped this way) + +| Include form | Resolved by | Rule | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ------ | +| `#import ` | `React.framework/Headers` via FRAMEWORK_SEARCH_PATHS; modular via the umbrella | R1, R4 | +| `#import ` | same, modular via the R9 `header` entry | R9 | +| `#import ` | same, **textual** via the R9 entry | R9 | +| `#import ` (C++) | `ReactNativeHeaders/Headers` search path, textual | R2 | +| `#import ` | same, **modular** via the yoga R5 module | R2, R5 | +| `#import ` | same (deps namespaces relocated here) | R2 | +| `` | same, modular | R10 | +| `#import "RCTFabricComponentsPlugins.h"` (quoted, community Fabric pods) | CocoaPods only: re-vended by the `React-RCTFabric` facade into the pod header map (`rncore_facades.rb`, `FACADE_REEXPOSED_HEADERS`) | — | + +- **SwiftPM**: both xcframeworks are plain `.binaryTarget`s; Xcode auto-serves + `React.framework`'s `Headers/`+`Modules/` and `ReactNativeHeaders`' `Headers/` + (incl. its `module.modulemap`) to dependents. Zero flags. +- **CocoaPods**: `React-Core-prebuilt`'s `prepare_command` flattens + `ReactNativeHeaders`' Headers (incl. the module map) into the pod, and the + React-core pods are installed as dependency-only **facades** + (`rncore_facades.rb`) so no source headers shadow the artifact. + +## Fail-closed invariants (the "can't silently regress" list) + +| Guard | Trips when | Where | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| R8 collision check | two sources project to one destination | `computeSpecPlan` | +| R9 allowlist validation | private header removed/renamed, or bucket drifted | `validatePrivateReactHeaders` | +| R10 umbrella check | a probed namespace loses all modular headers | `planFromInventory` | +| R5 exemption assert | an invalid-module-identifier namespace gains a modular-candidate header | `planFromInventory` | +| Deps namespace guard (missing) | folly/glog/… not staged at compose time | `buildReactNativeHeadersXcframework` | +| Deps namespace guard (undeclared) | the deps artifact ships a namespace not in `DEPS_NAMESPACES` (new third-party dep) | `buildReactNativeHeadersXcframework` | +| Include-health ratchet | a shipped header gains a `notShipped`/`unresolved`/quoted-unresolvable include not in the committed baseline | `headers-verify.js` | +| Structural gate | composed module maps/umbrellas differ from the spec render; R9 headers or deps dirs absent | `headers-verify.js` | +| Compile gates | the React module, any R5 namespace module, the R10 umbrella, the R9 textual (Expo-shape) surface, or Swift `RCTBridge.moduleRegistry` fails to compile | `headers-verify.js` (CI: prebuild compose job) | +| Facade re-vend glob | `RCTFabricComponentsPlugins.h` glob matches nothing | `rncore_facades.rb` | + +`DEPS_NAMESPACES` (headers-spec.js) is the single source of truth for +third-party namespaces — the inventory's include classifier derives from it, and +compose enforces set-equality with the deps artifact in both directions. + +The unit tests (`__tests__/headers-spec-test.js`) exercise R9/R10/R11, the R5 +exemption assert, the ratchet diff, and the gate fixtures red/green. + +## Resilience against new headers (drift analysis) + +The system is **derived-by-construction for the common case** — a new ordinary +header requires zero maintenance — but its consumer-facing contracts are +**allowlist-maintained** and only fail downstream. Grouped by _when_ a change +surfaces (daily CI = prebuild compose + rn-tester prebuilt consumer lanes): + +### A. Auto-adapts — nothing to do + +- **New header in an existing pod**: podspec-glob discovery → inventory → placed + by R1/R2; joins the umbrella (R4) or its namespace module (R5) automatically + iff `isUmbrellaSafe`. +- **New pod/namespace** (valid module identifier): gets an R5 module + automatically. +- **New header in `React_RCTAppDelegate`**: joins the derived R10 umbrella. +- **New `+Category` / extern-inline / objc-blocked header**: auto-excluded from + the modular surface — the _safe_ default for app consumers. + +### B. Fails loudly at compose time (the fail-closed guards) + +R8 collisions, R9 allowlist drift (rename/removal, modular→blocked), R10 +total-loss, missing deps namespaces, facade re-vend glob. These protect the +spec's _own_ invariants and cannot silently regress. + +### C. Fails loudly at the GENERATOR — the headers gate (`headers-verify.js`) + +These classes previously failed late (consumer CI lane) or not at all; the gate +— run in the prebuild compose job — moved them to compose time: + +- **A new umbrella member that doesn't compile standalone** (its include chain + hits a `notShipped` header, or the language scanner misbucketed a C++-only + header as C): the gate's ObjC TU precompiles the `React` module — compiling + _every_ umbrella header — at generator time. +- **A broken R5 namespace module**: the gate imports one header from every + namespace module, precompiling each (previously lazy — a module rn-tester + never imported was latent). +- **A public header gaining a quoted `#import "Private.h"` of a non-shipped + header**: recorded by the inventory (`quotedNotShipped`) and caught by the + include-health ratchet against `headers-include-baseline.json` — no compile + coverage needed. +- **The privileged-consumer (Expo) contract**: the gate compiles an Expo-shaped + ObjC++ TU (every R9 textual Fabric header) and a Swift TU (`import React` + + `RCTBridge.moduleRegistry`), plus `__has_include` asserts for the R9/R10 + surfaces — a tested invariant instead of a downstream discovery. + +Proof the gate earns its keep — its FIRST run found two real shipping defects: +the dual-identity redefinitions that became R11, and an undeclared +`SocketRocket` namespace in the deps artifact (caught by the set-equality guard +the moment it was added). SocketRocket is deliberately NOT relocated +(`DEPS_NAMESPACES_NOT_RELOCATED`): the real pod vends it, and textual copies +collide with it under `use_frameworks` — the gate asserts its absence. + +### D. Remaining silent gaps — allowlist maintenance (by design) + +The contracts describing what _external_ consumers need are still curated lists; +a new consumer need shows up downstream first, then becomes a one-line allowlist +addition (now protected by the gate once added): + +1. **New privileged-consumer headers**: a new `+Private.h` or Fabric-class + objc-blocked header an Expo-class consumer starts importing — + `PRIVATE_REACT_HEADERS` is manual by design. +2. **New umbrella-probe namespaces**: a consumer probing `` + for a namespace not in `UMBRELLA_NAMESPACES`. +3. **Partial R10 shrink**: a `React_RCTAppDelegate` header flipping + modular→blocked drops out of the derived umbrella (fail-closed triggers only + on _total_ loss). Mitigated: the Expo probe surface is compile-tested, so a + shrink that breaks the umbrella itself is caught. + +### Remaining recommendation (not yet implemented) + +- **Artifact snapshot metrics** (per-namespace header counts, size bounds) as a + coarse tripwire for large silent drops beyond what the structural gate + byte-compares. 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 new file mode 100644 index 000000000000..8f6b53a16cb7 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-inventory-test.js @@ -0,0 +1,118 @@ +/** + * 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 {scanHeader} = require('../headers-inventory'); + +describe('scanHeader include classification', () => { + test('an unguarded include is not cxx-guarded', () => { + const r = scanHeader('#import \n'); + expect(r.includes).toEqual([ + {token: 'React/RCTBridge.h', cxxGuarded: false}, + ]); + }); + + test('an include under #ifdef __cplusplus is cxx-guarded', () => { + const r = scanHeader( + '#ifdef __cplusplus\n#include \n#endif\n', + ); + expect(r.includes).toEqual([{token: 'folly/dynamic.h', cxxGuarded: true}]); + }); + + test('#else flips the __cplusplus guard', () => { + const src = [ + '#ifdef __cplusplus', + '#include ', + '#else', + '#include ', + '#endif', + '', + ].join('\n'); + expect(scanHeader(src).includes).toEqual([ + {token: 'cpp/only.h', cxxGuarded: true}, + {token: 'c/only.h', cxxGuarded: false}, + ]); + }); + + test('#elif __cplusplus enters a cxx-only region', () => { + const src = [ + '#if SOMETHING', + '#include ', + '#elif __cplusplus', + '#include ', + '#endif', + '', + ].join('\n'); + expect(scanHeader(src).includes).toEqual([ + {token: 'a.h', cxxGuarded: false}, + {token: 'b.h', cxxGuarded: true}, + ]); + }); +}); + +describe('scanHeader C++ / ObjC surface detection', () => { + test('an unguarded namespace is unguarded C++', () => { + const r = scanHeader('namespace facebook { struct X; }\n'); + expect(r.hasUnguardedCxx).toBe(true); + expect(r.hasGuardedCxx).toBe(false); + }); + + test('a namespace under __cplusplus is guarded, not unguarded', () => { + const r = scanHeader('#ifdef __cplusplus\nnamespace facebook {}\n#endif\n'); + expect(r.hasGuardedCxx).toBe(true); + expect(r.hasUnguardedCxx).toBe(false); + }); + + test('a named aggregate with a member initializer is ObjC++', () => { + const r = scanHeader('struct RCTFontProperties { CGFloat size = NAN; };\n'); + expect(r.hasUnguardedCxx).toBe(true); + }); + + test('an ANONYMOUS aggregate with a member initializer is ObjC++', () => { + // Regression: the tag name is optional, so a typedef'd anonymous struct + // carrying a C++ default member initializer is still detected as ObjC++. + const r = scanHeader('typedef struct { CGFloat x = NAN; } Foo;\n'); + expect(r.hasUnguardedCxx).toBe(true); + }); + + test('@interface marks the header as ObjC', () => { + const r = scanHeader('@interface RCTBridge : NSObject\n@end\n'); + expect(r.hasObjC).toBe(true); + }); +}); + +describe('scanHeader comment handling', () => { + test('a multi-line block comment mentioning C++ keywords does not trip the detector', () => { + const src = [ + '/*', + ' * namespace foo is documented here', + ' * template and constexpr too', + ' */', + '@interface RCTFoo', + '@end', + '', + ].join('\n'); + const r = scanHeader(src); + expect(r.hasUnguardedCxx).toBe(false); + expect(r.hasGuardedCxx).toBe(false); + expect(r.hasObjC).toBe(true); + }); + + test('an inline block comment does not trip the detector', () => { + expect(scanHeader('int x; /* namespace y */\n').hasUnguardedCxx).toBe( + false, + ); + }); + + test('a // line comment mentioning a keyword does not trip the detector', () => { + expect(scanHeader('// using namespace std;\n').hasUnguardedCxx).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js new file mode 100644 index 000000000000..b1b7d530577b --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js @@ -0,0 +1,276 @@ +/** + * 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 { + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, +} = require('../headers-spec'); +const fs = require('fs'); + +/*:: +type TestInventoryManifest = { + headers: Array<{ + naturalPath: string, + bucket: string, + lang: string, + identities: Array<{source: string}>, + }>, +}; +*/ + +// isUmbrellaSafe reads each header's source to reject extern-inline defs. Stub +// it to empty so synthetic objc-modular-candidate headers count as umbrella-safe +// (and thus land in namespaceModules), making these tests deterministic. +const planFromInventoryForTest = (manifest /*: TestInventoryManifest */) => { + const readFileSync = jest.spyOn(fs, 'readFileSync').mockReturnValue(''); + try { + return planFromInventory(manifest); + } finally { + readFileSync.mockRestore(); + } +}; + +const entry = ( + naturalPath /*: string */, + bucket /*: string */, + source /*:: ?: string */, +) => ({ + naturalPath, + bucket, + lang: 'objc', + identities: [{source: source ?? `does/not/exist/${naturalPath}`}], +}); + +// A manifest satisfying both the R9 private-header allowlist and the R10 +// umbrella-namespace allowlist (React_RCTAppDelegate). +const validManifest = () => ({ + headers: [ + entry('React/RCTBridge+Private.h', 'objc-modular-candidate'), + entry('React/RCTComponentViewFactory.h', 'objc-blocked'), + entry('React/RCTComponentViewProtocol.h', 'objc-blocked'), + entry('React/RCTComponentViewRegistry.h', 'objc-blocked'), + entry('React/RCTMountingManager.h', 'objc-blocked'), + entry('React/RCTSurfacePresenter.h', 'objc-blocked'), + entry('React/RCTViewComponentView.h', 'objc-blocked'), + entry( + 'React_RCTAppDelegate/RCTReactNativeFactory.h', + 'objc-modular-candidate', + ), + entry( + 'React_RCTAppDelegate/RCTRootViewFactory.h', + 'objc-modular-candidate', + ), + entry('React_RCTAppDelegate/RCTAppDelegate.h', 'objc-modular-candidate'), + ], +}); + +describe('renderReactModuleMap (R9 private headers)', () => { + test('appends modular allowlist as `header` and objc-blocked as `textual header`', () => { + const out = renderReactModuleMap({ + modular: ['RCTBridge+Private.h'], + textual: ['RCTMountingManager.h'], + }); + expect(out).toContain('umbrella header "React-umbrella.h"'); + expect(out).toContain(' header "RCTBridge+Private.h"'); + expect(out).toContain(' textual header "RCTMountingManager.h"'); + // A textual header must NOT also appear as a plain modular `header`. + expect(out).not.toMatch(/^\s*header "RCTMountingManager\.h"/m); + }); + + test('with no private headers renders just the umbrella (backwards compatible)', () => { + const out = renderReactModuleMap(); + expect(out).toContain('umbrella header "React-umbrella.h"'); + expect(out).not.toContain('textual header'); + }); +}); + +describe('planFromInventory R9 validation', () => { + test('passes for a valid allowlist and exposes privateReactHeaders', () => { + const plan = planFromInventoryForTest(validManifest()); + expect(plan.privateReactHeaders.modular).toContain('RCTBridge+Private.h'); + expect(plan.privateReactHeaders.textual).toContain('RCTMountingManager.h'); + }); + + test('throws when an allowlisted header is absent from the inventory', () => { + const m = validManifest(); + m.headers = m.headers.filter( + x => x.naturalPath !== 'React/RCTBridge+Private.h', + ); + expect(() => planFromInventoryForTest(m)).toThrow( + /RCTBridge\+Private\.h is absent/, + ); + }); + + test('throws when a modular allowlist header is no longer objc-modular-candidate', () => { + const m = validManifest(); + const h = m.headers.find( + x => x.naturalPath === 'React/RCTBridge+Private.h', + ); + if (h == null) { + throw new Error('fixture missing RCTBridge+Private.h'); + } + h.bucket = 'objc-blocked'; + expect(() => planFromInventoryForTest(m)).toThrow( + /not 'objc-modular-candidate'/, + ); + }); +}); + +describe('R10 per-namespace umbrella (React_RCTAppDelegate)', () => { + test('emits a derived umbrella for the namespace', () => { + const plan = planFromInventoryForTest(validManifest()); + const u = plan.namespaceUmbrellas.find( + x => x.relPath === 'React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h', + ); + expect(u).toBeDefined(); + if (u == null) { + return; + } + // Imports are relative to the namespace dir, derived from the live set. + expect(u.content).toContain('#import "RCTReactNativeFactory.h"'); + expect(u.content).toContain('#import "RCTRootViewFactory.h"'); + expect(u.content).toContain('#import "RCTAppDelegate.h"'); + expect(u.content).toContain('#ifdef __OBJC__'); + // No CocoaPods version boilerplate. + expect(u.content).not.toContain('FOUNDATION_EXPORT'); + }); + + test('module map lists the umbrella so the import stays modular', () => { + const plan = planFromInventoryForTest(validManifest()); + const mm = renderNamespaceModuleMap(plan.namespaceModules); + expect(mm).toContain('module React_RCTAppDelegate {'); + expect(mm).toContain( + 'header "React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h"', + ); + }); + + test('fails closed when the umbrella namespace lost its modular headers', () => { + const m = validManifest(); + m.headers = m.headers.filter( + x => !x.naturalPath.startsWith('React_RCTAppDelegate/'), + ); + expect(() => planFromInventoryForTest(m)).toThrow( + /umbrella namespace 'React_RCTAppDelegate'/, + ); + }); +}); + +describe('R5 invalid-identifier exemption assert (H5)', () => { + test('throws when an invalid-identifier namespace gains a modular candidate', () => { + const m = validManifest(); + m.headers.push(entry('bad-namespace/Foo.h', 'objc-modular-candidate')); + expect(() => planFromInventoryForTest(m)).toThrow( + /namespace 'bad-namespace' is not a valid module identifier/, + ); + }); + + test('invalid-identifier namespaces with only non-modular headers stay exempt', () => { + const m = validManifest(); + m.headers.push(entry('jsinspector-modern/Foo.h', 'objcxx')); + expect(() => planFromInventoryForTest(m)).not.toThrow(); + }); +}); + +describe('R11 redirect shims for dual-identity headers', () => { + test('RNH spelling of a source that also ships as React/ becomes a shim', () => { + const m = validManifest(); + m.headers.push( + entry('React/RCTClipboard.h', 'objc-modular-candidate', 'src/clip.h'), + entry( + 'CoreModules/RCTClipboard.h', + 'objc-modular-candidate', + 'src/clip.h', + ), + ); + const plan = planFromInventoryForTest(m); + const shim = plan.reactNativeHeaders.find( + e => e.naturalPath === 'CoreModules/RCTClipboard.h', + ); + expect(shim?.redirectTo).toBe('React/RCTClipboard.h'); + // The React/ owner keeps its content. + const owner = plan.react.find( + e => e.naturalPath === 'React/RCTClipboard.h', + ); + expect(owner?.redirectTo).toBeUndefined(); + // The shim stays a namespace-module member (imports the owning module). + expect(plan.namespaceModules.CoreModules).toContain( + 'CoreModules/RCTClipboard.h', + ); + }); + + test('bare root alias shims to its RNH namespaced owner', () => { + const m = validManifest(); + m.headers.push( + entry('RCTAppDelegate.h', 'objc-modular-candidate', 'src/appdelegate.h'), + ); + // Same source as the namespaced form. + const ns = m.headers.find( + x => x.naturalPath === 'React_RCTAppDelegate/RCTAppDelegate.h', + ); + if (ns == null) { + throw new Error('fixture missing namespaced RCTAppDelegate.h'); + } + ns.identities[0].source = 'src/appdelegate.h'; + const plan = planFromInventoryForTest(m); + const bare = plan.react.find(e => e.naturalPath === 'RCTAppDelegate.h'); + expect(bare?.redirectTo).toBe('React_RCTAppDelegate/RCTAppDelegate.h'); + const owner = plan.reactNativeHeaders.find( + e => e.naturalPath === 'React_RCTAppDelegate/RCTAppDelegate.h', + ); + expect(owner?.redirectTo).toBeUndefined(); + }); + + test('single-identity headers get no redirect', () => { + const plan = planFromInventoryForTest(validManifest()); + for (const e of [...plan.react, ...plan.reactNativeHeaders]) { + expect(e.redirectTo).toBeUndefined(); + } + }); +}); + +describe('headers-verify gate pieces', () => { + const { + diffAgainstBaseline, + renderObjcFixture, + renderPrivilegedFixture, + } = require('../headers-verify'); + + test('diffAgainstBaseline ratchets: new offenders fail, resolved reported', () => { + const {newOffenders, resolved} = diffAgainstBaseline( + ['a', 'c'], + ['a', 'b'], + ); + expect(newOffenders).toEqual(['c']); + expect(resolved).toEqual(['b']); + }); + + test('ObjC fixture asserts and imports the R9/R10 + module surfaces', () => { + const plan = planFromInventoryForTest(validManifest()); + const tu = renderObjcFixture(plan); + expect(tu).toContain('__has_include()'); + expect(tu).toContain( + '__has_include()', + ); + expect(tu).toContain('#import '); + expect(tu).toContain('#import '); + // One import per namespace module (fixture has React_RCTAppDelegate). + expect(tu).toMatch(/#import { + const plan = planFromInventoryForTest(validManifest()); + const tu = renderPrivilegedFixture(plan); + expect(tu).toContain('#import '); + expect(tu).toContain('#import '); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js new file mode 100644 index 000000000000..23b4af4441e7 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -0,0 +1,432 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers compose — emits the headers-spec layout (rules R1–R8 in + * headers-spec.js) into a React.xcframework and builds the headers-only + * ReactNativeHeaders.xcframework beside it. The prebuild path (xcframework.js) + * composes before signing (R7); `ensureHeadersLayout()` applies the same + * emission to an already-cached artifact. One projector, spec-driven, + * byte-identical output either way. + */ + +const {computeInventory} = require('./headers-inventory'); +const { + DEPS_NAMESPACES, + DEPS_NAMESPACES_NOT_RELOCATED, + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const {execFileSync} = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// APFS clonefile (-c) is a macOS-only cp flag; plain -R elsewhere (Linux CI +// exercises these paths through the jest integration tests). +const CP_FLAGS = process.platform === 'darwin' ? '-Rc' : '-R'; + +// Hash of the compose tooling itself. Folded into the freshness marker so a +// local edit to any of these scripts (which changes the composed output) +// forces a recompose even when the cached source xcframework is untouched — +// the Info.plist mtime alone can't catch that. Stable across fresh checkouts +// (content-based, not mtime-based). sha256 to avoid weak-hash lint. +function composeToolingHash() /*: string */ { + const hash = crypto.createHash('sha256'); + for (const name of [ + 'headers-inventory.js', + 'headers-spec.js', + 'headers-compose.js', + ]) { + hash.update(fs.readFileSync(path.join(__dirname, name))); + } + return hash.digest('hex'); +} + +/*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */ + +/** + * Computes the spec plan from the live source tree. Throws on collisions + * (R8) — a collision means the spec and the source tree disagree and the + * artifact must not be produced. + */ +function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ { + const inventory = computeInventory(rnRoot); + // R8, part 1: two distinct sources mapping to the same natural Headers/ path. + // addIdentity merges these and planFromInventory keeps only identities[0], so + // the second source would be silently dropped. Fail closed instead. + 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, rnRoot); + // R8, part 2: two natural paths colliding on the same xcframework destination. + if (plan.collisions.length > 0) { + throw new Error( + `headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`, + ); + } + return plan; +} + +/** + * Copies spec entries (each `{relPath, source}`) into a staging dir, creating + * parent dirs. Shared by the React.framework and ReactNativeHeaders emission. + */ +function stageEntries( + stage /*: string */, + entries /*: Array */, + rnRoot /*: string */, +) /*: void */ { + for (const e of entries) { + const dest = path.join(stage, e.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + if (e.redirectTo != null) { + // R11: duplicate spelling of a source that lives elsewhere — emit a + // redirect shim so the declarations exist in exactly one file (module + // ownership and #import-once stay coherent across spellings). + fs.writeFileSync( + dest, + `// @generated by headers-compose (R11): this spelling redirects to\n` + + `// the content's single home so clang modules stay coherent.\n` + + `#import <${e.redirectTo}>\n`, + ); + } else { + fs.copyFileSync(path.join(rnRoot, e.source), dest); + } + } +} + +/** + * Emits the React.framework side of the spec (R1, R4, R6) into every slice + * of an xcframework: Headers root = React/ hoisted to root + bare aliases, + * generated umbrella + framework module map. Replaces each slice's Headers + * and Modules. The xcframework's ROOT Headers/ (the CocoaPods header surface) + * is left untouched. + */ +function emitReactFrameworkHeaders( + xcfwPath /*: string */, + plan /*: HeadersSpecPlan */, + rnRoot /*: string */, +) /*: void */ { + const stage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.react-stage-'), + ); + stageEntries(stage, plan.react, rnRoot); + fs.writeFileSync( + path.join(stage, 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + ); + + // A slice is any entry carrying a React.framework. The framework as built by + // xcodebuild -create-xcframework ships no Headers/ dir of its own — this + // emission creates it (and replaces Modules), so detect by the framework, not + // by a pre-existing Headers/. + const slices = fs + .readdirSync(xcfwPath) + .filter(d => fs.existsSync(path.join(xcfwPath, d, 'React.framework'))); + for (const slice of slices) { + const fwk = path.join(xcfwPath, slice, 'React.framework'); + fs.rmSync(path.join(fwk, 'Headers'), {recursive: true, force: true}); + execFileSync('/bin/cp', [CP_FLAGS, stage, path.join(fwk, 'Headers')]); + fs.rmSync(path.join(fwk, 'Modules'), {recursive: true, force: true}); + fs.mkdirSync(path.join(fwk, 'Modules'), {recursive: true}); + fs.writeFileSync( + path.join(fwk, 'Modules', 'module.modulemap'), + renderReactModuleMap(plan.privateReactHeaders), + ); + } + fs.rmSync(stage, {recursive: true, force: true}); + console.log( + `headers-compose: React.framework spec layout -> ${slices.join(', ')} ` + + `(${plan.react.length} headers, umbrella ${plan.umbrella.length})`, + ); +} + +/*:: +type StubSlice = { + name: string, // human label + sdk: string, // xcrun --sdk name + targets: Array, // clang -target triples (lipo'd when > 1) +}; +*/ + +const DEFAULT_STUB_SLICES /*: Array */ = [ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, +]; + +// Mac Catalyst slice — used by the real compose (the cached-artifact +// repackage path skips it to stay fast; React.xcframework carries it). +const CATALYST_STUB_SLICE /*: StubSlice */ = { + name: 'mac-catalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], +}; + +/** + * Builds ReactNativeHeaders.xcframework (R2, R5): a headers-only LIBRARY + * xcframework (stub static archives — nothing embeds in apps) whose Headers + * root carries every non-React namespace incl. the third-party deps + * namespaces, plus module.modulemap with the plain per-namespace modules. + * SPM serves its Headers automatically to dependents — no flags. + */ +function buildReactNativeHeadersXcframework( + outDir /*: string */, + plan /*: HeadersSpecPlan */, + depsHeaders /*: string */, + rnRoot /*: string */, + includeCatalyst /*: boolean */ = false, + // Optional dir containing a `hermes/` namespace (Hermes public headers from + // the hermes-ios tarball's destroot/include). Folded in as a textual + // namespace like folly/glog so `` resolves without per-library + // wiring. null when unstaged — then `` stays unavailable. + hermesHeaders /*: ?string */ = null, +) /*: string */ { + // ---- stage headers ---- + const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); + stageEntries(stage, plan.reactNativeHeaders, rnRoot); + for (const ns of plan.depsNamespaces) { + const src = path.join(depsHeaders, ns); + // Fail closed: a declared deps namespace (folly/glog/boost/...) missing + // from the staged ReactNativeDependencies headers means the artifact would + // ship WITHOUT those ``-style headers — a silently-broken + // ReactNativeHeaders.xcframework (consumers lose third-party header + // resolution). Refuse rather than emit it. Stage + // third-party/ReactNativeDependencies.xcframework/Headers (full prebuild or + // cache slot) before composing. + if (!fs.existsSync(src)) { + throw new Error( + `headers-compose: deps namespace '${ns}' missing under ${depsHeaders}. ` + + `ReactNativeDependencies headers are not staged — refusing to ship an ` + + `incomplete ReactNativeHeaders.xcframework.`, + ); + } + execFileSync('/bin/cp', [CP_FLAGS, src, path.join(stage, ns)]); + } + // Set equality with the deps artifact: a namespace dir present in the + // artifact but neither declared for relocation (DEPS_NAMESPACES) nor + // explicitly excluded (DEPS_NAMESPACES_NOT_RELOCATED — namespaces a real + // consumer pod vends itself, e.g. SocketRocket) means a new third-party dep + // was added upstream — fail closed so a decision is made deliberately. + const foundDepsDirs = fs + .readdirSync(depsHeaders, {withFileTypes: true}) + .filter(e => e.isDirectory()) + .map(e => String(e.name)); + const undeclared = foundDepsDirs.filter( + d => + !plan.depsNamespaces.includes(d) && + !DEPS_NAMESPACES_NOT_RELOCATED.includes(d), + ); + if (undeclared.length > 0) { + throw new Error( + `headers-compose: deps artifact ships undeclared namespace(s): ` + + `${undeclared.join(', ')}. Add them to DEPS_NAMESPACES (relocated) or ` + + `DEPS_NAMESPACES_NOT_RELOCATED (vended by a real pod) in headers-spec.js.`, + ); + } + // Hermes public headers (separate source from the deps namespaces — they + // come from the hermes-ios tarball, not ReactNativeDependencies). Vend only + // the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying + // it here would double-vend. + let hermesFolded = false; + if (hermesHeaders != null) { + const src = path.join(hermesHeaders, 'hermes'); + if (fs.existsSync(src)) { + execFileSync('/bin/cp', [CP_FLAGS, src, path.join(stage, 'hermes')]); + hermesFolded = true; + } else { + console.warn( + `headers-compose: hermes headers missing at ${src} — the composed ` + + 'ReactNativeHeaders will NOT resolve ; a downstream ' + + 'build that imports Hermes headers will fail. Ensure the hermes-ios ' + + "tarball's destroot/include was staged into the slot.", + ); + } + } + // R10: per-namespace umbrella headers (e.g. React_RCTAppDelegate-umbrella.h) + // that consumers like Expo probe via __has_include. Must be staged before the + // module map references them. + for (const u of plan.namespaceUmbrellas) { + const dest = path.join(stage, u.relPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.writeFileSync(dest, u.content); + } + fs.writeFileSync( + path.join(stage, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + ); + + // ---- stub static archives per slice ---- + const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); + fs.writeFileSync( + path.join(work, 'stub.c'), + '// ReactNativeHeaders is headers-only; this stub satisfies xcframework tooling.\nstatic int RNHeadersStub __attribute__((unused)) = 0;\n', + ); + const slices = includeCatalyst + ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] + : DEFAULT_STUB_SLICES; + const libs = slices.map(slice => { + const sdkPath = execFileSync('xcrun', [ + '--sdk', + slice.sdk, + '--show-sdk-path', + ]) + .toString() + .trim(); + const thins = slice.targets.map((t, i) => { + const obj = path.join(work, `stub-${slice.name}-${i}.o`); + execFileSync('xcrun', [ + 'clang', + '-c', + '-target', + t, + '-isysroot', + sdkPath, + path.join(work, 'stub.c'), + '-o', + obj, + ]); + const lib = path.join(work, `stub-${slice.name}-${i}.a`); + execFileSync('xcrun', ['libtool', '-static', '-o', lib, obj], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + return lib; + }); + const outLib = path.join(work, `libReactNativeHeaders-${slice.name}.a`); + if (thins.length === 1) { + fs.copyFileSync(thins[0], outLib); + } else { + execFileSync('xcrun', ['lipo', '-create', ...thins, '-output', outLib]); + } + return outLib; + }); + + // ---- compose ---- + const outXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + fs.rmSync(outXcfw, {recursive: true, force: true}); + const xcframeworkArgs = ['-create-xcframework']; + for (const l of libs) { + xcframeworkArgs.push('-library', l, '-headers', stage); + } + xcframeworkArgs.push('-output', outXcfw); + execFileSync('xcodebuild', xcframeworkArgs, {stdio: 'pipe'}); + fs.rmSync(stage, {recursive: true, force: true}); + fs.rmSync(work, {recursive: true, force: true}); + console.log( + `headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}` + + `${hermesFolded ? ', hermes' : ''}; ` + + `${Object.keys(plan.namespaceModules).length} namespace modules)`, + ); + return outXcfw; +} + +/** + * Ensures the headers-spec layout exists at `outDir`, composed from the cache + * slot's artifacts: clones React.xcframework (APFS clonefile), strips the + * stale signature (R7 — production signs after compose), emits the spec + * layout into every slice, and builds ReactNativeHeaders.xcframework from + * the plan + the slot's deps headers. + * + * Skips when the freshness marker matches the source artifact (same + * realpath + Info.plist mtime) unless `force`. Any consumer with a cache slot + * gets composed artifacts automatically — no published ReactNativeHeaders + * required. + */ +function ensureHeadersLayout( + artifactsDir /*: string */, + rnRoot /*: string */, + outDir /*: string */, + force /*: boolean */ = false, +) /*: {reactXcfw: string, headersXcfw: string} */ { + const sourceXcfw = fs.realpathSync( + path.join(artifactsDir, 'React.xcframework'), + ); + const depsHeaders = path.join( + artifactsDir, + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + // Hermes public headers staged into the slot by download-spm-artifacts + // (the hermes-ios tarball ships them in destroot/include, which the + // xcframework extraction otherwise discards). null when absent — then + // ReactNativeHeaders composes without the hermes namespace. + const hermesHeadersDir = path.join(artifactsDir, 'hermes-headers'); + const hermesHeaders = fs.existsSync(path.join(hermesHeadersDir, 'hermes')) + ? hermesHeadersDir + : null; + const reactXcfw = path.join(outDir, 'React.xcframework'); + const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + const markerPath = path.join(outDir, '.composed-from'); + + const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist')); + // Fold the hermes-headers presence into the marker so a slot that gains + // staged hermes headers (e.g. after a tooling upgrade re-downloads them) + // recomposes instead of reusing a hermes-less ReactNativeHeaders. 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`; + if ( + !force && + fs.existsSync(reactXcfw) && + fs.existsSync(headersXcfw) && + fs.existsSync(markerPath) && + fs.readFileSync(markerPath, 'utf8') === marker + ) { + return {reactXcfw, headersXcfw}; + } + + console.log( + `headers-compose: composing layout from ${path.basename(artifactsDir)} slot...`, + ); + fs.rmSync(reactXcfw, {recursive: true, force: true}); + fs.rmSync(markerPath, {force: true}); + fs.mkdirSync(outDir, {recursive: true}); + execFileSync('/bin/cp', [CP_FLAGS, sourceXcfw, reactXcfw]); + fs.rmSync(path.join(reactXcfw, '_CodeSignature'), { + recursive: true, + force: true, + }); + + const plan = computeSpecPlan(rnRoot); + emitReactFrameworkHeaders(reactXcfw, plan, rnRoot); + buildReactNativeHeadersXcframework( + outDir, + plan, + depsHeaders, + rnRoot, + false, + hermesHeaders, + ); + fs.writeFileSync(markerPath, marker); + return {reactXcfw, headersXcfw}; +} + +module.exports = { + computeSpecPlan, + emitReactFrameworkHeaders, + buildReactNativeHeadersXcframework, + ensureHeadersLayout, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json new file mode 100644 index 000000000000..9221b6945ba3 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json @@ -0,0 +1,29 @@ +[ + "notShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> react/renderer/observers/intersection/IntersectionObserverManager.h", + "notShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> react/renderer/observers/mutation/MutationObserverManager.h", + "notShipped react/renderer/animated/InterpolationAnimatedNode.h -> react/renderer/animated/internal/primitives.h", + "notShipped react/renderer/animated/NativeAnimatedNodesManager.h -> react/renderer/animated/event_drivers/EventAnimationDriver.h", + "notShipped react/renderer/animated/PropsAnimatedNode.h -> react/renderer/animated/internal/primitives.h", + "notShipped react/renderer/mounting/MountingCoordinator.h -> react/renderer/mounting/stubs/stubs.h", + "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\"", + "quotedNotShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/microtasks/NativeMicrotasks.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/viewtransition/NativeViewTransition.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"rncoreJSI.h\"", + "quotedNotShipped react/renderer/animated/AnimatedModule.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/renderer/animated/NativeAnimatedNodesManager.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped reactperflogger/FuseboxTracer.h -> \"folly/json/dynamic.h\"" +] diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js new file mode 100644 index 000000000000..a1bfe6757b06 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -0,0 +1,630 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Inventory and classify every header the React xcframework ships — the input + * to the headers spec (headers-spec.js). + * + * Enumerates headers through the SAME podspec-driven discovery the prebuild + * uses (headers.js), so the inventory cannot drift from the shipped set. For + * each header it records: + * + * - both identities: the pod-namespaced layout path (`Headers//`) + * and the natural path (the include path consumers write) + * - language surface: objc | objcxx | cxx | c (with `#ifdef __cplusplus` + * guard awareness, so ObjC headers that only reach C++ behind guards are + * not misclassified) + * - a modularizability bucket (can this header live in a Clang module?) + * + * `computeInventory()` returns the classified set in-memory for the prebuild + * compose step; the CLI (`node scripts/ios-prebuild/headers-inventory.js`) + * writes the same set as a JSON manifest. Read-only: never touches the trees + * it describes. + */ + +const {getHeaderFilesFromPodspecs} = require('./headers'); +// headers-spec.js requires only fs/path, so this cannot cycle. +const { + DEPS_NAMESPACES, + DEPS_NAMESPACES_NOT_RELOCATED, +} = require('./headers-spec'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type Identity = { + pod: string, // pod folder name in Headers/ (specName with '-' -> '_') + spec: string, // (sub)spec name the header came from + namespacedPath: string, // path inside the xcframework Headers/ dir + source: string, // repo-relative path to the physical file + bareAlias?: boolean, // synthetic root-level alias (React_RCTAppDelegate rule) +}; + +type IncludeRef = { + token: string, // text between <> or "" + cxxGuarded: boolean, // true when only reachable under #ifdef __cplusplus +}; + +type HeaderEntry = { + naturalPath: string, + identities: Array, + lang: 'objc' | 'objcxx' | 'cxx' | 'c', + bucket: 'objc-modular-candidate' | 'objc-blocked' | 'objcxx' | 'cxx', + includes: { + internal: Array<{naturalPath: string, cxxGuarded: boolean}>, + thirdParty: Array<{lib: string, token: string, cxxGuarded: boolean}>, + hermes: Array, + system: Array, + std: Array<{token: string, cxxGuarded: boolean}>, + metaInternal: Array, + otherPlatform: Array, + notShipped: Array, + unresolved: Array, + // Quoted includes that do not resolve to a shipped header. Fine inside the + // framework binary's own compilation, but a consumer compiling the shipped + // header hits "file not found" (source builds mask this via pod header + // maps). Gated by the include-health ratchet (headers-verify.js). + quotedNotShipped: Array, + }, +}; +*/ +// Third-party C++ libraries that RN's public headers re-expose (Tier 3 of the +// modularization doc). Keyed by the first include-path segment. Single source +// of truth: the spec's DEPS_NAMESPACES (the namespaces relocated into +// ReactNativeHeaders) — a new third-party dep is declared ONCE and both the +// include classifier and the compose step follow. +const THIRD_PARTY_LIBS /*: Set */ = new Set([ + ...DEPS_NAMESPACES, + ...DEPS_NAMESPACES_NOT_RELOCATED, +]); + +// Apple SDK / platform include roots (first path segment). Includes resolving +// here are "system": always modular or always available, never our problem. +const SDK_PREFIXES = new Set([ + 'Accelerate', + 'Accessibility', + 'AVFoundation', + 'AVKit', + 'CommonCrypto', + 'CoreFoundation', + 'CoreGraphics', + 'CoreLocation', + 'CoreMedia', + 'CoreServices', + 'CoreText', + 'CoreVideo', + 'Foundation', + 'ImageIO', + 'JavaScriptCore', + 'MachO', + 'Metal', + 'MetalKit', + 'MobileCoreServices', + 'Network', + 'PhotosUI', + 'QuartzCore', + 'SafariServices', + 'Security', + 'SwiftUI', + 'TargetConditionals.h', + 'UIKit', + 'UserNotifications', + 'WebKit', + 'XCTest', + 'arm', + 'dispatch', + 'libkern', + 'mach', + 'mach-o', + 'malloc', + 'objc', + 'os', + 'simd', + 'sys', +]); + +/** + * Scans a header's text line by line, tracking the preprocessor-conditional + * stack just enough to know whether a line is only compiled under + * `__cplusplus`. Returns the include list and language-marker observations. + * Heuristic by design: nested #if logic beyond __cplusplus is treated as + * "other" and ignored. + */ +function scanHeader(text /*: string */) /*: { + includes: Array, + hasObjC: boolean, + hasUnguardedCxx: boolean, + hasGuardedCxx: boolean, +} */ { + const includes /*: Array */ = []; + let hasObjC = false; + let hasUnguardedCxx = false; + let hasGuardedCxx = false; + + // Stack frames: 'cpp' (only under __cplusplus), 'notcpp', 'other'. + const stack /*: Array<'cpp' | 'notcpp' | 'other'> */ = []; + const inCxxOnly = () => stack.includes('cpp'); + + const includeRe = /^\s*#\s*(?:include|import)\s+(?:<([^>]+)>|"([^"]+)")/; + const objcRe = + /^\s*(@(interface|protocol|implementation|class\s|end)|NS_ASSUME_NONNULL_BEGIN)/; + const cxxRe = + /^\s*(namespace\s+[A-Za-z_]|template\s*<|extern\s+"C\+\+"|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); + } + const cond = line.match(/^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$/); + if (cond) { + const [, directive, rest] = cond; + const mentionsCpp = /__cplusplus/.test(rest); + if (directive === 'ifdef' || directive === 'if') { + stack.push( + mentionsCpp && + !/!\s*defined|defined\s*\(\s*__cplusplus\s*\)\s*==\s*0/.test(rest) + ? 'cpp' + : 'other', + ); + } else if (directive === 'ifndef') { + stack.push(mentionsCpp ? 'notcpp' : 'other'); + } else if (directive === 'else') { + const top = stack.pop() ?? 'other'; + stack.push( + top === 'cpp' ? 'notcpp' : top === 'notcpp' ? 'cpp' : 'other', + ); + } else if (directive === 'elif') { + stack.pop(); + stack.push(mentionsCpp ? 'cpp' : 'other'); + } else if (directive === 'endif') { + stack.pop(); + } + continue; + } + + const inc = line.match(includeRe); + if (inc) { + includes.push({ + token: inc[1] != null ? inc[1] : `"${inc[2]}"`, + cxxGuarded: inCxxOnly(), + }); + } + if (objcRe.test(line)) { + hasObjC = true; + } + if (cxxRe.test(line)) { + if (inCxxOnly()) { + hasGuardedCxx = true; + } else { + hasUnguardedCxx = true; + } + } + } + + // C++ default member initializer inside an aggregate, e.g. + // struct RCTFontProperties { NSString *family = nil; CGFloat size = NAN; }; + // Illegal in C/ObjC, so the header is really ObjC++ and cannot compile in a + // plain ObjC module. The keyword scan above misses it (no namespace/template/ + // class keyword). Detect a `struct`/`class` body that contains a member + // declaration carrying an `=` initializer. Whole-text (not per-line) so the + // aggregate context is required, avoiding false positives on file-scope + // definitions. Unguarded by construction (definitions can't sit under a + // pure `#ifdef __cplusplus` and still be the ObjC surface). The tag name is + // optional so an anonymous `typedef struct { CGFloat x = NAN; } Foo;` is + // caught too (a named aggregate is not required for the ObjC++ surface). + const aggregateMemberInitRe = + /\b(?:struct|class)\b(?:\s+[A-Za-z_]\w*)?[^;{}]*\{[^{}]*?\b[A-Za-z_][\w\s:<>,]*\**\s+\*?[A-Za-z_]\w*\s*=\s*[^;{}]+;/s; + if (aggregateMemberInitRe.test(text)) { + hasUnguardedCxx = true; + } + + return {includes, hasObjC, hasUnguardedCxx, hasGuardedCxx}; +} + +// Meta-internal headers referenced behind RN_DISABLE_OSS_PLUGIN_HEADER (the +// FB*Plugins pattern) or fbjni/FBI18n — never resolvable in OSS, by design. +const META_INTERNAL_RE /*: RegExp */ = + /^(fbjni|FBI18n)\/|^React\/FB\w+Plugins\.h$/; +// Non-Apple platform headers (Android-only branches in shared headers). +const OTHER_PLATFORM_PREFIXES = new Set(['android', 'jni']); + +// C++ standard library headers have no slash and no extension (); +// C standard headers have no slash and a .h (). +function classifyExternal( + token /*: string */, + ownNamespaces /*: Set */, + rootFolder /*: string */, +) /*: string */ { + const first = token.split('/')[0]; + if (THIRD_PARTY_LIBS.has(first)) { + return 'thirdParty'; + } + if (first === 'hermes') { + return 'hermes'; + } + if (META_INTERNAL_RE.test(token)) { + return 'metaInternal'; + } + if (OTHER_PLATFORM_PREFIXES.has(first)) { + return 'otherPlatform'; + } + if (!token.includes('/')) { + return token.endsWith('.h') ? 'system' : 'std'; + } + if (SDK_PREFIXES.has(first)) { + return 'system'; + } + // RN's own include namespace but absent from the shipped set: either a + // genuinely unshipped header or a header_dir-flattening mismatch (headers.js + // ships /, dropping inner subdirs like mounting/stubs/). + if ( + ownNamespaces.has(first) || + fs.existsSync(path.join(rootFolder, 'ReactCommon', token)) + ) { + return 'notShipped'; + } + return 'unresolved'; +} + +function buildInventory(rootFolder /*: string */) /*: { + entries: Map, + sourceToNatural: Map>, + collisions: Array<{naturalPath: string, sources: Array}>, +} */ { + const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); + + // naturalPath -> entry skeleton; absolute source -> naturalPaths it serves. + const entries /*: Map */ = new Map(); + const sourceToNatural /*: Map> */ = new Map(); + const naturalToSources /*: Map> */ = new Map(); + + const addIdentity = ( + naturalPath /*: string */, + identity /*: Identity */, + absSource /*: string */, + ) => { + let entry = entries.get(naturalPath); + if (!entry) { + entry = { + naturalPath, + identities: [], + lang: 'c', + bucket: 'cxx', + includes: { + internal: [], + thirdParty: [], + hermes: [], + system: [], + std: [], + metaInternal: [], + otherPlatform: [], + notShipped: [], + unresolved: [], + quotedNotShipped: [], + }, + }; + entries.set(naturalPath, entry); + } + entry.identities.push(identity); + + const naturals = sourceToNatural.get(absSource) ?? []; + if (!naturals.includes(naturalPath)) { + naturals.push(naturalPath); + } + sourceToNatural.set(absSource, naturals); + + const sources = naturalToSources.get(naturalPath) ?? new Set(); + sources.add(absSource); + naturalToSources.set(naturalPath, sources); + }; + + for (const podspecPath of Object.keys(podSpecsWithHeaderFiles)) { + const headerMaps = podSpecsWithHeaderFiles[podspecPath]; + // xcframework.js and vfs.js both use the ROOT spec's name (first map) as + // the pod folder, with the same first-occurrence '-' -> '_' replacement. + const podName = headerMaps[0].specName.replace(/-/g, '_'); + + for (const headerMap of headerMaps) { + for (const header of headerMap.headers) { + // Some header patterns are written as *.{m,mm,cpp,h}; only headers ship. + if (!/\.(h|hpp)$/.test(header.source)) { + continue; + } + // Natural path = the VFS key: the podspec target, with root-level + // targets of header_dir-less pods prefixed by the pod name (vfs.js rule). + let naturalPath = header.target; + if ( + !naturalPath.includes('/') && + (!headerMap.headerDir || headerMap.headerDir === '') + ) { + naturalPath = `${podName}/${naturalPath}`; + } + const identity /*: Identity */ = { + pod: podName, + spec: headerMap.specName, + namespacedPath: path.join(podName, header.target), + source: path.relative(rootFolder, header.source), + }; + addIdentity(naturalPath, identity, header.source); + + // The merged ReactCoreHeaders tree ALSO exposes React_RCTAppDelegate + // headers bare at the root (hosts write #import ). + // Model that second identity explicitly. + if (podName === 'React_RCTAppDelegate') { + addIdentity( + path.basename(header.target), + { + ...identity, + bareAlias: true, + }, + header.source, + ); + } + } + } + } + + const collisions = []; + for (const [naturalPath, sources] of naturalToSources) { + if (sources.size > 1) { + collisions.push({ + naturalPath, + sources: Array.from(sources) + .map(s => path.relative(rootFolder, s)) + .sort(), + }); + } + } + collisions.sort((a, b) => a.naturalPath.localeCompare(b.naturalPath)); + + return {entries, sourceToNatural, collisions}; +} + +function classifyEntries( + entries /*: Map */, + sourceToNatural /*: Map> */, + rootFolder /*: string */, +) /*: void */ { + // RN's own top-level include namespaces, derived from the shipped set, so + // "in our namespace but not shipped" is detectable. + const ownNamespaces = new Set( + Array.from(entries.keys()) + .map(p => p.split('/')[0]) + .filter(p => p.includes('.') === false), + ); + + // Scan each entry's primary source once. + for (const entry of entries.values()) { + const absSource = path.join(rootFolder, entry.identities[0].source); + let text; + try { + text = fs.readFileSync(absSource, 'utf8'); + } catch { + entry.includes.unresolved.push(''); + continue; + } + const scan = scanHeader(text); + const isHpp = absSource.endsWith('.hpp'); + if (scan.hasObjC && scan.hasUnguardedCxx) { + entry.lang = 'objcxx'; + } else if (scan.hasObjC) { + entry.lang = 'objc'; + } else if (scan.hasUnguardedCxx || isHpp) { + entry.lang = 'cxx'; + } else { + entry.lang = 'c'; + } + + for (const inc of scan.includes) { + let token = inc.token; + // Quoted include: resolve against the source dir and map back to a + // natural path if the resolved file is itself a shipped header. + if (token.startsWith('"')) { + const resolved = path.resolve( + path.dirname(absSource), + token.slice(1, -1), + ); + const naturals = sourceToNatural.get(resolved); + if (naturals && naturals.length > 0) { + entry.includes.internal.push({ + naturalPath: naturals[0], + cxxGuarded: inc.cxxGuarded, + }); + } else { + // A quoted include in a SHIPPED header that doesn't land on another + // shipped header: works in source builds (pod header maps / sibling + // files) but has no resolution target in the packaged layout when a + // consumer compiles this header. Recorded for the include-health + // ratchet rather than silently dropped. + entry.includes.quotedNotShipped.push(token); + } + continue; + } + if (entries.has(token)) { + entry.includes.internal.push({ + naturalPath: token, + cxxGuarded: inc.cxxGuarded, + }); + continue; + } + const kind = classifyExternal(token, ownNamespaces, rootFolder); + if (kind === 'thirdParty') { + entry.includes.thirdParty.push({ + lib: token.split('/')[0], + token, + cxxGuarded: inc.cxxGuarded, + }); + } else if (kind === 'hermes') { + entry.includes.hermes.push(token); + } else if (kind === 'system') { + entry.includes.system.push(token); + } else if (kind === 'std') { + entry.includes.std.push({token, cxxGuarded: inc.cxxGuarded}); + } else if (kind === 'metaInternal') { + entry.includes.metaInternal.push(token); + } else if (kind === 'otherPlatform') { + entry.includes.otherPlatform.push(token); + } else if (kind === 'notShipped') { + entry.includes.notShipped.push(token); + } else { + entry.includes.unresolved.push(token); + } + } + } + + // Fixpoint over UNGUARDED edges only: what an Obj-C (non-C++) consumer of + // this header actually pulls in. Decides modularizability of the ObjC surface. + const reachesCxx /*: Map */ = new Map(); + const reachesTp /*: Map> */ = new Map(); + for (const [naturalPath, entry] of entries) { + reachesCxx.set( + naturalPath, + entry.lang === 'cxx' || + entry.lang === 'objcxx' || + entry.includes.std.some(s => !s.cxxGuarded), + ); + reachesTp.set( + naturalPath, + new Set( + entry.includes.thirdParty.filter(t => !t.cxxGuarded).map(t => t.lib), + ), + ); + } + let changed = true; + while (changed) { + changed = false; + for (const [naturalPath, entry] of entries) { + let cxx = reachesCxx.get(naturalPath) ?? false; + const tp = reachesTp.get(naturalPath) ?? new Set(); + const beforeCxx = cxx; + const beforeTp = tp.size; + for (const dep of entry.includes.internal) { + if (dep.cxxGuarded) { + continue; + } + cxx = cxx || (reachesCxx.get(dep.naturalPath) ?? false); + for (const lib of reachesTp.get(dep.naturalPath) ?? []) { + tp.add(lib); + } + } + if (cxx !== beforeCxx || tp.size !== beforeTp) { + reachesCxx.set(naturalPath, cxx); + reachesTp.set(naturalPath, tp); + changed = true; + } + } + } + + for (const [naturalPath, entry] of entries) { + if (entry.lang === 'cxx') { + entry.bucket = 'cxx'; + } else if (entry.lang === 'objcxx') { + entry.bucket = 'objcxx'; + } else { + const cxx = reachesCxx.get(naturalPath) ?? false; + const tp = Array.from(reachesTp.get(naturalPath) ?? []).sort(); + if (!cxx && tp.length === 0) { + entry.bucket = 'objc-modular-candidate'; + } else { + entry.bucket = 'objc-blocked'; + } + } + } +} + +function main() /*: void */ { + const argv = process.argv.slice(2); + const getFlag = (name /*: string */) /*: ?string */ => { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; + }; + const rootFolder = path.resolve( + getFlag('--root') ?? path.join(__dirname, '..', '..'), + ); + const outPath = path.resolve( + getFlag('--out') ?? path.join(rootFolder, 'build', 'header-inventory.json'), + ); + + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + const headers = Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ); + + const manifest = { + formatVersion: 1, + generatedBy: 'scripts/ios-prebuild/headers-inventory.js', + root: rootFolder, + totals: {headers: headers.length}, + collisions, + headers, + }; + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + + console.log(`Wrote ${headers.length} headers to ${outPath}`); +} + +if (require.main === module) { + main(); +} + +/** + * In-memory inventory for tooling that needs the classified header set + * without going through the JSON manifest on disk (e.g. the prebuild compose + * step feeding headers-spec.planFromInventory). + */ +function computeInventory(rootFolder /*: string */) /*: { + headers: Array, + collisions: Array<{naturalPath: string, sources: Array}>, +} */ { + const {entries, sourceToNatural, collisions} = buildInventory(rootFolder); + classifyEntries(entries, sourceToNatural, rootFolder); + return { + headers: Array.from(entries.values()).sort((a, b) => + a.naturalPath.localeCompare(b.naturalPath), + ), + // Natural-path collisions (two distinct sources mapping to the same + // Headers/ path) are silently merged by addIdentity — planFromInventory + // only keeps identities[0].source, so the second source is dropped. Surface + // them so the compose gate can fail closed (R8) instead of regressing. + collisions, + }; +} + +module.exports = { + buildInventory, + classifyEntries, + computeInventory, + scanHeader, + THIRD_PARTY_LIBS, + META_INTERNAL_RE, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js new file mode 100644 index 000000000000..c7f7371dc49c --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -0,0 +1,484 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * THE HEADERS SPEC — executable contract for the packaged header layout. + * + * One source of truth: the prebuild compose step (headers-compose.js) EMITS + * artifacts from it, and the SPM tooling derives what consumers need from it + * (nothing extra, by design). + * + * The rules: + * + * R1. React.framework/Headers ROOT serves the `React/` namespace (contents + * hoisted to root) plus the bare root aliases. The framework name supplies + * the `React/` prefix, so `` resolves verbatim through + * FRAMEWORK_SEARCH_PATHS. The `react/` (lowercase) namespace is NOT here — + * it ships in ReactNativeHeaders (R2). Resolving it through React.framework + * would require case-folding `react.framework` → `React.framework`, which + * only works on case-insensitive filesystems; the header-search-path route + * is exact and works everywhere. + * R2. Every other namespace (incl. `react/`) ships in ONE headers-only library + * xcframework ("ReactNativeHeaders"), namespace dirs at its Headers root, + * INCLUDING the third-party deps namespaces (folly/glog/boost/fmt/ + * double-conversion/fast_float, sourced from the deps artifact) — making + * ReactNativeDependencies binary-only. Served by exact header-search-path + * lookup, so resolution is filesystem-case-independent. + * R3. NO include rewriting anywhere — source headers are byte-identical to + * the repo (content authority = source files; layout authority = this + * spec). Consumers compile unchanged except bare-form angle includes + * (R6). + * R4. React.framework gets a framework module map with an umbrella over the + * ObjC modular surface: objc-modular-candidate ∧ React/-namespace ∧ no + * '+'-category header ∧ no C extern-inline definition (C99 extern inline + * emits a STRONG symbol per importing .m TU → duplicate symbols; + * RCTTextInputNativeCommands.h found empirically). + * R5. Every namespace with objc-modular-candidates gets a module declaring + * exactly those candidates (framework modules may not textually include + * non-modular framework headers; yoga + RCTDeprecation found + * empirically). Namespaces whose name is not a valid module identifier + * (e.g. jsinspector-modern) are exempt — they have no candidates today; + * planFromInventory FAILS CLOSED if that changes. `react/` is also exempt: its few + * objc-modular-candidates stay textual (as they already were inside + * React.framework) so no `react` module aliases the `React` framework + * module. + * R6. Bare root aliases are servable only as `` — bare angle forms + * (`#import `) have no framework spelling. This is the + * accepted, measured consumer migration (~4 lines ecosystem-wide). + * R7. Artifacts are code-signed AFTER header composition (signature pins the + * header manifest). + * R8. Collisions are ERRORS: two different source files may never project to + * the same destination path. + * R9. Private React headers — a curated allowlist of `` headers the + * umbrella (R4) excludes (they are `+`-suffixed and/or objc-blocked) — are + * added to the React framework module map so privileged consumers can reach + * them. The allowlist fails closed on drift (validatePrivateReactHeaders). + * R10. Per-namespace umbrella headers are emitted into ReactNativeHeaders so + * consumers that import a whole namespace (e.g. Expo) get one entry point; + * each is derived from namespaceModules (R5) so it cannot drift. + * R11. ONE source file, ONE content location. Some sources ship under several + * spellings (React/X.h + a legacy pod-namespace form like CoreModules/X.h, + * or a bare root alias + React_RCTAppDelegate/X.h). Under the VFS overlay + * all spellings mapped to one physical file, so #import-once and module + * ownership were coherent. The flattened layout would duplicate the + * declarations — any -fmodules consumer touching two spellings (even + * transitively: a legacy-spelling import whose header pulls a modular + * ) hits redefinition errors. Therefore: the MODULE-OWNED + * spelling keeps the content (the React/ form when it exists — it is the + * umbrella/module-React owner or the canonical textual home; else the + * R5-module namespaced form), and every other spelling is emitted as a + * one-line redirect shim (`#import `). Shims that are namespace- + * module members are fine: they import the owning module, so declarations + * stay single-owned. + */ + +const fs = require('fs'); +const path = require('path'); + +// Fallback root only — inventory `source` paths are relative to the root the +// inventory was computed from, so callers with a different tree (SPM tooling, +// headers-inventory --root) must pass that root to planFromInventory. +const RN_ROOT = path.join(__dirname, '..', '..'); + +/*:: +export type SpecEntry = { + relPath: string, // destination under the artifact's Headers root + source: string, // repo-relative source file + naturalPath: string, // canonical include identity (inventory key) + // R11: when set, the destination is a one-line redirect shim + // (`#import `) instead of a copy of the source file. + redirectTo?: string, +}; + +export type HeadersSpecPlan = { + // React.xcframework -> React.framework/Headers (R1) + react: Array, + // ReactNativeHeaders.xcframework -> Headers (R2); deps namespaces are + // added by the emitter from the deps artifact (not per-file here). + reactNativeHeaders: Array, + depsNamespaces: Array, + // R4: umbrella header list (React/-relative paths) + umbrella: Array, + // R5: plain modules for ReactNativeHeaders' module.modulemap + namespaceModules: {[ns: string]: Array}, + // R10: per-namespace umbrella headers emitted into ReactNativeHeaders. + namespaceUmbrellas: Array<{relPath: string, content: string}>, + // R9: private headers added to the React module map (allowlist). + privateReactHeaders: {modular: Array, textual: Array}, + collisions: Array, +}; +*/ + +// R2: third-party namespaces relocated from the deps artifact. Kept in exact +// sync with the artifact's Headers/ dirs — compose fails closed on a missing +// OR an undeclared namespace, and the include classifier +// (headers-inventory.js THIRD_PARTY_LIBS) derives from this same list. +const DEPS_NAMESPACES = [ + 'folly', + 'glog', + 'boost', + 'fmt', + 'double-conversion', + 'fast_float', +]; + +// Namespaces the deps artifact ships but which must NOT be relocated into +// ReactNativeHeaders: a REAL CocoaPods pod of the same name exists in consumer +// graphs and vends these headers itself (with its own module). Relocating +// textual copies puts them on every pod's HEADER_SEARCH_PATHS via the +// flattened React-Core-prebuilt/Headers and collides with the pod's own +// headers — duplicate @interface definitions compiling the SocketRocket pod, +// and a poisoned module graph in use_frameworks/explicit-modules apps +// (Expo regression, 2026-07-03). The compose set-equality guard treats these +// as declared (so only genuinely NEW namespaces fail closed), and the headers +// gate asserts they stay ABSENT from the artifact. +const DEPS_NAMESPACES_NOT_RELOCATED = ['SocketRocket']; + +// R4/R5 umbrella exclusion: C extern-inline definitions. +const EXTERN_INLINE_RE /*: RegExp */ = + /\b(RCT_EXTERN\s+inline|extern\s+inline)\b/; + +const MODULE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +// R9: Private React headers — a curated allowlist of `` headers that +// privileged framework consumers (e.g. Expo) import, but which the public +// umbrella (R4) excludes (they are `+`-suffixed and/or objc-blocked). They are +// already shipped in React.framework/Headers; adding them to the React module +// map keeps the existing `#import ` sites MODULAR under explicit +// modules — backwards-compatible, no consumer import (or Swift) changes. Split +// by inventory bucket: +// - modular: objc-modular-candidate (reach no C++) -> real `header`. +// - textual: objc-blocked (reach C++ via ``) -> `textual header` +// (a real member would re-trip -Wnon-modular-include; the C++ includes +// resolve at the consumer's use site, exactly as under the old VFS overlay). +// Privacy is by convention (the `+Private`/internal naming): a single binary +// artifact cannot hard-gate apps from headers a framework legitimately needs. +const PRIVATE_REACT_HEADERS /*: {modular: Array, textual: Array} */ = + { + modular: ['RCTBridge+Private.h'], + textual: [ + 'RCTComponentViewFactory.h', + 'RCTComponentViewProtocol.h', + 'RCTComponentViewRegistry.h', + 'RCTMountingManager.h', + 'RCTSurfacePresenter.h', + 'RCTViewComponentView.h', + ], + }; + +// Fail closed if an allowlisted private header drifts: it must exist in the +// inventory (else it was removed/renamed in source — e.g. RCTUIKit.h / +// RCTRootContentView.h, which need restoration, NOT this allowlist), and a +// `modular` entry must really be objc-modular-candidate (else it now reaches +// C++/third-party and must move to `textual`). +function validatePrivateReactHeaders(manifest /*: any */) /*: void */ { + const byNatural = new Map(manifest.headers.map(h => [h.naturalPath, h])); + const requireShipped = (name /*: string */) => { + const e = byNatural.get(`React/${name}`); + if (e == null) { + throw new Error( + `Private React header allowlist: React/${name} is absent from the ` + + `inventory (removed/renamed in source?). Restore the header or remove ` + + `it from PRIVATE_REACT_HEADERS.`, + ); + } + return e; + }; + for (const name of PRIVATE_REACT_HEADERS.modular) { + const e = requireShipped(name); + if (e.bucket !== 'objc-modular-candidate') { + throw new Error( + `Private React header React/${name} is bucket '${e.bucket}', not ` + + `'objc-modular-candidate' — it now reaches C++/third-party. Move it ` + + `to PRIVATE_REACT_HEADERS.textual.`, + ); + } + } + for (const name of PRIVATE_REACT_HEADERS.textual) { + requireShipped(name); + } +} + +function isUmbrellaSafe(h /*: any */, rnRoot /*: string */) /*: boolean */ { + if (h.bucket !== 'objc-modular-candidate' || h.naturalPath.includes('+')) { + return false; + } + try { + return !EXTERN_INLINE_RE.test( + fs.readFileSync(path.join(rnRoot, h.identities[0].source), 'utf8'), + ); + } catch { + return false; + } +} + +// R10: per-namespace umbrella headers. Some consumers (e.g. Expo's +// RCTAppDelegateUmbrella.h) probe +// `` via __has_include. +// The flattened ReactNativeHeaders layout (R2/R5) ships the individual +// namespace headers but no umbrella, so the probe fails and e.g. +// RCTReactNativeFactory / RCTRootViewFactory are never declared. Re-emit a +// per-namespace umbrella for the namespaces consumers probe — content DERIVED +// from namespaceModules (R5) so it can't drift — and add it to that +// namespace's module so the import stays modular under explicit modules. +// Targeted (not all namespaces): only those a consumer imports as +// ``. Extend as the ecosystem surfaces more. +const UMBRELLA_NAMESPACES /*: Array */ = ['React_RCTAppDelegate']; + +// Renders a per-namespace umbrella that re-imports the namespace's modular +// headers. Paths are relative to the namespace dir (where the umbrella lives), +// so the first `/` segment is stripped. +function renderNamespaceUmbrella( + ns /*: string */, + headers /*: Array */, +) /*: string */ { + const imports = headers + .map(np => `#import "${np.slice(ns.length + 1)}"`) + .join('\n'); + return `#ifdef __OBJC__\n#import \n#endif\n\n${imports}\n`; +} + +/** + * Computes the full layout plan from the header inventory manifest + * (build/header-inventory.json — regenerate with header-inventory.js). + * `rnRoot` is the tree the inventory's relative `source` paths resolve + * against; defaults to the manifest's recorded root, then to the package + * hosting this script. + */ +function planFromInventory( + manifest /*: any */, + rnRoot /*:: ?: string */, +) /*: HeadersSpecPlan */ { + const root = rnRoot ?? manifest.root ?? RN_ROOT; + validatePrivateReactHeaders(manifest); // R9: fail closed on allowlist drift + const react /*: Array */ = []; + const reactNativeHeaders /*: Array */ = []; + const umbrella /*: Array */ = []; + const namespaceModules /*: {[string]: Array} */ = {}; + const collisions /*: Array */ = []; + const seen /*: Map */ = new Map(); + + for (const h of manifest.headers) { + const np = h.naturalPath; + const source = h.identities[0].source; + let bucketKey; + let entryList; + let relPath; + if (np.startsWith('React/')) { + relPath = np.slice(6); // R1: hoist React/ to the framework Headers root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else if (!np.includes('/')) { + relPath = np; // R1/R6: bare alias at root + bucketKey = `React.framework/${relPath}`; + entryList = react; + } else { + // R2: every other namespace (incl. react/) keeps its prefix and is + // served from ReactNativeHeaders via the header search path. + relPath = np; + bucketKey = `ReactNativeHeaders/${relPath}`; + entryList = reactNativeHeaders; + } + const prev = seen.get(bucketKey); + if (prev != null) { + if (prev !== source) { + collisions.push(`${bucketKey}: ${prev} vs ${source}`); // R8 + } + continue; + } + seen.set(bucketKey, source); + entryList.push({relPath, source, naturalPath: np}); + + // R4: React umbrella membership. + if (np.startsWith('React/') && isUmbrellaSafe(h, root)) { + umbrella.push(np); + } + // R5: namespace modules (only for ReactNativeHeaders namespaces). Every + // namespace with modular candidates gets a module so that React.framework's + // modular headers can `#import ` as a MODULAR include (otherwise + // clang's -Wnon-modular-include-in-framework-module rejects it). `react/` is + // included here too — its module is renamed in renderNamespaceModuleMap so a + // `react` module never aliases the `React` framework module on a + // case-insensitive filesystem. + if (entryList === reactNativeHeaders) { + const ns = np.split('/')[0]; + if (isUmbrellaSafe(h, root)) { + // R5 exemption assert: a namespace whose name is not a valid module + // identifier cannot get a module, so a modular-candidate header in it + // would be silently non-modular — consumers importing it from a + // framework-module context hit -Wnon-modular-include downstream. + // Fail here instead: rename the namespace or keep the header out of + // the modular surface. + if (!MODULE_IDENT_RE.test(ns)) { + throw new Error( + `R5: namespace '${ns}' is not a valid module identifier but ` + + `ships a modular-candidate header (${np}). It cannot get a ` + + `namespace module, so the header would be silently ` + + `non-modular for consumers.`, + ); + } + if (!namespaceModules[ns]) { + namespaceModules[ns] = []; + } + namespaceModules[ns].push(np); + } + } + } + + umbrella.sort(); + for (const ns of Object.keys(namespaceModules)) { + namespaceModules[ns].sort(); + } + + // R11: assign redirect shims for duplicate spellings of one source. + // Owner precedence: the React/ form (module React / canonical textual home) + // when the source ships one; else the RNH namespaced form (the R5-module + // owner — bare root aliases redirect INTO it, since content must live at + // the module-owned spelling or dual-module/dual-copy redefinitions return). + const reactBySource /*: Map */ = new Map(); + for (const e of react) { + if (e.naturalPath.startsWith('React/')) { + reactBySource.set(e.source, e.relPath); + } + } + const rnhBySource /*: Map */ = new Map(); + for (const e of reactNativeHeaders) { + if (!rnhBySource.has(e.source)) { + rnhBySource.set(e.source, e.naturalPath); + } + } + for (const e of reactNativeHeaders) { + const reactForm = reactBySource.get(e.source); + if (reactForm != null) { + e.redirectTo = `React/${reactForm}`; + } else { + // Two RNH spellings of one source with no React/ form: the first is + // the owner, later ones shim to it. + const owner = rnhBySource.get(e.source); + if (owner != null && owner !== e.naturalPath) { + e.redirectTo = owner; + } + } + } + for (const e of react) { + if (e.naturalPath.includes('/')) { + continue; // real React/ content, never a shim + } + // Bare root alias: prefer the React/ owner (three-spelling case), else + // the RNH namespaced sibling (the React_RCTAppDelegate rule). + const reactForm = reactBySource.get(e.source); + const nsForm = rnhBySource.get(e.source); + if (reactForm != null) { + e.redirectTo = `React/${reactForm}`; + } else if (nsForm != null) { + e.redirectTo = nsForm; + } + } + + // R10: fail closed if a probed umbrella namespace lost all its modular + // headers (removed/renamed) — the umbrella would silently vanish and + // re-break consumers like Expo. + const namespaceUmbrellas = UMBRELLA_NAMESPACES.map(ns => { + const headers = namespaceModules[ns]; + if (headers == null || headers.length === 0) { + throw new Error( + `R10: umbrella namespace '${ns}' has no modular headers in the ` + + `inventory (removed/renamed?). Update UMBRELLA_NAMESPACES.`, + ); + } + return { + relPath: `${ns}/${ns}-umbrella.h`, + content: renderNamespaceUmbrella(ns, headers), + }; + }); + + return { + react, + reactNativeHeaders, + depsNamespaces: DEPS_NAMESPACES, + umbrella, + namespaceModules, + namespaceUmbrellas, + privateReactHeaders: PRIVATE_REACT_HEADERS, + collisions, + }; +} + +/** + * Renders React.framework's module map (R4 + R9). The umbrella covers the + * public modular surface; the allowlisted private headers (R9) are appended as + * explicit `header` (modular) / `textual header` (objc-blocked) entries so + * `#import ` of them stays modular without polluting the umbrella. + */ +function renderReactModuleMap( + privateReactHeaders /*:: ?: {modular: Array, textual: Array} */, +) /*: string */ { + const pv = privateReactHeaders ?? {modular: [], textual: []}; + const extra = [ + ...pv.modular.map(h => ` header "${h}"`), + ...pv.textual.map(h => ` textual header "${h}"`), + ]; + const extraBlock = extra.length > 0 ? '\n' + extra.join('\n') : ''; + return `framework module React { + umbrella header "React-umbrella.h"${extraBlock} + export * + module * { export * } +} +`; +} + +/** Renders the umbrella header content (R4). */ +function renderUmbrellaHeader(umbrella /*: Array */) /*: string */ { + return umbrella.map(u => `#import <${u}>`).join('\n') + '\n'; +} + +/** + * Renders ReactNativeHeaders' module.modulemap (R5): PLAIN (non-framework) + * modules, one per namespace with modular candidates — discovered implicitly + * by clang via the auto-added header search path. Headers are referenced by + * their path relative to the Headers root (= the modulemap's directory). + */ +function renderNamespaceModuleMap( + namespaceModules /*: {[string]: Array} */, +) /*: string */ { + // The module NAME is internal to clang's module graph (consumers never + // `@import` these; they `#import ` and clang maps the header to its + // module). It only has to be unique and must not alias the `React` framework + // module on a case-insensitive filesystem — so the lowercase `react` + // namespace is given a distinct module name. Header paths are unchanged, so + // `` still resolves and is now a modular include. + const moduleNameFor = (ns /*: string */) /*: string */ => + ns === 'react' ? 'ReactNativeHeaders_react' : ns; + const blocks = []; + for (const ns of Object.keys(namespaceModules).sort()) { + const headerLines = namespaceModules[ns].map(hh => ` header "${hh}"`); + // R10: the per-namespace umbrella is itself a module member, so importing + // it stays modular (otherwise it re-trips -Wnon-modular-include inside the + // consumer's framework module). + if (UMBRELLA_NAMESPACES.includes(ns)) { + headerLines.push(` header "${ns}/${ns}-umbrella.h"`); + } + blocks.push( + `module ${moduleNameFor(ns)} {\n` + + headerLines.join('\n') + + `\n export *\n}`, + ); + } + return blocks.join('\n\n') + '\n'; +} + +module.exports = { + planFromInventory, + DEPS_NAMESPACES_NOT_RELOCATED, + renderReactModuleMap, + renderUmbrellaHeader, + renderNamespaceModuleMap, + DEPS_NAMESPACES, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js new file mode 100644 index 000000000000..a18143bb6262 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -0,0 +1,477 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers gate — verifies the composed prebuilt header layout at GENERATOR + * time, so consumer-facing regressions fail the prebuild instead of a + * downstream (rn-tester / Expo / community) build. Three stages: + * + * 1. INCLUDE HEALTH (no artifact needed): every shipped header's + * notShipped / unresolved / quotedNotShipped includes are ratcheted + * against a committed baseline (headers-include-baseline.json). A NEW + * offender fails; a resolved one is reported so the baseline can shrink. + * Update intentionally with --update-baseline. + * 2. STRUCTURAL: the composed artifact's module maps / umbrellas are + * byte-identical to what the current spec renders, and the R9 private + * headers + deps namespaces are physically present. + * 3. COMPILE (simulator SDK, syntax-only): + * a. An ObjC TU that __has_include-asserts the R9/R10 surfaces, then + * imports under -fmodules (precompiling the React + * module = compiling EVERY umbrella header + the R9 modular member), + * one header from every R5 namespace module (precompiling each), and + * the R10 per-namespace umbrella. + * b. A privileged-consumer ObjC++ TU (the Expo shape): textually imports + * every R9 textual Fabric header against ReactNativeHeaders. + * c. A Swift TU: `import React` + `RCTBridge.moduleRegistry` — the Expo + * Swift case, proving the R9 modular header is module-visible. + * + * Usage: + * node scripts/ios-prebuild/headers-verify.js [--flavor Debug|Release] + * [--artifacts ] [--skip-compile] [--update-baseline] + */ + +const {computeInventory} = require('./headers-inventory'); +const { + DEPS_NAMESPACES_NOT_RELOCATED, + planFromInventory, + renderNamespaceModuleMap, + renderReactModuleMap, + renderUmbrellaHeader, +} = require('./headers-spec'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/*:: import type {HeadersSpecPlan} from './headers-spec'; */ + +const RN_ROOT = path.join(__dirname, '..', '..'); +const BASELINE_PATH = path.join(__dirname, 'headers-include-baseline.json'); + +// Mirrors the folly compiler flags RN itself builds with (helpers.rb). +const FOLLY_DEFINES = [ + '-DFOLLY_MOBILE=1', + '-DFOLLY_USE_LIBCPP=1', + '-DFOLLY_CFG_NO_COROUTINES=1', + '-DFOLLY_HAVE_CLOCK_GETTIME=1', +]; +const SIM_TARGET = 'arm64-apple-ios15.0-simulator'; + +function log(msg /*: string */) { + console.log(`[headers-verify] ${msg}`); +} + +// --------------------------------------------------------------------------- +// Stage 1 — include health ratchet +// --------------------------------------------------------------------------- + +/** + * Flattens an inventory's include-health offenders to stable, sorted strings: + * " -> ". + */ +function collectIncludeHealth(inventory /*: any */) /*: Array */ { + const out = []; + for (const h of inventory.headers) { + for (const t of h.includes.notShipped) { + out.push(`notShipped ${h.naturalPath} -> ${t}`); + } + for (const t of h.includes.unresolved) { + out.push(`unresolved ${h.naturalPath} -> ${t}`); + } + for (const t of h.includes.quotedNotShipped) { + out.push(`quotedNotShipped ${h.naturalPath} -> ${t}`); + } + } + return out.sort(); +} + +/** + * Ratchet compare. New offenders (not in baseline) are failures; baseline + * entries no longer present are reported so the baseline can shrink. + */ +function diffAgainstBaseline( + current /*: Array */, + baseline /*: Array */, +) /*: {newOffenders: Array, resolved: Array} */ { + const base = new Set(baseline); + const cur = new Set(current); + return { + newOffenders: current.filter(x => !base.has(x)), + resolved: baseline.filter(x => !cur.has(x)), + }; +} + +function verifyIncludeHealth( + inventory /*: any */, + updateBaseline /*: boolean */, +) /*: void */ { + const current = collectIncludeHealth(inventory); + if (updateBaseline) { + fs.writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n'); + log(`include-health baseline updated (${current.length} entries).`); + return; + } + if (!fs.existsSync(BASELINE_PATH)) { + throw new Error( + `include-health baseline missing at ${BASELINE_PATH}. ` + + `Generate it with --update-baseline.`, + ); + } + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); + const {newOffenders, resolved} = diffAgainstBaseline(current, baseline); + if (resolved.length > 0) { + log( + `include-health: ${resolved.length} baseline entr(ies) resolved — ` + + `shrink the baseline with --update-baseline:\n ${resolved.join('\n ')}`, + ); + } + if (newOffenders.length > 0) { + throw new Error( + `include-health ratchet: ${newOffenders.length} NEW unresolvable ` + + `include(s) in shipped headers (work in source builds via pod header ` + + `maps, break the packaged layout):\n ${newOffenders.join('\n ')}\n` + + `Fix the include (ship the target header / guard it), or — only if ` + + `knowingly acceptable — add it to the baseline with --update-baseline.`, + ); + } + log(`include-health: OK (${current.length} baselined, 0 new).`); +} + +// --------------------------------------------------------------------------- +// Stage 2 — structural checks against the composed artifact +// --------------------------------------------------------------------------- + +function findSimSlice( + xcfwPath /*: string */, + probe /*: string */, +) /*: string */ { + const slices = fs + .readdirSync(xcfwPath) + .filter( + d => + d.includes('simulator') && fs.existsSync(path.join(xcfwPath, d, probe)), + ); + if (slices.length === 0) { + throw new Error(`No simulator slice with ${probe} under ${xcfwPath}`); + } + return path.join(xcfwPath, slices[0]); +} + +function assertFileEquals( + filePath /*: string */, + expected /*: string */, + what /*: string */, +) { + if (!fs.existsSync(filePath)) { + throw new Error(`${what} missing: ${filePath}`); + } + const actual = fs.readFileSync(filePath, 'utf8'); + if (actual !== expected) { + throw new Error( + `${what} does not match the current spec render: ${filePath}\n` + + `The artifact is stale or was composed from a different tree — ` + + `recompose (node scripts/ios-prebuild -c) and re-verify.`, + ); + } +} + +function verifyStructural( + plan /*: HeadersSpecPlan */, + artifactsDir /*: string */, +) /*: {reactSlice: string, rnhHeaders: string} */ { + const reactSlice = findSimSlice( + path.join(artifactsDir, 'React.xcframework'), + 'React.framework', + ); + const fwk = path.join(reactSlice, 'React.framework'); + assertFileEquals( + path.join(fwk, 'Modules', 'module.modulemap'), + renderReactModuleMap(plan.privateReactHeaders), + 'React module map', + ); + assertFileEquals( + path.join(fwk, 'Headers', 'React-umbrella.h'), + renderUmbrellaHeader(plan.umbrella), + 'React umbrella header', + ); + for (const name of [ + ...plan.privateReactHeaders.modular, + ...plan.privateReactHeaders.textual, + ]) { + if (!fs.existsSync(path.join(fwk, 'Headers', name))) { + throw new Error(`R9 private header missing from artifact: ${name}`); + } + } + + const rnhSlice = findSimSlice( + path.join(artifactsDir, 'ReactNativeHeaders.xcframework'), + 'Headers', + ); + const rnhHeaders = path.join(rnhSlice, 'Headers'); + assertFileEquals( + path.join(rnhHeaders, 'module.modulemap'), + renderNamespaceModuleMap(plan.namespaceModules), + 'ReactNativeHeaders module map', + ); + for (const u of plan.namespaceUmbrellas) { + assertFileEquals( + path.join(rnhHeaders, u.relPath), + u.content, + `R10 umbrella ${u.relPath}`, + ); + } + for (const ns of plan.depsNamespaces) { + if (!fs.existsSync(path.join(rnhHeaders, ns))) { + throw new Error(`deps namespace missing from artifact: ${ns}`); + } + } + // Excluded namespaces must stay ABSENT: relocating them collides with the + // real pod's own headers (SocketRocket / Expo use_frameworks regression). + for (const ns of DEPS_NAMESPACES_NOT_RELOCATED) { + if (fs.existsSync(path.join(rnhHeaders, ns))) { + throw new Error( + `excluded deps namespace '${ns}' found in the artifact — it must NOT ` + + `be relocated (a real pod vends it; textual copies collide).`, + ); + } + } + log('structural: OK (module maps + umbrellas byte-match the spec render).'); + return {reactSlice, rnhHeaders}; +} + +// --------------------------------------------------------------------------- +// Stage 3 — compile gates +// --------------------------------------------------------------------------- + +/** ObjC TU: __has_include asserts + React module + every namespace module. */ +function renderObjcFixture(plan /*: HeadersSpecPlan */) /*: string */ { + const lines = []; + for (const name of [ + ...plan.privateReactHeaders.modular, + ...plan.privateReactHeaders.textual, + ]) { + lines.push( + `#if !__has_include()`, + `#error "R9 private header not servable: "`, + `#endif`, + ); + } + for (const u of plan.namespaceUmbrellas) { + lines.push( + `#if !__has_include(<${u.relPath}>)`, + `#error "R10 umbrella not servable: <${u.relPath}>"`, + `#endif`, + ); + } + // Precompiles the React framework module (every umbrella header + the R9 + // modular member compile as module members). + lines.push('#import '); + for (const name of plan.privateReactHeaders.modular) { + lines.push(`#import `); + } + // One header per R5 namespace module precompiles the whole module. + for (const ns of Object.keys(plan.namespaceModules).sort()) { + lines.push(`#import <${plan.namespaceModules[ns][0]}>`); + } + for (const u of plan.namespaceUmbrellas) { + lines.push(`#import <${u.relPath}>`); + } + return lines.join('\n') + '\n'; +} + +/** Privileged-consumer ObjC++ TU (Expo shape): the R9 textual surface. */ +function renderPrivilegedFixture(plan /*: HeadersSpecPlan */) /*: string */ { + return ( + plan.privateReactHeaders.textual + .map(name => `#import `) + .join('\n') + '\n' + ); +} + +/** Swift fixture: the Expo Swift private-API case. */ +function renderSwiftFixture() /*: string */ { + return `import React +func _headersVerifyProbe(_ bridge: RCTBridge) { + // RCTBridge.moduleRegistry is declared in RCTBridge+Private.h (an R9 + // modular member) — visible through \`import React\` iff R9 holds. + _ = bridge.moduleRegistry +} +`; +} + +function xcrun(args /*: Array */, what /*: string */) { + try { + execFileSync('xcrun', args, {stdio: ['ignore', 'pipe', 'pipe']}); + } catch (e) { + const stderr = e.stderr != null ? String(e.stderr) : String(e); + throw new Error(`${what} FAILED:\n${stderr}`); + } +} + +function runCompileGates( + plan /*: HeadersSpecPlan */, + reactSlice /*: string */, + rnhHeaders /*: string */, +) /*: void */ { + const sdk = execFileSync( + 'xcrun', + ['--sdk', 'iphonesimulator', '--show-sdk-path'], + {encoding: 'utf8'}, + ).trim(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'headers-verify-')); + try { + const common = [ + '-fsyntax-only', + '-target', + SIM_TARGET, + '-isysroot', + sdk, + '-F', + reactSlice, + '-I', + rnhHeaders, + ]; + + const objc = path.join(tmp, 'gate-modules.m'); + fs.writeFileSync(objc, renderObjcFixture(plan)); + xcrun( + [ + 'clang', + '-x', + 'objective-c', + '-fobjc-arc', + '-fmodules', + `-fmodules-cache-path=${path.join(tmp, 'mc')}`, + ...common, + objc, + ], + 'ObjC module gate (React module + namespace modules + R10 umbrella)', + ); + log( + `compile: React module + ${Object.keys(plan.namespaceModules).length} ` + + `namespace modules precompile OK.`, + ); + + const objcxx = path.join(tmp, 'gate-privileged.mm'); + fs.writeFileSync(objcxx, renderPrivilegedFixture(plan)); + xcrun( + [ + 'clang++', + '-x', + 'objective-c++', + '-std=c++20', + '-fobjc-arc', + ...FOLLY_DEFINES, + '-Wno-comma', + '-Wno-shorten-64-to-32', + ...common, + objcxx, + ], + 'Privileged-consumer gate (R9 textual Fabric headers)', + ); + log('compile: privileged-consumer (Expo-shape) ObjC++ fixture OK.'); + + const swift = path.join(tmp, 'gate-swift.swift'); + fs.writeFileSync(swift, renderSwiftFixture()); + xcrun( + [ + 'swiftc', + '-typecheck', + '-sdk', + sdk, + '-target', + SIM_TARGET, + '-module-cache-path', + path.join(tmp, 'mc-swift'), + '-F', + reactSlice, + '-I', + rnhHeaders, + swift, + ], + 'Swift gate (import React + RCTBridge.moduleRegistry)', + ); + log('compile: Swift moduleRegistry fixture OK.'); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv /*: Array */) /*: { + flavor: string, + artifacts: ?string, + skipCompile: boolean, + updateBaseline: boolean, +} */ { + let flavor = 'Debug'; + let artifacts /*: ?string */ = null; + let skipCompile = false; + let updateBaseline = false; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--flavor') { + flavor = argv[++i]; + } else if (argv[i] === '--artifacts') { + artifacts = argv[++i]; + } else if (argv[i] === '--skip-compile') { + skipCompile = true; + } else if (argv[i] === '--update-baseline') { + updateBaseline = true; + } + } + return {flavor, artifacts, skipCompile, updateBaseline}; +} + +function main(argv /*:: ?: Array */) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + const inventory = computeInventory(RN_ROOT); + const plan = planFromInventory(inventory, RN_ROOT); + if (plan.collisions.length > 0) { + throw new Error(`R8 collisions:\n ${plan.collisions.join('\n ')}`); + } + + verifyIncludeHealth(inventory, args.updateBaseline); + if (args.updateBaseline) { + return; + } + + const artifactsDir = + args.artifacts ?? + path.join(RN_ROOT, '.build', 'output', 'xcframeworks', args.flavor); + if (!fs.existsSync(artifactsDir)) { + throw new Error( + `No composed artifacts at ${artifactsDir} — run the compose first ` + + `(node scripts/ios-prebuild -c -f ${args.flavor}).`, + ); + } + const {reactSlice, rnhHeaders} = verifyStructural(plan, artifactsDir); + + if (args.skipCompile) { + log('compile gates skipped (--skip-compile).'); + } else { + runCompileGates(plan, reactSlice, rnhHeaders); + } + log('ALL GATES PASSED.'); +} + +if (require.main === module) { + main(); +} + +module.exports = { + collectIncludeHealth, + diffAgainstBaseline, + renderObjcFixture, + renderPrivilegedFixture, + main, +}; diff --git a/packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h b/packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h deleted file mode 100644 index 53d69b27c1b8..000000000000 --- a/packages/react-native/scripts/ios-prebuild/templates/React-umbrella.h +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#ifdef __OBJC__ -#import -#else -#ifndef FOUNDATION_EXPORT -#if defined(__cplusplus) -#define FOUNDATION_EXPORT extern "C" -#else -#define FOUNDATION_EXPORT extern -#endif -#endif -#endif - -// RCTDefines.h defines RCT_EXTERN, RCT_EXTERN_C_BEGIN/END, RCT_EXPORT_METHOD, etc. -// Must be loaded first so its macros are visible to subsequent umbrella headers (e.g. RCTBridgeConstants.h). -// clang-format off -#import -// clang-format on -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -FOUNDATION_EXPORT double ReactVersionNumber; -FOUNDATION_EXPORT const unsigned char ReactVersionString[]; diff --git a/packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h b/packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h deleted file mode 100644 index 78627a9e2566..000000000000 --- a/packages/react-native/scripts/ios-prebuild/templates/React_RCTAppDelegate-umbrella.h +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#ifdef __OBJC__ -#import -#else -#ifndef FOUNDATION_EXPORT -#if defined(__cplusplus) -#define FOUNDATION_EXPORT extern "C" -#else -#define FOUNDATION_EXPORT extern -#endif -#endif -#endif - -#import "RCTAppDelegate.h" -#import "RCTAppSetupUtils.h" -#import "RCTDefaultReactNativeFactoryDelegate.h" -#import "RCTDependencyProvider.h" -#import "RCTJSRuntimeConfiguratorProtocol.h" -#import "RCTReactNativeFactory.h" -#import "RCTRootViewFactory.h" -#import "RCTUIConfiguratorProtocol.h" - -FOUNDATION_EXPORT double React_RCTAppDelegateVersionNumber; -FOUNDATION_EXPORT const unsigned char React_RCTAppDelegateVersionString[]; diff --git a/packages/react-native/scripts/ios-prebuild/templates/module.modulemap b/packages/react-native/scripts/ios-prebuild/templates/module.modulemap deleted file mode 100644 index 587aaaa832b6..000000000000 --- a/packages/react-native/scripts/ios-prebuild/templates/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module React { - umbrella header "React_Core/React_Core-umbrella.h" - export * -} - - -framework module React_RCTAppDelegate { - umbrella header "React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h" - export * -} diff --git a/packages/react-native/scripts/ios-prebuild/types.js b/packages/react-native/scripts/ios-prebuild/types.js index 56cad1f9ab18..663ebfa77a91 100644 --- a/packages/react-native/scripts/ios-prebuild/types.js +++ b/packages/react-native/scripts/ios-prebuild/types.js @@ -22,24 +22,6 @@ export type Destination = export type BuildFlavor = 'Debug' | 'Release'; export type MavenSubGroup = 'hermes' | 'react'; - -export type VFSEntry = { - name: string, - type: 'file' | 'directory', - 'external-contents'?: string, - contents?: Array, -}; - -export type VFSOverlay = { - version: number, - 'case-sensitive': boolean, - roots: Array, -}; - -export type HeaderMapping = { - key: string, - path: string, -}; */ module.exports = {}; diff --git a/packages/react-native/scripts/ios-prebuild/vfs.js b/packages/react-native/scripts/ios-prebuild/vfs.js deleted file mode 100644 index 13e2cb233d5f..000000000000 --- a/packages/react-native/scripts/ios-prebuild/vfs.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -/*:: import type {HeaderMapping, VFSEntry, VFSOverlay} from './types'; */ - -const headers = require('./headers'); - -const {getHeaderFilesFromPodspecs} = headers; - -const ROOT_PATH_PLACEHOLDER = '${ROOT_PATH}'; - -/** - * Builds a hierarchical VFS directory structure from a list of header mappings. - * Clang's VFS overlay requires a tree structure where directories contain their children. - */ -function buildVFSStructure( - mappings /*: Array */, -) /*: Array */ { - // Group files by their directory structure - const dirTree /*: Map> */ = new Map(); - - for (const mapping of mappings) { - const parts = mapping.key.split('/'); - const fileName = parts[parts.length - 1]; - const dirPath = parts.slice(0, -1).join('/'); - - if (!dirTree.has(dirPath)) { - dirTree.set(dirPath, new Map()); - } - const filesMap = dirTree.get(dirPath); - if (filesMap) { - filesMap.set(fileName, mapping.path); - } - } - - // Build the root-level entries (files at root + top-level directories) - const rootDirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - const topLevel = dirPath.split('/')[0]; - if (topLevel) { - rootDirs.add(topLevel); - } - } - - const roots /*: Array */ = []; - - // Add files that live at the root (e.g. key === 'RCTAppDelegate.h') - const rootFiles = dirTree.get(''); - if (rootFiles) { - for (const [fileName, sourcePath] of Array.from( - rootFiles.entries(), - ).sort()) { - roots.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - for (const rootDir of Array.from(rootDirs).sort()) { - const dirEntry = buildDirectoryEntry(rootDir, '', dirTree); - roots.push(dirEntry); - } - - return roots; -} - -/** - * Recursively builds a directory entry for the VFS - */ -function buildDirectoryEntry( - dirName /*: string */, - parentPath /*: string */, - dirTree /*: Map> */, -) /*: VFSEntry */ { - const currentPath = parentPath ? `${parentPath}/${dirName}` : dirName; - const contents /*: Array */ = []; - - // Add files in this directory - const filesInDir = dirTree.get(currentPath); - if (filesInDir) { - for (const [fileName, sourcePath] of Array.from( - filesInDir.entries(), - ).sort()) { - contents.push({ - name: fileName, - type: 'file', - 'external-contents': sourcePath, - }); - } - } - - // Add subdirectories - const subdirs /*: Set */ = new Set(); - for (const dirPath of dirTree.keys()) { - if (dirPath.startsWith(currentPath + '/')) { - const remainder = dirPath.slice(currentPath.length + 1); - const nextDir = remainder.split('/')[0]; - if (nextDir) { - subdirs.add(nextDir); - } - } - } - - for (const subdir of Array.from(subdirs).sort()) { - contents.push(buildDirectoryEntry(subdir, currentPath, dirTree)); - } - - return { - name: dirName, - type: 'directory', - contents, - }; -} - -/** - * Simple YAML generator for VFS overlay structure (hierarchical format) - */ -function generateVFSOverlayYAML(overlay /*: VFSOverlay */) /*: string */ { - let yaml = ''; - - yaml += `version: ${String(overlay.version)}\n`; - yaml += `case-sensitive: ${String(overlay['case-sensitive'])}\n`; - yaml += `roots:\n`; - - for (const root of overlay.roots) { - yaml += generateEntryYAML(root, 1); - } - - return yaml; -} - -/** - * Recursively generates YAML for a VFS entry - */ -function generateEntryYAML( - entry /*: VFSEntry */, - indent /*: number */, -) /*: string */ { - const spaces = ' '.repeat(indent); - let yaml = ''; - - yaml += `${spaces}- name: '${entry.name}'\n`; - yaml += `${spaces} type: '${entry.type}'\n`; - - if (entry['external-contents']) { - yaml += `${spaces} external-contents: '${entry['external-contents']}'\n`; - } - - if (entry.contents && entry.contents.length > 0) { - yaml += `${spaces} contents:\n`; - for (const child of entry.contents) { - yaml += generateEntryYAML(child, indent + 2); - } - } - - return yaml; -} - -/** - * Creates a VFS overlay object from the header files in podspecs. - * The source paths use ${ROOT_PATH} as a placeholder for later replacement - * with the actual root path on the end user's machine. - * - * The VFS overlay wraps all header mappings under a single root at - * ${ROOT_PATH}/Headers, which matches the HEADER_SEARCH_PATHS configured - * in rncore.rb. This allows the compiler to find headers like - * by looking up ${ROOT_PATH}/Headers/yoga/style/Style.h - * which the VFS redirects to the flat location in the xcframework. - * - * @param rootFolder The root folder of the React Native package - * @returns A VFS overlay object that can be serialized to YAML - */ -function createVFSOverlayContents(rootFolder /*: string */) /*: VFSOverlay */ { - // Get header files from podspecs (disable testing since we just need the mappings) - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - const mappings /*: Array */ = []; - - // Process each podspec and its header files - Object.keys(podSpecsWithHeaderFiles).forEach(podspecPath => { - const headerMaps = podSpecsWithHeaderFiles[podspecPath]; - - // Use the first podspec spec name as the podspec name (this is the root spec) - const podSpecName = headerMaps[0].specName.replace('-', '_'); - - headerMaps.forEach(headerMap => { - headerMap.headers.forEach(header => { - // The key is just the target path (the import path) - // e.g., 'react/renderer/graphics/Size.h' for #import - let key = header.target; - - // If the podspec doesn't specify a header_dir, CocoaPods exposes public headers under - // (and umbrella headers typically use quoted imports resolved relative - // to the pod's public headers directory). To mirror that layout and avoid collisions - // between pods, prefix root-level header targets with the pod spec name. - if ( - !key.includes('/') && - (!headerMap.headerDir || headerMap.headerDir === '') - ) { - key = `${podSpecName}/${key}`; - } - - // The external-contents path is always podSpecName + header.target because - // xcframework.js copies headers to: outputHeadersPath/podSpecName/headerFile.target - // So the VFS must point to that same location. - const sourcePath = `${ROOT_PATH_PLACEHOLDER}/Headers/${podSpecName}/${header.target}`; - - mappings.push({ - key, - path: sourcePath, - }); - }); - }); - }); - - // Build the hierarchical VFS structure from mappings - const innerRoots = buildVFSStructure(mappings); - - // Wrap all roots under a single ${ROOT_PATH}/Headers root. - // This is required because Clang's VFS overlay needs absolute paths for root entries. - // The compiler will have -I${ROOT_PATH}/Headers in its include paths, so when it - // searches for , it looks for ${ROOT_PATH}/Headers/yoga/style/Style.h. - // The VFS overlay intercepts this and maps it to the actual flat location. - const wrappedRoot /*: VFSEntry */ = { - name: `${ROOT_PATH_PLACEHOLDER}/Headers`, - type: 'directory', - contents: innerRoots, - }; - - return { - version: 0, - 'case-sensitive': false, - roots: [wrappedRoot], - }; -} - -/** - * Creates a VFS overlay YAML file from the header files in podspecs. - * This is a convenience function that combines createVFSOverlayContents and - * generateVFSOverlayYAML into a single call. - * - * @param rootFolder The root folder of the React Native package - * @returns The VFS overlay as a YAML string ready to be written to a file - */ -function createVFSOverlay(rootFolder /*: string */) /*: string */ { - const overlay = createVFSOverlayContents(rootFolder); - return generateVFSOverlayYAML(overlay); -} - -/** - * Resolves a VFS overlay template by replacing the ${ROOT_PATH} placeholder - * with the actual root path. This is the equivalent of the Ruby create_vfs_overlay - * function in rncore.rb. - * - * The VFS overlay template contains ${ROOT_PATH} placeholders that need to be - * replaced with the actual path to the xcframework on the end user's machine - * (e.g., the path to React.xcframework in the Pods folder). - * - * @param vfsTemplate The VFS overlay template content (YAML string with ${ROOT_PATH} placeholders) - * @param rootPath The actual root path to substitute for ${ROOT_PATH} - * @returns The resolved VFS overlay YAML string with absolute paths - */ -function resolveVFSOverlay( - vfsTemplate /*: string */, - rootPath /*: string */, -) /*: string */ { - return vfsTemplate.split(ROOT_PATH_PLACEHOLDER).join(rootPath); -} - -module.exports = { - createVFSOverlay, - resolveVFSOverlay, -}; diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 1f7d473046e5..347d0942fbba 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -13,46 +13,16 @@ const { generateFBReactNativeSpecIOS, } = require('../codegen/generate-artifacts-executor/generateFBReactNativeSpecIOS'); -const headers = require('./headers'); const utils = require('./utils'); -const vfs = require('./vfs'); const childProcess = require('child_process'); const fs = require('fs'); const path = require('path'); -const {execSync} = childProcess; -const {getHeaderFilesFromPodspecs} = headers; -const {createFolderIfNotExists, createLogger} = utils; -const {createVFSOverlay} = vfs; +const {execSync, execFileSync} = childProcess; +const {createLogger} = utils; const frameworkLog = createLogger('XCFramework'); -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const REACT_CORE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React-umbrella.h', -); - -/** - * Path to the React umbrella header file. - * This umbrella header contains ONLY the list of headers that are accessible by Swift, so no C++ construct are allowed in the headers. - */ -const RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'React_RCTAppDelegate-umbrella.h', -); - -const RN_MODULEMAP_PATH /*: string*/ = path.join( - __dirname, - 'templates', - 'module.modulemap', -); - function buildXCFrameworks( rootFolder /*: string */, buildFolder /*: string */, @@ -70,7 +40,7 @@ function buildXCFrameworks( buildType, 'React.xcframework', ); - // Delete all target platform folders (everything but the Headers and Modules folders) + // Delete any previous output try { fs.rmSync(outputPath, {recursive: true, force: true}); } catch (error) { @@ -104,98 +74,41 @@ function buildXCFrameworks( return; } - // Use the header files from podspecs - const podSpecsWithHeaderFiles = getHeaderFilesFromPodspecs(rootFolder); - - // Delete header files to the output path - const outputHeadersPath = path.join(outputPath, 'Headers'); - - // Store umbrella headers keyed on podspec names - const umbrellaHeaders /*: {[key: string]: string} */ = {}; - const copiedHeaderFilesWithPodspecNames /*: {[key: string]: string[]} */ = {}; - - // Enumerate podspecs and copy headers, create umbrella headers and module map file - Object.keys(podSpecsWithHeaderFiles).forEach(podspec => { - const headerFiles = podSpecsWithHeaderFiles[podspec] - .map(h => h.headers) - .flat(); - - // Use the first podspec spec name as the podspec name (this is the root spec in the podspec file) - const podSpecName = podSpecsWithHeaderFiles[podspec][0].specName.replace( - '-', - '_', - ); - - if (headerFiles.length > 0) { - // Create a folder for the podspec in the output headers path - const podSpecTargetFolder = path.join(outputHeadersPath, podSpecName); - - // Copy each header file to the podspec folder - copiedHeaderFilesWithPodspecNames[podSpecName] = headerFiles.map( - headerFile => { - const headerFileTargetPath = path.join( - podSpecTargetFolder, - headerFile.target, - ); - createFolderIfNotExists(path.dirname(headerFileTargetPath)); - fs.copyFileSync(headerFile.source, headerFileTargetPath); - return headerFileTargetPath; - }, - ); - - // Create umbrella header file for the podspec - const umbrellaHeaderFilename = path.join( - podSpecTargetFolder, - podSpecName + '-umbrella.h', - ); - - if ( - podSpecName === 'React_Core' || - podSpecName === 'React_RCTAppDelegate' - ) { - if (podSpecName === 'React_Core') { - // Copy the React-umbrella.h file to the umbrella header filename - fs.copyFileSync( - REACT_CORE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } else { - fs.copyFileSync( - RCT_APP_DELEGATE_UMBRELLA_HEADER_PATH, - umbrellaHeaderFilename, - ); - } - - // Store the umbrella header filename in the umbrellaHeaders object - umbrellaHeaders[podSpecName] = umbrellaHeaderFilename; - } - } - }); - - // Create the module map file using the header files in podSpecsWithHeaderFiles - const moduleMapFile = createModuleMapFile(outputPath); - if (!moduleMapFile) { - frameworkLog( - 'Failed to create module map file. The XCFramework may not work correctly. Stopping.', - 'error', - ); - return; - } + // Copy Symbols to symbols folder + copySymbols(outputPath, frameworkFolders); - // Copy header files and module map file to each platform slice in the XCFramework - copyHeaderFilesToSlices( + // Emit the headers-spec layout into every slice's React.framework and build + // the ReactNativeHeaders headers-only xcframework beside it. This is the only + // header surface consumers compile against — no root Headers/, no clang VFS + // overlay. MUST run before signing (spec R7: the signature pins the manifest). + const { + buildReactNativeHeadersXcframework, + computeSpecPlan, + emitReactFrameworkHeaders, + } = require('./headers-compose'); + const depsHeaders = path.join( rootFolder, - outputPath, - moduleMapFile, - umbrellaHeaders, - copiedHeaderFilesWithPodspecNames, + 'third-party', + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + const plan = computeSpecPlan(rootFolder); + emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // NOTE: Hermes public headers (``) are folded into + // ReactNativeHeaders on the consumer side by ensureHeadersLayout. When this + // publish path is productionized, pass the prebuild's hermes destroot/include + // as the 6th arg so the PUBLISHED ReactNativeHeaders carries hermes too. + const headersXcfw = buildReactNativeHeadersXcframework( + path.dirname(outputPath), + plan, + depsHeaders, + rootFolder, + true, // include the mac-catalyst slice in the real compose ); - - // Copy Symbols to symbols folder - copySymbols(outputPath, frameworkFolders); if (identity) { signXCFramework(identity, outputPath); + signXCFramework(identity, headersXcfw); } // Tar the output folder to a .tar.gz file @@ -208,11 +121,21 @@ function buildXCFrameworks( ); frameworkLog('Creating tar file: ' + tarFilePath); try { - execSync( - `tar -czf ${tarFilePath} -C ${path.dirname(outputPath)} React.xcframework`, - { - stdio: 'inherit', - }, + // Ship ReactNativeHeaders.xcframework alongside React.xcframework in the + // reactnative-core artifact so the React-Core-prebuilt pod can vend both + // (React.framework -> , ReactNativeHeaders -> every other + // namespace). The headers-only xcframework is a sibling of React.xcframework. + execFileSync( + 'tar', + [ + '-czf', + tarFilePath, + '-C', + path.dirname(outputPath), + 'React.xcframework', + path.basename(headersXcfw), + ], + {stdio: 'inherit'}, ); } catch (error) { frameworkLog( @@ -220,6 +143,34 @@ function buildXCFrameworks( 'warning', ); } + + // Publish ReactNativeHeaders alongside React. + const headersTarPath = path.join( + buildFolder, + 'output', + 'xcframeworks', + buildType, + 'ReactNativeHeaders.xcframework.tar.gz', + ); + frameworkLog('Creating tar file: ' + headersTarPath); + try { + execFileSync( + 'tar', + [ + '-czf', + headersTarPath, + '-C', + path.dirname(headersXcfw), + 'ReactNativeHeaders.xcframework', + ], + {stdio: 'inherit'}, + ); + } catch (error) { + frameworkLog( + `Error creating ReactNativeHeaders tar: ${error.message}`, + 'warning', + ); + } } function copySymbols( @@ -277,134 +228,6 @@ function copySymbols( }); } -// Copy header files and module map file to each platform slice in the XCFramework. -function copyHeaderFilesToSlices( - rootFolder /*:string*/, - outputPath /*:string*/, - moduleMapFile /*:string*/, - umbrellaHeaderFiles /*:{[key: string]: string}*/, - outputHeaderFiles /*: {[key: string]: string[]} */, -) { - frameworkLog('Linking modules and headers to platform folders for slice...'); - - // Enumerate all platform folders in the output path - const platformFolders = fs - .readdirSync(outputPath) - .map(folder => path.join(outputPath, folder)) - .filter(folder => { - return ( - fs.statSync(folder).isDirectory() && - !folder.endsWith('Headers') && - !folder.endsWith('Modules') - ); - }); - - platformFolders.forEach(platformFolder => { - // Link the Modules folder into the platform folder - const targetModulesFolder = path.join( - platformFolder, - 'React.Framework', - 'Modules', - ); - createFolderIfNotExists(targetModulesFolder); - - try { - fs.linkSync( - moduleMapFile, - path.join(targetModulesFolder, path.basename(moduleMapFile)), - ); - } catch (error) { - frameworkLog( - `Error copying module map file: ${error.message}. Check if the file exists at ${moduleMapFile}.`, - 'error', - ); - } - // Copy headers folder into the platform folder - const targetHeadersFolder = path.join( - platformFolder, - 'React.Framework', - 'Headers', - ); - - // Copy umbrella / header files into the platform folder - Object.keys(umbrellaHeaderFiles).forEach(podSpecName => { - const umbrellaHeaderFile = umbrellaHeaderFiles[podSpecName]; - - // Create the target folder for the umbrella header file - const targetPodSpecFolder = path.join(targetHeadersFolder, podSpecName); - createFolderIfNotExists(targetPodSpecFolder); - // Copy the umbrella header file to the target folder - try { - fs.copyFileSync( - umbrellaHeaderFile, - path.join(targetPodSpecFolder, path.basename(umbrellaHeaderFile)), - ); - } catch (error) { - frameworkLog( - `Error copying umbrella header file: ${umbrellaHeaderFile}\nError: ${error.message}. Check if the file exists.`, - 'error', - ); - } - }); - - Object.keys(outputHeaderFiles).forEach(podSpecName => { - outputHeaderFiles[podSpecName].forEach(headerFile => { - // Get the relative path from the root Headers folder to preserve directory structure - // headerFile is like /path/to/Headers/Yoga/yoga/style/Style.h - // We need to extract Yoga/yoga/style/Style.h and copy to the same structure in the slice - const rootHeadersFolder = path.join(outputPath, 'Headers'); - const relativeHeaderPath = path.relative(rootHeadersFolder, headerFile); - const targetHeaderFile = path.join( - targetHeadersFolder, - relativeHeaderPath, - ); - createFolderIfNotExists(path.dirname(targetHeaderFile)); - if (!fs.existsSync(targetHeaderFile)) { - try { - fs.copyFileSync(headerFile, targetHeaderFile); - } catch (error) { - frameworkLog( - `Error copying header file: ${error.message}. Check if the file exists.`, - 'error', - ); - } - } - }); - }); - }); - - // Create VFS overlay file at the XCFramework root (same for all platforms) - const vfsFilePath = path.join(outputPath, 'React-VFS-template.yaml'); - try { - fs.writeFileSync(vfsFilePath, createVFSOverlay(rootFolder), 'utf8'); - frameworkLog(`Created VFS overlay: ${path.basename(vfsFilePath)}`); - } catch (error) { - frameworkLog(`Error creating VFS overlay file: ${error.message}.`, 'error'); - } -} - -function createModuleMapFile(outputPath /*: string */) { - // Create/get the module map folder - const moduleMapFolder = path.join(outputPath, 'Modules'); - createFolderIfNotExists(moduleMapFolder); - - // Create the module map file - const moduleMapFile = path.join(moduleMapFolder, 'module.modulemap'); - - frameworkLog('Creating module map file: ' + moduleMapFile); - - try { - fs.copyFileSync(RN_MODULEMAP_PATH, moduleMapFile); - return moduleMapFile; - } catch (error) { - frameworkLog( - `Error creating module map file: ${error.message}. Check if the file exists.`, - 'error', - ); - return null; - } -} - function getArchsFromFramework(frameworkPath /*:string*/) { try { return execSync(`vtool -show-build ${frameworkPath}|grep platform`) diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 78ddc33661b9..38a0619f1708 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -21,6 +21,7 @@ require_relative './cocoapods/privacy_manifest_utils.rb' require_relative './cocoapods/spm.rb' require_relative './cocoapods/rncore.rb' +require_relative './cocoapods/rncore_facades.rb' # Importing to expose use_native_modules! require_relative './cocoapods/autolinking.rb' @@ -52,6 +53,28 @@ def prepare_react_native_project! ReactNativePodsUtils.create_xcode_env_if_missing end +# Declares a React core pod, choosing source vs prebuilt facade. In prebuilt +# mode, pods in the RNCoreFacades manifest are installed as dependency-only +# facades (no source/headers) so they can't shadow the prebuilt artifact; their +# code + headers come from React-Core-prebuilt. Everything else (and the whole +# source build) is unaffected. See cocoapods/rncore_facades.rb. +def rncore_pod(name, **opts) + base = name.split('/').first + if !ReactNativeCoreUtils.build_rncore_from_source() && RNCoreFacades.facade?(base) + # Install as a LOCAL pod (`:path`) from the generated facade directory, so + # CocoaPods never fetches the placeholder git source (a `:podspec` external + # source would). Both the pod and any subspec declaration point at the SAME + # directory, so CocoaPods sees one consistent source for the name (a bare + # subspec declaration would otherwise default to the spec repo and conflict). + # Preserve the caller's options (e.g. :modular_headers) but replace :path with + # the facade directory. + facade_opts = opts.reject { |k, _| k == :path } + pod name, **facade_opts, :path => RNCoreFacades.facade_path(base) + else + pod name, **opts + end +end + # Function that setup all the react native dependencies #  # Parameters @@ -123,19 +146,25 @@ def use_react_native! ( # Update ReactNativeCoreUtils so that we can easily switch between source and prebuilt ReactNativeCoreUtils.setup_rncore(prefix, react_native_version) + # In prebuilt mode, generate the facade podspecs the core pods are installed as + # (instead of their source podspecs) so they don't ship shadowing headers. + unless ReactNativeCoreUtils.build_rncore_from_source() + RNCoreFacades.generate(react_native_path, Pod::Config.instance.installation_root, react_native_version, min_ios_version_supported) + end + Pod::UI.puts "Configuring the target with the New Architecture\n" # The Pods which should be included in all projects - pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector" - pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" + rncore_pod 'FBLazyVector', :path => "#{prefix}/Libraries/FBLazyVector" + rncore_pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" pod 'RCTTypeSafety', :path => "#{prefix}/Libraries/TypeSafety", :modular_headers => true pod 'React', :path => "#{prefix}/" if !ReactNativeCoreUtils.build_rncore_from_source() pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true end - pod 'React-Core', :path => "#{prefix}/" + rncore_pod 'React-Core', :path => "#{prefix}/" pod 'React-CoreModules', :path => "#{prefix}/React/CoreModules" - pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime" + rncore_pod 'React-RCTRuntime', :path => "#{prefix}/React/Runtime" pod 'React-RCTAppDelegate', :path => "#{prefix}/Libraries/AppDelegate" pod 'React-RCTActionSheet', :path => "#{prefix}/Libraries/ActionSheetIOS" pod 'React-RCTAnimation', :path => "#{prefix}/Libraries/NativeAnimation" @@ -146,7 +175,7 @@ def use_react_native! ( pod 'React-RCTSettings', :path => "#{prefix}/Libraries/Settings" pod 'React-RCTText', :path => "#{prefix}/Libraries/Text" pod 'React-RCTVibration', :path => "#{prefix}/Libraries/Vibration" - pod 'React-Core/RCTWebSocket', :path => "#{prefix}/" + rncore_pod 'React-Core/RCTWebSocket', :path => "#{prefix}/" pod 'React-cxxreact', :path => "#{prefix}/ReactCommon/cxxreact" pod 'React-cxxstableapi', :path => "#{prefix}/ReactCommon/react/cxxstableapi" pod 'React-debug', :path => "#{prefix}/ReactCommon/react/debug" @@ -164,7 +193,7 @@ def use_react_native! ( pod 'React-Mapbuffer', :path => "#{prefix}/ReactCommon" pod 'React-bridging', :path => "#{prefix}/ReactCommon/react/bridging", :modular_headers => true pod 'React-jserrorhandler', :path => "#{prefix}/ReactCommon/jserrorhandler" - pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + rncore_pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation" pod 'React-RCTFBReactNativeSpec', :path => "#{prefix}/React" pod 'React-jsi', :path => "#{prefix}/ReactCommon/jsi" pod 'RCTSwiftUI', :path => "#{prefix}/ReactApple/RCTSwiftUI" @@ -197,7 +226,7 @@ def use_react_native! ( pod 'React-logger', :path => "#{prefix}/ReactCommon/logger" pod 'ReactCommon/turbomodule/core', :path => "#{prefix}/ReactCommon", :modular_headers => true pod 'React-NativeModulesApple', :path => "#{prefix}/ReactCommon/react/nativemodule/core/platform/ios", :modular_headers => true - pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true + rncore_pod 'Yoga', :path => "#{prefix}/ReactCommon/yoga", :modular_headers => true setup_fabric!(:react_native_path => prefix) setup_bridgeless!(:react_native_path => prefix, :use_hermes => hermes_enabled) @@ -569,15 +598,14 @@ def react_native_post_install( ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) if !ReactNativeCoreUtils.build_rncore_from_source() - # In XCode 26 we need to revert the new setting SWIFT_ENABLE_EXPLICIT_MODULES when building - # with precompiled binaries. - ReactNativePodsUtils.set_build_setting(installer, build_setting: "SWIFT_ENABLE_EXPLICIT_MODULES", value: "NO") - - # Process the VFS overlay for prebuilt React Native Core - this is done as part of the post install so - # that we can update paths based on the final location of the Pods installation. - ReactNativeCoreUtils.process_vfs_overlay() - - # Configure xcconfig for prebuilt usage (VFS overlay, header paths, cleanup redundant paths) + # The Xcode-26 SWIFT_ENABLE_EXPLICIT_MODULES=NO workaround (#53457) is removed: + # the modular ReactNativeHeaders layout + React-Core-prebuilt module-map + # activation + header-less facades let the React module precompile cleanly with + # explicit modules ON (verified cold-DD green), so the override is unnecessary. + + # Make the prebuilt React.xcframework headers resolvable from aggregate (main app) + # and third-party pod targets that don't go through add_rncore_dependency. The headers + # are served directly from the xcframework's headers-spec layout — no clang VFS overlay. ReactNativeCoreUtils.configure_aggregate_xcconfig(installer) end diff --git a/packages/react-native/scripts/replace-rncore-version.js b/packages/react-native/scripts/replace-rncore-version.js index 2684d3250b5d..b3d960890dfe 100644 --- a/packages/react-native/scripts/replace-rncore-version.js +++ b/packages/react-native/scripts/replace-rncore-version.js @@ -99,17 +99,19 @@ function replaceRNCoreConfiguration( throw new Error(`tar extraction failed with exit code ${result.status}`); } - // Verify extraction produced the expected xcframework structure + // Verify extraction produced the expected xcframework structure. The + // module map now lives per-slice inside React.framework, so check the + // xcframework's Info.plist instead of a root Modules/module.modulemap. const xcfwPath = path.join(tmpExtractDir, 'React.xcframework'); - const modulemapPath = path.join(xcfwPath, 'Modules', 'module.modulemap'); - if (!fs.existsSync(modulemapPath)) { + const infoPlistPath = path.join(xcfwPath, 'Info.plist'); + if (!fs.existsSync(infoPlistPath)) { throw new Error( - `Extraction verification failed: ${modulemapPath} not found`, + `Extraction verification failed: ${infoPlistPath} not found`, ); } - // Delete all directories in finalLocation - not files, since we want to - // keep the React-VFS.yaml file + // Delete only directories in finalLocation (e.g. the React.xcframework) - + // not files, so any sibling files written during pod install are preserved. const dirs = fs .readdirSync(finalLocation, {withFileTypes: true}) .filter(dirent => dirent.isDirectory()); @@ -144,6 +146,51 @@ function replaceRNCoreConfiguration( } } } + + // The podspec prepare_command flattens ReactNativeHeaders' headers into a + // top-level Headers/ dir, but it does not re-run on a config swap. Mirror + // it here: re-flatten the headers (identical across slices) and drop the + // now-redundant xcframework so $(PODS_ROOT)/React-Core-prebuilt/Headers + // keeps resolving , , etc. + // + // 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}); From 77b75122ef9ed172012d7c6acb335f966d4ede79 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 15 Jul 2026 05:08:52 -0700 Subject: [PATCH 068/834] refactor(iOS): include resources like bundles into the precompiled XCFrameworks (#57305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: **Depends on https://github.com/react/react-native/issues/57285** In source builds, `React-Core` ships non-header resources — its privacy manifest (`PrivacyInfo.xcprivacy`) and its localized strings (RCTI18nStrings) — via the podspec's resource_bundles. In the prebuilt path those source pods aren't installed (CocoaPods facades) or aren't present at all (SwiftPM), so these resources were silently dropped: prebuilt/SwiftPM apps shipped no React Native privacy manifest, and localized strings were unavailable. This embeds them in React.framework at prebuild time so they ship uniformly across CocoaPods-prebuilt and SwiftPM, with source builds unchanged: - **Privacy manifest** — the PrivacyInfo.xcprivacy of the pods baked into React.framework are merged into one manifest at the framework root. React.framework is a dynamic framework, so Xcode's privacy-report aggregation picks it up automatically (no runtime involvement). - **RCTI18nStrings** — rebuilt as RCTI18nStrings.bundle inside React.framework. RCTLocalizedString.mm now resolves the bundle from its own framework first (bundleForClass:), falling back to the app's main bundle for static source builds. It also fixes a latent bug in `React-Core.podspec`: a later resource_bundles = was overwriting the earlier resource_bundle =, so source builds had stopped shipping RCTI18nStrings. Both bundles are now declared together. With the artifact owning these resources, the prebuilt RNCore facade no longer carries them. ## Changelog: [IOS][FIXED] - Ship React-Core's privacy manifest and localized strings (RCTI18nStrings) inside the prebuilt React.xcframework, so CocoaPods-prebuilt and SwiftPM apps include them Pull Request resolved: https://github.com/react/react-native/pull/57305 Test Plan: - ✅ yarn jest packages/react-native/scripts/ios-prebuild — unit + integration tests for the merge/discovery/bundle-build logic (red/green). - ✅ Built React.xcframework from this branch and confirmed each slice's React.framework carries the merged PrivacyInfo.xcprivacy and RCTI18nStrings.bundle (37 locales). - ✅ Cold-built rn-tester in prebuilt mode and verified the app bundle contains Frameworks/React.framework/{PrivacyInfo.xcprivacy, RCTI18nStrings.bundle}. - ✅ Confirmed React-Core.podspec now reports both resource bundles (RCTI18nStrings, React-Core_privacy) for source builds. Reviewed By: fabriziocucci Differential Revision: D111448862 Pulled By: cipolleschi fbshipit-source-id: a95360f51cc1131510ef03f7e48c689575d1c22d --- packages/react-native/React-Core.podspec | 11 +- .../React/I18n/RCTLocalizedString.mm | 40 +- .../scripts/cocoapods/rncore_facades.rb | 79 +--- .../__tests__/framework-resources-test.js | 371 ++++++++++++++++++ .../ios-prebuild/framework-resources.js | 306 +++++++++++++++ .../scripts/ios-prebuild/headers-compose.js | 48 ++- 6 files changed, 784 insertions(+), 71 deletions(-) create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/framework-resources.js diff --git a/packages/react-native/React-Core.podspec b/packages/react-native/React-Core.podspec index 63eb78aeb59a..88115a1790ec 100644 --- a/packages/react-native/React-Core.podspec +++ b/packages/react-native/React-Core.podspec @@ -51,7 +51,6 @@ Pod::Spec.new do |s| s.author = "Meta Platforms, Inc. and its affiliates" s.platforms = min_supported_versions s.source = source - s.resource_bundle = { "RCTI18nStrings" => ["React/I18n/strings/*.lproj"]} s.compiler_flags = js_engine_flags() s.header_dir = "React" s.weak_framework = "JavaScriptCore" @@ -122,7 +121,15 @@ Pod::Spec.new do |s| s.dependency "React-hermes" end - s.resource_bundles = {'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy'} + # Both bundles in one declaration: a second `resource_bundle(s) =` would replace + # (not merge) the first. RCTI18nStrings holds React-Core's localized strings + # (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest. + # (Prebuilt/SwiftPM get both from inside React.xcframework instead — see + # scripts/ios-prebuild/framework-resources.js — but source builds ship them here.) + s.resource_bundles = { + 'RCTI18nStrings' => ['React/I18n/strings/*.lproj'], + 'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy', + } add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern') diff --git a/packages/react-native/React/I18n/RCTLocalizedString.mm b/packages/react-native/React/I18n/RCTLocalizedString.mm index 6a09613b77a0..df87ea02ea5a 100644 --- a/packages/react-native/React/I18n/RCTLocalizedString.mm +++ b/packages/react-native/React/I18n/RCTLocalizedString.mm @@ -7,8 +7,45 @@ #import "RCTLocalizedString.h" +#import + #if !defined(WITH_FBI18N) || !(WITH_FBI18N) +// Anchors resource lookups to the bundle that contains this code: React.framework +// when React Native is consumed prebuilt / via SwiftPM, or the app's main bundle +// for static source builds. +@interface RCTI18nStringsAnchor : NSObject +@end +@implementation RCTI18nStringsAnchor +@end + +// Resolves RCTI18nStrings.bundle wherever it ships: the code's own bundle first +// (prebuilt/SwiftPM embed it inside React.framework), then the app's main bundle +// (source builds copy it there via the podspec resource_bundles). Returns nil +// when absent, so the caller falls back to the untranslated default value. +static NSBundle *RCTI18nStringsBundle(void) +{ + NSBundle *codeBundle = [NSBundle bundleForClass:[RCTI18nStringsAnchor class]]; + NSURL *url = [codeBundle URLForResource:@"RCTI18nStrings" withExtension:@"bundle"]; + if (url != nil) { + return [NSBundle bundleWithURL:url]; + } + NSString *mainPath = [[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" ofType:@"bundle"]; + if (mainPath != nil) { + return [NSBundle bundleWithPath:mainPath]; + } +#if RCT_DEV + // Missing resources are otherwise silent (every lookup falls back to the + // untranslated default and the privacy manifest quietly drops out of the + // app's aggregated privacy report). Called once — the caller caches. + RCTLogWarn( + @"RCTI18nStrings.bundle not found in React.framework or the app bundle. Localized strings will use their " + @"untranslated defaults, and React's PrivacyInfo.xcprivacy may be missing from the app's privacy report. " + @"When consuming the prebuilt React.framework, verify it is embedded into the app with its resources intact."); +#endif + return nil; +} + extern "C" { static NSString *FBTStringByConvertingIntegerToBase64(uint64_t number) @@ -33,8 +70,7 @@ NSString *RCTLocalizedStringFromKey(uint64_t key, NSString *defaultValue) { - static NSBundle *bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" - ofType:@"bundle"]]; + static NSBundle *bundle = RCTI18nStringsBundle(); if (bundle == nil) { return defaultValue; } else { diff --git a/packages/react-native/scripts/cocoapods/rncore_facades.rb b/packages/react-native/scripts/cocoapods/rncore_facades.rb index fa3b935aed48..2016cff9c1e0 100644 --- a/packages/react-native/scripts/cocoapods/rncore_facades.rb +++ b/packages/react-native/scripts/cocoapods/rncore_facades.rb @@ -103,18 +103,19 @@ def self.facade?(name) # call once per `pod install`. # # `react_native_path` locates the real podspecs we mirror. version + subspecs + - # default_subspecs + resources are all DERIVED from the real spec so the facade - # stays graph- and resource-equivalent to the source pod. A facaded pod whose - # real podspec can't be read is a hard error (see load_real_spec) — silently - # shipping an empty facade would hide exactly the drift this guards against. + # default_subspecs are DERIVED from the real spec so the facade stays + # graph-equivalent to the source pod (resources are NOT carried — they live in + # the prebuilt artifact; see the note in the loop). A facaded pod whose real + # podspec can't be read is a hard error (see load_real_spec) — silently shipping + # an empty facade would hide exactly the drift this guards against. def self.generate(react_native_path, install_root, version, ios_version) @@install_root = install_root.to_s abs_base = File.join(@@install_root, FACADE_RELDIR) FileUtils.mkdir_p(abs_base) FACADE_PODS.each do |name, podspec_rel_path| podspec_path = File.join(react_native_path.to_s, podspec_rel_path) - real = load_real_spec(podspec_path, name) podspec_dir = File.dirname(podspec_path) + real = load_real_spec(podspec_path, name) dir = File.join(abs_base, name) FileUtils.mkdir_p(dir) @@ -133,17 +134,11 @@ def self.generate(react_native_path, install_root, version, ios_version) "dependencies" => { "React-Core-prebuilt" => [] }, } - # Preserve non-code RESOURCES (privacy manifest, i18n bundles, ...). They - # don't shadow headers, and React-Core-prebuilt doesn't vend them, so the - # facade must carry them or prebuilt installs lose them. The matched - # files are COPIED into the facade dir (like the re-exposed headers): - # CocoaPods file accessors only match globs against files under the pod - # root, so a `..`-escaping glob back into the source tree would silently - # match nothing and the bundles would ship empty. - resource_bundles = derive_resource_bundles(real, podspec_dir, dir) - spec["resource_bundles"] = resource_bundles unless resource_bundles.empty? - resources = derive_resources(real, podspec_dir, dir) - spec["resources"] = resources unless resources.empty? + # NOTE: the facade carries NO resources. The pods' non-code resources + # (e.g. the privacy manifest) are embedded directly in the prebuilt + # React.xcframework by the ios-prebuild compose (see ios-prebuild/framework-resources.js), + # so they reach both CocoaPods-prebuilt and SwiftPM from the artifact — + # the facade only needs to declare the React-Core-prebuilt dependency. # Re-vend the narrow set of angle-only framework headers that community # modules quote-import (see FACADE_REEXPOSED_HEADERS). The header is @@ -190,8 +185,8 @@ def self.facade_path(name) # Loads the real podspec so we can mirror its structure. A facaded pod MUST have # a readable real podspec — if it's missing or unparseable we raise rather than - # ship an empty facade, since that would silently drop subspecs/resources (the - # very drift this mechanism exists to prevent). + # ship an empty facade, since that would silently drop subspecs (the very drift + # this mechanism exists to prevent). def self.load_real_spec(path, name) unless File.exist?(path) raise "[RNCoreFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \ @@ -212,22 +207,6 @@ def self.derive_subspecs(real) end private_class_method :derive_subspecs - # Effective resource_bundles of the real spec (e.g. React-Core_privacy), - # copied into the facade dir under resources//. Unions the - # `resource_bundle` (singular) and `resource_bundles` (plural) DSL forms. - def self.derive_resource_bundles(real, podspec_dir, facade_dir) - out = {} - [real.attributes_hash["resource_bundle"], real.attributes_hash["resource_bundles"]].each do |rb| - next unless rb.is_a?(Hash) - rb.each do |bundle, globs| - copied = copy_resources(Array(globs), podspec_dir, facade_dir, File.join("resources", bundle)) - out[bundle] = copied unless copied.empty? - end - end - out - end - private_class_method :derive_resource_bundles - # Copy the re-exposed header(s) into the facade dir (flat) and return the # facade-relative source_files entries. `globs` are resolved against the real # podspec dir. A glob that matches nothing is a hard error: the whole point is @@ -250,36 +229,4 @@ def self.copy_reexposed_headers(globs, podspec_dir, facade_dir, name) copied.uniq end private_class_method :copy_reexposed_headers - - # Loose `resources` of the real spec, copied into the facade dir. - def self.derive_resources(real, podspec_dir, facade_dir) - copy_resources(Array(real.attributes_hash["resources"]), podspec_dir, facade_dir, "resources") - end - private_class_method :derive_resources - - # Copies everything the resource globs match (files or whole directories, - # e.g. *.lproj bundles) into `//` and returns - # facade-relative paths for the copies. Globs resolve against the real - # podspec dir. A glob that matches nothing is a hard error: the real spec - # declared a resource, so silently dropping it would make the facade drift. - # rm_rf-before-cp_r keeps the snapshot fresh across repeated `pod install`s. - def self.copy_resources(globs, podspec_dir, facade_dir, subdir) - copied = [] - globs.each do |g| - matches = Dir.glob(File.expand_path(g, podspec_dir)) - if matches.empty? - raise "[RNCoreFacades] Resource glob '#{g}' matched no files under #{podspec_dir}. " \ - "Update the facade resource derivation if the podspec layout changed." - end - dest_dir = File.join(facade_dir, subdir) - FileUtils.mkdir_p(dest_dir) - matches.each do |src| - FileUtils.rm_rf(File.join(dest_dir, File.basename(src))) - FileUtils.cp_r(src, dest_dir) - copied << File.join(subdir, File.basename(src)) - end - end - copied.uniq - end - private_class_method :copy_resources end diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js new file mode 100644 index 000000000000..05878f8740c9 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js @@ -0,0 +1,371 @@ +/** + * 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 { + buildI18nStringsBundle, + buildReactPrivacyManifest, + collectLprojDirs, + collectReactPrivacyManifestPaths, + i18nBundleInfoPlist, + mergePrivacyManifests, + readPrivacyManifest, + serializePrivacyManifest, +} = require('../framework-resources'); +const {emitReactFrameworkHeaders} = require('../headers-compose'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// react-native package root (…/scripts/ios-prebuild/__tests__ -> …) +const RN_PATH = path.resolve(__dirname, '..', '..', '..'); + +// Apple privacy manifest fixtures mirroring the real ones shipped by the pods +// baked into React.framework. +const reactCore = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + ], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, +}; + +const cxxreact = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + ], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, +}; + +describe('mergePrivacyManifests', () => { + it('returns a valid empty manifest for no inputs', () => { + expect(mergePrivacyManifests([])).toEqual({ + NSPrivacyAccessedAPITypes: [], + NSPrivacyCollectedDataTypes: [], + NSPrivacyTracking: false, + }); + }); + + it('passes a single manifest through unchanged (by value)', () => { + expect(mergePrivacyManifests([reactCore])).toEqual(reactCore); + }); + + it('unions accessed-API categories, deduping reasons per category', () => { + const merged = mergePrivacyManifests([reactCore, cxxreact]); + const byType = Object.fromEntries( + merged.NSPrivacyAccessedAPITypes.map(e => [ + e.NSPrivacyAccessedAPIType, + e.NSPrivacyAccessedAPITypeReasons, + ]), + ); + // FileTimestamp appears in both -> single entry, reason deduped. + expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(2); + expect(byType.NSPrivacyAccessedAPICategoryFileTimestamp).toEqual([ + 'C617.1', + ]); + expect(byType.NSPrivacyAccessedAPICategoryUserDefaults).toEqual(['CA92.1']); + }); + + it('unions reasons across manifests for the same category', () => { + const a = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + ], + }; + const b = { + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['1C8F.1'], + }, + ], + }; + const merged = mergePrivacyManifests([a, b]); + expect(merged.NSPrivacyAccessedAPITypes).toHaveLength(1); + expect( + merged.NSPrivacyAccessedAPITypes[0].NSPrivacyAccessedAPITypeReasons.sort(), + ).toEqual(['1C8F.1', 'CA92.1']); + }); + + it('ORs NSPrivacyTracking and unions tracking domains', () => { + const a = {NSPrivacyTracking: false, NSPrivacyTrackingDomains: ['a.com']}; + const b = { + NSPrivacyTracking: true, + NSPrivacyTrackingDomains: ['a.com', 'b.com'], + }; + const merged = mergePrivacyManifests([a, b]); + expect(merged.NSPrivacyTracking).toBe(true); + expect(merged.NSPrivacyTrackingDomains.sort()).toEqual(['a.com', 'b.com']); + }); + + it('unions collected data types, deduping structurally-equal entries', () => { + const entry = { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData', + NSPrivacyCollectedDataTypeLinked: false, + }; + const merged = mergePrivacyManifests([ + {NSPrivacyCollectedDataTypes: [entry]}, + {NSPrivacyCollectedDataTypes: [{...entry}]}, + ]); + expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1); + }); + + it('dedups collected data types regardless of source key order', () => { + // Same dict, different key order (order comes from each plist) must dedup. + const merged = mergePrivacyManifests([ + { + NSPrivacyCollectedDataTypes: [ + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData', + NSPrivacyCollectedDataTypeLinked: false, + }, + ], + }, + { + NSPrivacyCollectedDataTypes: [ + { + NSPrivacyCollectedDataTypeLinked: false, + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData', + }, + ], + }, + ]); + expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1); + }); + + it('does not fabricate a reasons key when the source omitted it', () => { + const merged = mergePrivacyManifests([ + { + NSPrivacyAccessedAPITypes: [ + {NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryOther'}, + ], + }, + ]); + const entry = merged.NSPrivacyAccessedAPITypes[0]; + expect(entry.NSPrivacyAccessedAPIType).toBe( + 'NSPrivacyAccessedAPICategoryOther', + ); + expect('NSPrivacyAccessedAPITypeReasons' in entry).toBe(false); + }); +}); + +describe('serialize/read privacy-manifest round-trip', () => { + it('serialize -> readPrivacyManifest yields the same object (guards the plist.build byte shape)', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-rt-')); + const file = path.join(tmp, 'PrivacyInfo.xcprivacy'); + try { + fs.writeFileSync(file, serializePrivacyManifest(reactCore)); + expect(readPrivacyManifest(file)).toEqual(reactCore); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } + }); +}); + +describe('collectReactPrivacyManifestPaths drift gate', () => { + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-')); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + function writeManifest(rel) { + const file = path.join(tmp, rel); + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, serializePrivacyManifest(reactCore)); + return file; + } + + it('throws when a manifest under the privacy roots is not allowlisted (a new pod must be a conscious decision)', () => { + writeManifest('React/Resources/PrivacyInfo.xcprivacy'); + writeManifest('Libraries/SomeNewPod/PrivacyInfo.xcprivacy'); + expect(() => collectReactPrivacyManifestPaths(tmp)).toThrow(/SomeNewPod/); + expect(() => collectReactPrivacyManifestPaths(tmp)).toThrow( + /REACT_PRIVACY_MANIFESTS/, + ); + }); + + it('returns the found subset of allowlisted manifests (partial trees stay valid)', () => { + const file = writeManifest('React/Resources/PrivacyInfo.xcprivacy'); + expect(collectReactPrivacyManifestPaths(tmp)).toEqual([file]); + }); + + it('returns [] for a tree with no manifests', () => { + expect(collectReactPrivacyManifestPaths(tmp)).toEqual([]); + }); +}); + +describe('buildReactPrivacyManifest (against the real source tree)', () => { + it('discovers React-core PrivacyInfo.xcprivacy files (not third-party deps)', () => { + const paths = collectReactPrivacyManifestPaths(RN_PATH); + expect(paths.length).toBeGreaterThan(0); + // third-party-podspecs manifests belong to ReactNativeDependencies, not React.framework + expect(paths.some(p => p.includes('third-party-podspecs'))).toBe(false); + // React-Core's manifest is the canonical one that must be present + expect( + paths.some(p => p.endsWith('React/Resources/PrivacyInfo.xcprivacy')), + ).toBe(true); + }); + + it('merges them into one manifest covering the known React-core API usages', () => { + const merged = buildReactPrivacyManifest(RN_PATH); + expect(merged).not.toBeNull(); + const categories = (merged?.NSPrivacyAccessedAPITypes ?? []).map( + e => e.NSPrivacyAccessedAPIType, + ); + // FileTimestamp + UserDefaults are declared by React-Core; both must survive the merge. + expect(categories).toContain('NSPrivacyAccessedAPICategoryFileTimestamp'); + expect(categories).toContain('NSPrivacyAccessedAPICategoryUserDefaults'); + // No category should be duplicated after merging. + expect(new Set(categories).size).toBe(categories.length); + }); +}); + +describe('i18nBundleInfoPlist', () => { + it('is a valid resource-bundle Info.plist dict', () => { + const info = i18nBundleInfoPlist(); + expect(info.CFBundlePackageType).toBe('BNDL'); + expect(info.CFBundleName).toBe('RCTI18nStrings'); + expect(typeof info.CFBundleIdentifier).toBe('string'); + expect(info.CFBundleIdentifier.length).toBeGreaterThan(0); + expect(typeof info.CFBundleDevelopmentRegion).toBe('string'); + // Versioned so Apple validation tooling doesn't warn on a version-less bundle. + expect(info.CFBundleShortVersionString).toBeDefined(); + expect(info.CFBundleVersion).toBeDefined(); + }); +}); + +describe('collectLprojDirs (against the real source tree)', () => { + it('finds the React i18n .lproj locale dirs', () => { + const dirs = collectLprojDirs(RN_PATH); + expect(dirs.length).toBeGreaterThan(0); + expect(dirs.every(d => d.endsWith('.lproj'))).toBe(true); + // English is the canonical base locale and must be present. + expect(dirs.some(d => path.basename(d) === 'en.lproj')).toBe(true); + }); +}); + +describe('buildI18nStringsBundle', () => { + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-bundle-')); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('builds RCTI18nStrings.bundle with the .lproj dirs and an Info.plist', () => { + const out = path.join(tmp, 'RCTI18nStrings.bundle'); + const count = buildI18nStringsBundle(RN_PATH, out); + + expect(count).toBeGreaterThan(0); + expect(fs.existsSync(path.join(out, 'Info.plist'))).toBe(true); + expect(fs.existsSync(path.join(out, 'en.lproj'))).toBe(true); + // the copied locale carries its actual strings file(s) + expect(fs.readdirSync(path.join(out, 'en.lproj')).length).toBeGreaterThan( + 0, + ); + // count matches the number of .lproj dirs copied + const copied = fs.readdirSync(out).filter(e => e.endsWith('.lproj')); + expect(copied.length).toBe(count); + }); + + it('returns 0 and writes nothing when there are no .lproj dirs', () => { + const emptyRn = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-rn-')); + const out = path.join(tmp, 'RCTI18nStrings.bundle'); + const count = buildI18nStringsBundle(emptyRn, out); + expect(count).toBe(0); + expect(fs.existsSync(out)).toBe(false); + fs.rmSync(emptyRn, {recursive: true, force: true}); + }); +}); + +describe('emitReactFrameworkHeaders resource landing (integration)', () => { + let tmp; + let rnRoot; + let xcfw; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'emit-res-')); + + // Minimal fake source tree: one privacy manifest + one locale. + rnRoot = path.join(tmp, 'rn'); + const privacyDir = path.join(rnRoot, 'React', 'Resources'); + fs.mkdirSync(privacyDir, {recursive: true}); + fs.writeFileSync( + path.join(privacyDir, 'PrivacyInfo.xcprivacy'), + serializePrivacyManifest(reactCore), + ); + const lproj = path.join(rnRoot, 'React', 'I18n', 'strings', 'en.lproj'); + fs.mkdirSync(lproj, {recursive: true}); + fs.writeFileSync( + path.join(lproj, 'Localizable.strings'), + '"key" = "value";\n', + ); + + // Two-slice xcframework, each carrying an empty React.framework. + xcfw = path.join(tmp, 'React.xcframework'); + for (const slice of ['ios-arm64', 'ios-arm64_x86_64-simulator']) { + fs.mkdirSync(path.join(xcfw, slice, 'React.framework'), { + recursive: true, + }); + } + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('lands PrivacyInfo.xcprivacy and RCTI18nStrings.bundle into EVERY slice', () => { + // Empty header plan — this test targets the non-header resources only. + emitReactFrameworkHeaders( + xcfw, + { + react: [], + umbrella: [], + privateReactHeaders: {modular: [], textual: []}, + }, + rnRoot, + ); + + for (const slice of ['ios-arm64', 'ios-arm64_x86_64-simulator']) { + const fwk = path.join(xcfw, slice, 'React.framework'); + expect(fs.existsSync(path.join(fwk, 'PrivacyInfo.xcprivacy'))).toBe(true); + const bundle = path.join(fwk, 'RCTI18nStrings.bundle'); + expect(fs.existsSync(path.join(bundle, 'Info.plist'))).toBe(true); + expect(fs.existsSync(path.join(bundle, 'en.lproj'))).toBe(true); + // The composed module map is present too (proves the slice was rewritten). + expect(fs.existsSync(path.join(fwk, 'Modules', 'module.modulemap'))).toBe( + true, + ); + } + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/framework-resources.js b/packages/react-native/scripts/ios-prebuild/framework-resources.js new file mode 100644 index 000000000000..41e70bc1389b --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/framework-resources.js @@ -0,0 +1,306 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Non-header resources that the prebuild embeds into React.framework so the + * prebuilt artifact is self-describing for both CocoaPods-prebuilt and SwiftPM. + * + * In source builds each pod ships these via its podspec `resource_bundles`. In + * the prebuilt path the source pods aren't installed (CocoaPods facades) / not + * present (SwiftPM), so we reproduce them from the source tree at compose time: + * + * - Privacy manifest: the pods baked into React.framework each ship a + * PrivacyInfo.xcprivacy; we merge them into ONE manifest at the framework + * root, where Xcode's privacy-report aggregation picks it up (no runtime). + * - RCTI18nStrings: React-Core's localized strings (React/I18n/strings/*.lproj) + * rebuilt as RCTI18nStrings.bundle inside the framework, where the + * framework-aware RCTLocalizedString loader resolves them via bundleForClass:. + */ + +const fs = require('fs'); +const path = require('path'); +const plist = require('plist'); + +// Source roots whose pods compile into React.framework. third-party-podspecs is +// intentionally excluded — those (boost/glog/RCT-Folly) live in +// ReactNativeDependencies.xcframework and are aggregated there. +const REACT_PRIVACY_ROOTS = ['React', 'ReactCommon', 'Libraries', 'ReactApple']; + +// The privacy manifests of the pods that compile INTO React.framework — +// an EXPLICIT allowlist, not a glob: a PrivacyInfo.xcprivacy that appears +// under REACT_PRIVACY_ROOTS without being listed here fails the prebuild, +// forcing a conscious decision. A pod whose sources compile into +// React.framework belongs on this list; a pod that ships as its OWN framework +// must NOT have its manifest folded into React.framework's aggregate +// (over-declaration in the app's privacy report). +const REACT_PRIVACY_MANIFESTS = [ + path.join('React', 'Resources', 'PrivacyInfo.xcprivacy'), + path.join('ReactCommon', 'cxxreact', 'PrivacyInfo.xcprivacy'), + path.join('ReactCommon', 'react', 'timing', 'PrivacyInfo.xcprivacy'), +]; + +// Where React-Core's localized strings live, relative to the package root. +const STRINGS_REL = path.join('React', 'I18n', 'strings'); + +/*:: +type AccessedAPIType = { + NSPrivacyAccessedAPIType: string, + NSPrivacyAccessedAPITypeReasons?: Array, + ... +}; +type PrivacyManifest = { + NSPrivacyAccessedAPITypes?: Array, + NSPrivacyCollectedDataTypes?: Array, + NSPrivacyTracking?: boolean, + NSPrivacyTrackingDomains?: Array, + ... +}; +*/ + +// --------------------------------------------------------------------------- +// Privacy manifest +// --------------------------------------------------------------------------- + +// Recursively sorts object keys so structurally-equal values serialize +// identically regardless of source key order (arrays keep their order). +function canonicalize(value /*: unknown */) /*: unknown */ { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value != null && typeof value === 'object') { + const out /*: {[string]: unknown} */ = {}; + for (const k of Object.keys(value).sort()) { + out[k] = canonicalize(value[k]); + } + return out; + } + return value; +} + +/** + * Merges Apple privacy manifests into one. Pure; operates on parsed plist + * objects. Semantics: + * - NSPrivacyAccessedAPITypes: keyed by category; reasons unioned (deduped). + * - NSPrivacyCollectedDataTypes: unioned, deduped structurally. + * - NSPrivacyTrackingDomains: unioned (deduped); omitted when empty. + * - NSPrivacyTracking: logical OR. + */ +function mergePrivacyManifests( + manifests /*: Array */, +) /*: PrivacyManifest */ { + const reasonsByType /*: Map> */ = new Map(); + // Categories where at least one source actually declared the reasons key, so + // a source that omitted it doesn't get a fabricated empty-array key on the way + // out (keeps "a single manifest passes through unchanged" honest). + const typeHadReasonsKey /*: Set */ = new Set(); + const typeOrder /*: Array */ = []; + const trackingDomains /*: Set */ = new Set(); + const collected /*: Array */ = []; + const collectedSeen /*: Set */ = new Set(); + let tracking = false; + + for (const manifest of manifests) { + if (manifest == null) { + continue; + } + for (const entry of manifest.NSPrivacyAccessedAPITypes ?? []) { + const category = entry.NSPrivacyAccessedAPIType; + if (!reasonsByType.has(category)) { + reasonsByType.set(category, []); + typeOrder.push(category); + } + const entryReasons = entry.NSPrivacyAccessedAPITypeReasons; + if (entryReasons != null) { + typeHadReasonsKey.add(category); + const reasons = reasonsByType.get(category); + if (reasons != null) { + for (const reason of entryReasons) { + if (!reasons.includes(reason)) { + reasons.push(reason); + } + } + } + } + } + for (const domain of manifest.NSPrivacyTrackingDomains ?? []) { + trackingDomains.add(domain); + } + for (const dataType of manifest.NSPrivacyCollectedDataTypes ?? []) { + // Canonicalize (sort object keys recursively) before keying so two pods + // declaring the same data-type dict in different key order still dedup. + // The `?? ''` is unreachable at runtime (canonicalize of a plist dict + // never yields undefined) — it exists purely because Flow types + // JSON.stringify as `string | void`. + const key = JSON.stringify(canonicalize(dataType)) ?? ''; + if (!collectedSeen.has(key)) { + collectedSeen.add(key); + collected.push(dataType); + } + } + if (manifest.NSPrivacyTracking === true) { + tracking = true; + } + } + + const merged /*: PrivacyManifest */ = { + NSPrivacyAccessedAPITypes: typeOrder.map(category => { + const entry /*: AccessedAPIType */ = { + NSPrivacyAccessedAPIType: category, + }; + if (typeHadReasonsKey.has(category)) { + entry.NSPrivacyAccessedAPITypeReasons = + reasonsByType.get(category) ?? []; + } + return entry; + }), + NSPrivacyCollectedDataTypes: collected, + NSPrivacyTracking: tracking, + }; + if (trackingDomains.size > 0) { + merged.NSPrivacyTrackingDomains = Array.from(trackingDomains); + } + return merged; +} + +/** Parses a single `PrivacyInfo.xcprivacy` (plist) file into an object. */ +function readPrivacyManifest(filePath /*: string */) /*: PrivacyManifest */ { + // $FlowFixMe[incompatible-return] plist.parse returns a loose PlistValue. + return plist.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * Finds every `PrivacyInfo.xcprivacy` under the React-core source roots of + * `reactNativePath` (excluding third-party deps). Sorted for deterministic output. + */ +function collectReactPrivacyManifestPaths( + reactNativePath /*: string */, +) /*: Array */ { + const found /*: Array */ = []; + for (const root of REACT_PRIVACY_ROOTS) { + const dir = path.join(reactNativePath, root); + if (!fs.existsSync(dir)) { + continue; + } + for (const rel of fs.readdirSync(dir, {recursive: true})) { + if (path.basename(String(rel)) === 'PrivacyInfo.xcprivacy') { + found.push(path.join(root, String(rel))); + } + } + } + const unlisted = found.filter(rel => !REACT_PRIVACY_MANIFESTS.includes(rel)); + if (unlisted.length > 0) { + throw new Error( + 'React.framework privacy-manifest drift: found PrivacyInfo.xcprivacy ' + + 'file(s) under the React privacy roots that are not allowlisted:\n' + + unlisted.map(rel => ` ${rel}`).join('\n') + + '\nIf the owning pod compiles into React.framework, add the path to ' + + 'REACT_PRIVACY_MANIFESTS in framework-resources.js. If it ships as ' + + 'its own framework, its manifest must NOT be folded into ' + + "React.framework's aggregate — relocate it out of the privacy roots " + + 'or exclude it explicitly.', + ); + } + // A listed-but-absent manifest is legal (partial fixture trees; upstream + // deletions surface as under-declaration exactly like source builds would). + // A MOVED manifest cannot slip through: its new location is unlisted. + return found.map(rel => path.join(reactNativePath, rel)).sort(); +} + +/** + * Builds the aggregated React.framework privacy manifest from the source pods, + * or null when there are none. + */ +function buildReactPrivacyManifest( + reactNativePath /*: string */, +) /*: ?PrivacyManifest */ { + const paths = collectReactPrivacyManifestPaths(reactNativePath); + if (paths.length === 0) { + return null; + } + return mergePrivacyManifests(paths.map(readPrivacyManifest)); +} + +/** Serializes a manifest object back to a plist XML string. */ +function serializePrivacyManifest( + manifest /*: PrivacyManifest */, +) /*: string */ { + return plist.build(manifest); +} + +// --------------------------------------------------------------------------- +// RCTI18nStrings bundle +// --------------------------------------------------------------------------- + +/** The Info.plist contents that make the copied .lproj dirs load as an NSBundle. */ +function i18nBundleInfoPlist() /*: {[string]: string} */ { + return { + CFBundleDevelopmentRegion: 'en', + CFBundleIdentifier: 'org.reactnative.RCTI18nStrings', + CFBundleInfoDictionaryVersion: '6.0', + CFBundleName: 'RCTI18nStrings', + CFBundlePackageType: 'BNDL', + // Present so Apple validation tooling doesn't warn on a version-less bundle. + CFBundleShortVersionString: '1.0', + CFBundleVersion: '1', + }; +} + +/** Absolute paths of the React i18n `.lproj` locale dirs, sorted. */ +function collectLprojDirs(reactNativePath /*: string */) /*: Array */ { + const stringsDir = path.join(reactNativePath, STRINGS_REL); + if (!fs.existsSync(stringsDir)) { + return []; + } + return fs + .readdirSync(stringsDir, {withFileTypes: true}) + .filter(e => e.isDirectory() && String(e.name).endsWith('.lproj')) + .map(e => path.join(stringsDir, String(e.name))) + .sort(); +} + +/** + * Builds `RCTI18nStrings.bundle` at `outBundlePath` from the React i18n .lproj + * dirs + an Info.plist. Returns the number of locales copied (0 when there are + * none, in which case nothing is written). + */ +function buildI18nStringsBundle( + reactNativePath /*: string */, + outBundlePath /*: string */, +) /*: number */ { + const lprojDirs = collectLprojDirs(reactNativePath); + if (lprojDirs.length === 0) { + return 0; + } + fs.rmSync(outBundlePath, {recursive: true, force: true}); + fs.mkdirSync(outBundlePath, {recursive: true}); + for (const lproj of lprojDirs) { + fs.cpSync(lproj, path.join(outBundlePath, path.basename(lproj)), { + recursive: true, + }); + } + fs.writeFileSync( + path.join(outBundlePath, 'Info.plist'), + plist.build(i18nBundleInfoPlist()), + ); + return lprojDirs.length; +} + +module.exports = { + // privacy manifest + mergePrivacyManifests, + readPrivacyManifest, + collectReactPrivacyManifestPaths, + buildReactPrivacyManifest, + serializePrivacyManifest, + // RCTI18nStrings bundle + i18nBundleInfoPlist, + collectLprojDirs, + buildI18nStringsBundle, +}; diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js index 23b4af4441e7..a67e74216e29 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-compose.js +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -17,6 +17,12 @@ * byte-identical output either way. */ +const { + buildI18nStringsBundle, + buildReactPrivacyManifest, + collectLprojDirs, + serializePrivacyManifest, +} = require('./framework-resources'); const {computeInventory} = require('./headers-inventory'); const { DEPS_NAMESPACES, @@ -138,6 +144,28 @@ function emitReactFrameworkHeaders( const slices = fs .readdirSync(xcfwPath) .filter(d => fs.existsSync(path.join(xcfwPath, d, 'React.framework'))); + + // Aggregate the privacy manifests of the pods baked into React.framework into + // one root-level PrivacyInfo.xcprivacy, so the prebuilt artifact carries them + // for both CocoaPods-prebuilt and SwiftPM (source builds get them from the + // podspecs instead). Built once, embedded per slice. + const privacyManifest = buildReactPrivacyManifest(rnRoot); + + // Build RCTI18nStrings.bundle ONCE into a temp stage, then clone it into each + // slice below — mirrors the privacy manifest (computed once, embedded per + // slice) instead of rebuilding the bundle inside the slice loop. The stage is + // only created when there are locales to bundle. + let i18nStage = null; + let i18nBundleStage = null; + let i18nLocales = 0; + if (collectLprojDirs(rnRoot).length > 0) { + i18nStage = fs.mkdtempSync( + path.join(path.dirname(xcfwPath), '.i18n-stage-'), + ); + i18nBundleStage = path.join(i18nStage, 'RCTI18nStrings.bundle'); + i18nLocales = buildI18nStringsBundle(rnRoot, i18nBundleStage); + } + for (const slice of slices) { const fwk = path.join(xcfwPath, slice, 'React.framework'); fs.rmSync(path.join(fwk, 'Headers'), {recursive: true, force: true}); @@ -148,11 +176,29 @@ function emitReactFrameworkHeaders( path.join(fwk, 'Modules', 'module.modulemap'), renderReactModuleMap(plan.privateReactHeaders), ); + if (privacyManifest != null) { + fs.writeFileSync( + path.join(fwk, 'PrivacyInfo.xcprivacy'), + serializePrivacyManifest(privacyManifest), + ); + } + // Clone the prebuilt RCTI18nStrings.bundle so the framework-aware + // RCTLocalizedString loader resolves React-Core's strings in prebuilt/SPM. + if (i18nLocales > 0 && i18nBundleStage != null) { + const dest = path.join(fwk, 'RCTI18nStrings.bundle'); + fs.rmSync(dest, {recursive: true, force: true}); + execFileSync('/bin/cp', [CP_FLAGS, i18nBundleStage, dest]); + } } fs.rmSync(stage, {recursive: true, force: true}); + if (i18nStage != null) { + fs.rmSync(i18nStage, {recursive: true, force: true}); + } console.log( `headers-compose: React.framework spec layout -> ${slices.join(', ')} ` + - `(${plan.react.length} headers, umbrella ${plan.umbrella.length})`, + `(${plan.react.length} headers, umbrella ${plan.umbrella.length}` + + `${privacyManifest != null ? ', +PrivacyInfo.xcprivacy' : ''}` + + `${i18nLocales > 0 ? `, +RCTI18nStrings.bundle (${i18nLocales} locales)` : ''})`, ); } From a8156acf8bbc9ee15901cf0d8935d73454768aa7 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 15 Jul 2026 05:08:52 -0700 Subject: [PATCH 069/834] feat(iOS): serve third-party deps from the prebuilt ReactNativeDependencies pod via dependency-only facades (#57440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Step 1 of making the prebuilt `ReactNativeDependencies` pod the **single header authority** for the third-party C/C++ deps (RCT-Folly, glog, boost, DoubleConversion, fmt, fast_float, SocketRocket) in prebuilt-deps mode. Today the deps **binary** replaces the source pods' code, but the pod still `s.dependency`'s the real source pods and borrows their headers via `$(PODS_ROOT)/` search paths. That split header authority is the dual-copy bug class behind the 2026-07-03 SocketRocket regression (`duplicate interface` under `use_frameworks!` — SocketRocket's ObjC headers have no include guards). Three commits: 1. **fix(cocoapods): harden prebuilt-deps header search paths and artifact handling** — `rndependencies.rb`'s `||= [] << path` only added the deps header search path when `HEADER_SEARCH_PATHS` was unset (silently dropped otherwise); normalize and always append, and point at the pod-local flattened `Headers/`. `ReactNativeDependencies.podspec` `prepare_command` now fails closed (`exit 1`) instead of silently producing a no-link pod. `reactNativeDependencies.js` no longer deletes + re-downloads a locally staged artifact that lacks a version marker. 2. **feat(cocoapods): dependency-only facades for third-party pods in prebuilt-deps mode** — in prebuilt-deps mode the real source pods are not declared, so a community podspec's hardcoded `s.dependency "RCT-Folly"` would resolve from trunk and compile from source next to the prebuilt binary. `RNDepsFacades` generates dependency-only local facade podspecs (no sources, no headers, a single dependency on `ReactNativeDependencies`); versions/subspecs are derived from the real podspecs in `third-party-podspecs/` (SocketRocket synthesized fail-closed from `socket_rocket_config`). Full contract documented in `scripts/cocoapods/__docs__/prebuilt-deps.md`. 3. **feat(ios-prebuild): SocketRocket privacy manifest + Xcode 26 header layout** — embed an RN-authored, accurate-empty `PrivacyInfo.xcprivacy` for SocketRocket (upstream ships none), and stage flat public headers into `include/` so Xcode 26's SwiftPM accepts the header layout. Stacked on https://github.com/react/react-native/issues/57305 (base: `chrfalch/prebuilt-resources`); the SwiftPM preview (https://github.com/react/react-native/issues/57332) rebases on top of this. Follow-up (separate PR): headers-only `ReactNativeDependenciesHeaders.xcframework` sidecar so SPM auto-serves the deps namespaces and `ReactNativeHeaders` goes pure-RN. ## Changelog: [IOS][CHANGED] - Prebuilt-deps mode: serve third-party headers from the ReactNativeDependencies pod itself and resolve community `s.dependency` on RCT-Folly/glog/boost/etc. via dependency-only facade pods Pull Request resolved: https://github.com/react/react-native/pull/57440 Test Plan: E2E matrix (2026-07-06, locally built deps artifact via `prepare-ios-prebuilds.js`): - rn-tester, prebuilt core + prebuilt deps, static linkage — builds - rn-tester, prebuilt core + prebuilt deps, `USE_FRAMEWORKS=dynamic` — builds (the SocketRocket-regression config) - helloworld (private), prebuilt core + prebuilt deps, static — builds - source-mode control: no facades generated, `Podfile.lock` identical to baseline 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: fabriziocucci Differential Revision: D111449257 Pulled By: cipolleschi fbshipit-source-id: ace5716868d126a08721200efd640e903b191658 --- .../cocoapods/__docs__/prebuilt-deps.md | 74 +++++++ .../scripts/cocoapods/rndependencies.rb | 21 +- .../scripts/cocoapods/rndeps_facades.rb | 193 ++++++++++++++++++ .../ios-prebuild/reactNativeDependencies.js | 7 + .../react-native/scripts/react_native_pods.rb | 12 ++ .../ReactNativeDependencies.podspec | 4 +- .../releases/ios-prebuild/configuration.js | 18 +- .../SocketRocket/PrivacyInfo.xcprivacy | 12 ++ 8 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md create mode 100644 packages/react-native/scripts/cocoapods/rndeps_facades.rb create mode 100644 scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy diff --git a/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md new file mode 100644 index 000000000000..cfd171dae828 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md @@ -0,0 +1,74 @@ +# Prebuilt ReactNativeDependencies — self-serving headers + deps facades + +How the third-party C/C++ deps (`RCT-Folly`, `glog`, `boost`, +`DoubleConversion`, `fmt`, `fast_float`, `SocketRocket`) are served when +`ReactNativeDependenciesUtils.build_react_native_deps_from_source()` is false +(prebuilt-deps mode). Source-deps mode is unaffected by everything below. + +## Pod-served headers, CocoaPods only (`rndependencies.rb`) + +In prebuilt-deps mode the `ReactNativeDependencies` POD (CocoaPods) is the +single authority for the third-party deps: compiled code lives in its +xcframework binary, and the artifact's own +`Headers/{folly,glog,boost,fmt,double-conversion,fast_float,SocketRocket}` are +flattened into the pod's `Headers/` by the podspec's `prepare_command`. +Consumers resolve bare `` / `` via CocoaPods +public-header linkage from `s.dependency "ReactNativeDependencies"`, plus an +explicit `HEADER_SEARCH_PATHS` entry +(`$(PODS_ROOT)/ReactNativeDependencies/Headers`). The real source pods are +neither depended on nor searched. + +NOTE: this is a CocoaPods-level contract. The deps XCFRAMEWORK itself is NOT +self-serving: it is framework-type without `HeadersPath`, so its root `Headers/` +is invisible to SPM binaryTargets (verified 2026-07-04 — `HeadersPath` is +rejected on framework entries). In SPM the six C++ namespaces are served by +ReactNativeHeaders.xcframework; serving them from the deps side requires the +phase-2 headers-only library sidecar (see rn-deps-self-serving plan). + +## Why SocketRocket is vended here + +React-Core compiled from source (source-core + prebuilt-deps mix) imports +`` (`RCTReconnectingWebSocket.m`), and in +prebuilt-deps mode there is NO real SocketRocket pod in the graph — the artifact +is the sole supplier. This does not reintroduce the 2026-07-03 dual-copy +regression: that bug relocated SocketRocket copies onto every pod's search path +(via ReactNativeHeaders → React-Core-prebuilt) while a REAL SocketRocket pod +coexisted. Here there is exactly one physical copy and no coexisting pod. + +## Deps facades (`rndeps_facades.rb`, declared in `react_native_pods.rb`) + +The real source pods are only declared in the deps-from-source branch, so in +prebuilt-deps mode a community podspec's hardcoded `s.dependency "RCT-Folly"` / +`"RCT-Folly/Fabric"` / `"glog"` would resolve from the CocoaPods trunk and +compile from source next to the prebuilt binary. `RNDepsFacades` generates +dependency-only facade podspecs (`build/rndeps-facades//`), installed as +LOCAL pods (`:path`, so Podfile-local resolution beats trunk, nothing fetched): +no sources, no headers, single dependency on `ReactNativeDependencies`. +Versions + subspecs are DERIVED from the real podspecs in +`third-party-podspecs/` (RCT-Folly keeps `/Default` + `/Fabric`, +`default_subspecs = ["Default"]`). SocketRocket has no local podspec — its +facade version is SYNTHESIZED from +`Helpers::Constants::socket_rocket_config[:version]`, fail-closed if absent. +`:modular_headers` is intentionally dropped on facade declarations: a +dependency-only placeholder builds no module; consumers get modules from +`ReactNativeDependencies`. + +## Mode × supplier table + +| core × deps | real 3P pods in graph | SocketRocket headers supplier | +| ------------------- | ----------------------------------------------- | ------------------------------- | +| source + source | yes (`react_native_pods.rb` deps-source branch) | real pod | +| source + prebuilt | no | RNDeps artifact (sole supplier) | +| prebuilt + source | yes | real pod | +| prebuilt + prebuilt | no | RNDeps artifact | + +## SocketRocket privacy manifest + +Upstream SocketRocket ships NO privacy manifest, and the deps artifact +historically carried bundles only for boost/folly/glog. Fixed alongside this +work: the deps prebuild (`scripts/releases/ios-prebuild/configuration.js`) now +embeds `ReactNativeDependencies_SocketRocket.bundle/PrivacyInfo.xcprivacy`, +sourced from an RN-authored manifest at +`scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy` +(accurate-empty: SocketRocket uses no Required Reason APIs). Facades remain +resource-free by design. diff --git a/packages/react-native/scripts/cocoapods/rndependencies.rb b/packages/react-native/scripts/cocoapods/rndependencies.rb index 1c7fac1a6cb7..483dfa92c9e9 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -6,6 +6,7 @@ require "json" require 'net/http' require 'rexml/document' +require 'shellwords' require_relative './utils.rb' @@ -36,7 +37,7 @@ def add_rn_third_party_dependencies(s) header_search_paths = current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] || [] if header_search_paths.is_a?(String) - header_search_paths = header_search_paths.split(" ") + header_search_paths = Shellwords.shellsplit(header_search_paths) end header_search_paths << "$(PODS_ROOT)/glog" @@ -47,10 +48,24 @@ def add_rn_third_party_dependencies(s) header_search_paths << "$(PODS_ROOT)/SocketRocket" header_search_paths << "$(PODS_ROOT)/RCT-Folly" - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths + # uniq so a second call on the same spec can't duplicate entries. + current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] = header_search_paths.uniq 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" - current_pod_target_xcconfig["HEADER_SEARCH_PATHS"] ||= [] << "$(PODS_ROOT)/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 end s.pod_target_xcconfig = current_pod_target_xcconfig diff --git a/packages/react-native/scripts/cocoapods/rndeps_facades.rb b/packages/react-native/scripts/cocoapods/rndeps_facades.rb new file mode 100644 index 000000000000..150a4fa0cba9 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/rndeps_facades.rb @@ -0,0 +1,193 @@ +# 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 'json' +require 'fileutils' +# Self-contained against require ordering: this module reads +# Helpers::Constants.socket_rocket_config. react_native_pods.rb normally loads +# helpers.rb first, but requiring it here (idempotent) removes that implicit +# dependency. The defined? guard at the use site stays as a backstop. +require_relative './helpers' + +# Dependency-only facade podspecs for the third-party deps in prebuilt-deps +# mode (deps-side analogue of RNCoreFacades). Design + rationale: +# scripts/cocoapods/__docs__/prebuilt-deps.md +module RNDepsFacades + # The name of the umbrella prebuilt-deps pod every facade depends on. Its pod + # self-serves the third-party headers + carries the binary (see + # rndependencies.rb, ReactNativeDependencies.podspec). + DEPS_POD = "ReactNativeDependencies" + + # pod name => podspec path (relative to the react-native package root), or + # :synthesized for a pod with no local podspec (SocketRocket). Version + + # subspecs + default_subspecs are DERIVED from the real podspec where one + # exists; synthesized entries derive their version from a constant. + FACADE_PODS = { + "RCT-Folly" => "third-party-podspecs/RCT-Folly.podspec", + "glog" => "third-party-podspecs/glog.podspec", + "boost" => "third-party-podspecs/boost.podspec", + "DoubleConversion" => "third-party-podspecs/DoubleConversion.podspec", + "fmt" => "third-party-podspecs/fmt.podspec", + "fast_float" => "third-party-podspecs/fast_float.podspec", + "SocketRocket" => :synthesized, + } + + # Sub-directory (relative to the install root) that holds the generated + # deps facades. Kept separate from RNCoreFacades' `build/rncore-facades` so + # the two families never collide. + FACADE_RELDIR = File.join("build", "rndeps-facades") + + # Generates the facade podspecs and returns the base directory holding them. + # Each facade gets its OWN sub-directory containing a single + # `.podspec.json`, so it can be installed as a LOCAL pod via + # `:path => ` (PathSource uses the spec in place and never downloads + # `spec.source`). Idempotent; safe to call once per `pod install`. + # + # `react_native_path` locates the real third-party podspecs we mirror. + # version + subspecs + default_subspecs are DERIVED from the real spec (or, + # for SocketRocket, synthesized from the socket_rocket_config version) so the + # facade matches the source pod's spec/subspec SHAPE. It is not fully + # graph-equivalent: every derived subspec depends only on + # ReactNativeDependencies, so intra-pod subspec deps (e.g. RCT-Folly/Fabric + # -> RCT-Folly/Default) are not reproduced — harmless here because the deps + # are all declared explicitly in react_native_pods.rb. NO source_files and + # NO headers are emitted — the ReactNativeDependencies pod supplies both. A + # facaded pod whose real podspec can't be read is a hard error (see + # load_real_spec) — silently shipping an empty facade would hide drift. + def self.generate(react_native_path, install_root, ios_version) + abs_base = File.join(install_root.to_s, FACADE_RELDIR) + FileUtils.mkdir_p(abs_base) + FACADE_PODS.each do |name, podspec_rel_path| + dir = File.join(abs_base, name) + FileUtils.mkdir_p(dir) + + if podspec_rel_path == :synthesized + spec = synthesized_spec(name, ios_version) + else + podspec_path = File.join(react_native_path.to_s, podspec_rel_path) + real = load_real_spec(podspec_path, name) + spec = derived_spec(name, real, ios_version) + end + + File.write(File.join(dir, "#{name}.podspec.json"), JSON.pretty_generate(spec)) + end + abs_base + end + + # Facade dir for ``, RELATIVE to the install root — pass to `pod :path =>`. + # Relative (not absolute) so the path CocoaPods records in Podfile.lock is + # portable rather than machine-specific. + def self.facade_path(name) + File.join(FACADE_RELDIR, name) + end + + # Base spec skeleton shared by derived + synthesized facades: dependency-only, + # no source_files, no headers. Depends solely on ReactNativeDependencies. + def self.base_spec(name, version, ios_version) + { + "name" => name, + "version" => version, + "summary" => "Prebuilt facade for #{name} (code + headers live in #{DEPS_POD}).", + "homepage" => "https://reactnative.dev/", + "license" => "MIT", + "authors" => "Meta Platforms, Inc. and its affiliates", + "platforms" => { "ios" => ios_version }, + # Required podspec attribute, but never fetched: installed as a LOCAL + # pod (`:path => `), which uses this spec in place and ships no + # source_files. Placeholder only. + "source" => { "git" => "https://github.com/facebook/react-native.git" }, + "dependencies" => { DEPS_POD => [] }, + } + end + private_class_method :base_spec + + # Facade derived from a real third-party podspec: version + subspecs + + # default_subspecs mirror the real spec so a bare `pod ''` and any + # `pod '/'` resolve to the SAME graph (e.g. RCT-Folly's + # bare + /Default + /Fabric). Each subspec is also dependency-only and + # depends on ReactNativeDependencies. + # + # NOTE: resources (e.g. RCT-Folly's PrivacyInfo.xcprivacy) are intentionally + # NOT carried. In prebuilt-deps mode the third-party code — and its privacy + # manifest — is embedded in the ReactNativeDependencies artifact; the facade + # only needs to declare the dependency (see the design note in the PR). + def self.derived_spec(name, real, ios_version) + spec = base_spec(name, real.version.to_s, ios_version) + + defaults = Array(real.default_subspecs) + spec["default_subspecs"] = defaults unless defaults.empty? + + subspecs = derive_subspecs(real) + unless subspecs.empty? + spec["subspecs"] = subspecs.map do |ss| + { "name" => ss, "dependencies" => { DEPS_POD => [] } } + end + end + + spec + end + private_class_method :derived_spec + + # Facade synthesized for a pod with NO local podspec (SocketRocket, a trunk + # pod). Version comes from Helpers::Constants::socket_rocket_config; no + # subspecs. A missing/blank constant is a hard error rather than a silent + # versionless facade — a bare `pod 'SocketRocket'` in the source path is + # `"~> #{socket_rocket_config[:version]}"`, so the facade MUST carry a version + # that satisfies that constraint. + def self.synthesized_spec(name, ios_version) + version = synthesized_version(name) + base_spec(name, version, ios_version) + end + private_class_method :synthesized_spec + + # Resolves the synthesized version for a no-podspec facade. Fail-closed on a + # missing constant/version. Only SocketRocket is synthesized today. + def self.synthesized_version(name) + case name + when "SocketRocket" + unless defined?(Helpers::Constants) && Helpers::Constants.respond_to?(:socket_rocket_config) + raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \ + "Helpers::Constants.socket_rocket_config is unavailable." + end + version = Helpers::Constants.socket_rocket_config[:version] + if version.nil? || version.to_s.strip.empty? + raise "[RNDepsFacades] Cannot synthesize facade for '#{name}': " \ + "socket_rocket_config[:version] is missing or empty." + end + version.to_s + else + raise "[RNDepsFacades] No synthesized version rule for facaded pod '#{name}'. " \ + "Add one to synthesized_version or give it a real podspec in FACADE_PODS." + end + end + private_class_method :synthesized_version + + # Loads the real podspec so we can mirror its structure. A facaded pod with a + # declared podspec path MUST have a readable real podspec — if it's missing or + # unparseable we raise rather than ship an empty facade (which would silently + # drop subspecs / the version, the very drift this mechanism prevents). + def self.load_real_spec(path, name) + unless File.exist?(path) + raise "[RNDepsFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \ + "Update FACADE_PODS in rndeps_facades.rb if the podspec moved." + end + begin + Pod::Specification.from_file(path) + rescue => e + raise "[RNDepsFacades] Failed to read real podspec for facaded pod '#{name}' at #{path}: #{e.message}" + end + end + private_class_method :load_real_spec + + # Library (non-test, non-app) subspec names of the real spec, so third-party + # libs depending on `/` (e.g. `RCT-Folly/Fabric`) keep + # resolving. Derived, never hand-listed. + def self.derive_subspecs(real) + real.subspecs + .reject { |ss| ss.test_specification? || (ss.respond_to?(:app_specification?) && ss.app_specification?) } + .map(&:base_name) + end + private_class_method :derive_subspecs +end diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index f497338bef8b..24cba8083234 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -161,6 +161,13 @@ function checkExistingVersion( dependencyLog( `React Native Dependencies found on disk at: ${artifactsPath}.\nNo version file has been found. We are going to use it anyway, but there might be some unexpected behaviors.`, ); + // Honor the message above: an artifact without a version marker is a + // locally-staged one (e.g. a freshly composed deps build). Use it as-is. + // NOTE: this returns BEFORE the version.txt write below, so a + // locally-staged artifact never gains a marker — every later run re-hits + // this branch. That is intentional (don't clobber a hand-staged build), + // but downstream code must not assume version.txt exists here. + return true; } } else { dependencyLog('React Native Dependencies not found on disk'); diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 38a0619f1708..4214e23b20da 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -22,6 +22,7 @@ require_relative './cocoapods/spm.rb' require_relative './cocoapods/rncore.rb' require_relative './cocoapods/rncore_facades.rb' +require_relative './cocoapods/rndeps_facades.rb' # Importing to expose use_native_modules! require_relative './cocoapods/autolinking.rb' @@ -243,6 +244,17 @@ def use_react_native! ( ReactNativeCoreUtils.rncore_log("Using React Native Core and React Native Dependencies prebuilt versions.") pod 'ReactNativeDependencies', :podspec => "#{prefix}/third-party-podspecs/ReactNativeDependencies.podspec", :modular_headers => true + # Facades: community pods' hardcoded s.dependency "RCT-Folly"/"glog"/... must + # resolve locally instead of from trunk. See __docs__/prebuilt-deps.md. + RNDepsFacades.generate(react_native_path, Pod::Config.instance.installation_root, min_ios_version_supported) + pod 'DoubleConversion', :path => RNDepsFacades.facade_path('DoubleConversion') + pod 'glog', :path => RNDepsFacades.facade_path('glog') + pod 'boost', :path => RNDepsFacades.facade_path('boost') + pod 'fast_float', :path => RNDepsFacades.facade_path('fast_float') + pod 'fmt', :path => RNDepsFacades.facade_path('fmt') + pod 'RCT-Folly', :path => RNDepsFacades.facade_path('RCT-Folly') + pod 'SocketRocket', :path => RNDepsFacades.facade_path('SocketRocket') + if !ReactNativeCoreUtils.build_rncore_from_source() pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true end diff --git a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec index e08e6c2b0995..a006f2c1e4ca 100644 --- a/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec +++ b/packages/react-native/third-party-podspecs/ReactNativeDependencies.podspec @@ -53,13 +53,13 @@ Pod::Spec.new do |spec| # Check if XCFRAMEWORK_PATH is empty if [ -z "$XCFRAMEWORK_PATH" ]; then echo "ERROR: XCFRAMEWORK_PATH is empty." - exit 0 + exit 1 fi # Check if HEADERS_PATH is empty if [ -z "$HEADERS_PATH" ]; then echo "ERROR: HEADERS_PATH is empty." - exit 0 + exit 1 fi cp -R "$HEADERS_PATH/." Headers diff --git a/scripts/releases/ios-prebuild/configuration.js b/scripts/releases/ios-prebuild/configuration.js index 8bf6f5db12aa..3259ac632974 100644 --- a/scripts/releases/ios-prebuild/configuration.js +++ b/scripts/releases/ios-prebuild/configuration.js @@ -136,17 +136,27 @@ const dependencies /*: ReadonlyArray */ = [ }, }, { - name: 'socket-rocket', + name: 'SocketRocket', version: '0.7.1', + // Xcode 26's SwiftPM rejects a public-headers dir where the umbrella + // header (SocketRocket.h) has sibling directories (SocketRocket/Internal): + // "target 'SocketRocket' has invalid header layout". Stage the flat public + // headers into include/ and point publicHeaderFiles there. The artifact's + // Headers/SocketRocket namespace (files.headers) is unaffected, and + // Xcode 16 accepts both layouts. + prepareScript: 'mkdir -p include && cp SocketRocket/*.h include/', url: new URL( 'https://github.com/facebookincubator/SocketRocket/archive/refs/tags/0.7.1.tar.gz', ), files: { - sources: ['SocketRocket/**/*.{h,m}'], + sources: ['SocketRocket/**/*.{h,m}', 'include/*.h'], headers: ['SocketRocket/*.h'], + resources: [ + '../../../scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy', + ], }, settings: { - publicHeaderFiles: './SocketRocket', + publicHeaderFiles: './include', headerSearchPaths: [ './', 'SocketRocket', @@ -254,7 +264,7 @@ const dependencies /*: ReadonlyArray */ = [ 'fmt', 'boost', 'fast_float', - 'socket-rocket', + 'SocketRocket', ], settings: { publicHeaderFiles: './', diff --git a/scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy b/scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy new file mode 100644 index 000000000000..23a9697f383d --- /dev/null +++ b/scripts/releases/ios-prebuild/resources/SocketRocket/PrivacyInfo.xcprivacy @@ -0,0 +1,12 @@ + + + + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + From 6aa147f6c95337d930c6a4bca363449972f5c771 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 15 Jul 2026 05:08:52 -0700 Subject: [PATCH 070/834] feat(iOS): ReactNativeDependenciesHeaders sidecar + pure-RN ReactNativeHeaders, published to Maven (#57442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Step 2 of the prebuilt-deps roadmap: ship the deps headers as a **SwiftPM-ready, self-contained artifact** and make every header namespace have exactly **one physical home**. 1. **New artifact: `ReactNativeDependenciesHeaders.xcframework`** — the binary `ReactNativeDependencies.xcframework` is framework-type, so its root `Headers/` is invisible to SwiftPM binaryTargets (`HeadersPath` is rejected on framework entries; verified empirically). The deps prebuild now emits a headers-only library-type sidecar (stub archives + per-slice `Headers/` + `HeadersPath` — the exact `ReactNativeHeaders` recipe, factored into a shared `headers-xcframework.js` emitter) carrying all seven deps namespaces incl. SocketRocket, with slice parity derived from the binary artifact's Info.plist. Ships inside the deps tarball *and* standalone. 2. **`ReactNativeHeaders` goes pure-RN** — the R2 relocation of deps namespaces (and the `DEPS_NAMESPACES_NOT_RELOCATED` SocketRocket exclusion list) is deleted. Relocated copies are what enabled the SocketRocket dual-copy regression (duplicate `interface` / poisoned module graph under `use_frameworks!`); that bug class is now structurally impossible. Headers gate flipped: deps namespaces must be **absent** from RNH; the sidecar emitter enforces set-equality with `DEPS_NAMESPACES` fail-closed in both directions. On the CocoaPods side, a new `ReactNativeDependenciesUtils.configure_aggregate_xcconfig` injects the deps pod's `Headers/` globally (aggregate + every pod target), mirroring the rncore injection — this replaces the folly/glog resolution pods previously got via the flattened `React-Core-prebuilt/Headers`. 3. **CI: prebuilt + dynamic-frameworks lane** — the regression's exact config had no coverage (the `test-ios-rntester` action hard-coupled `use-frameworks:true` to source builds). New `use-prebuilds` input; `test_ios_rntester`'s dynamic cells now consume the workflow-built prebuilt artifacts. 4. **Maven publishing** — `ReactNativeHeaders` and `ReactNativeDependenciesHeaders` publish standalone on `react-native-artifacts` (classifiers `reactnative-headers-*`, `reactnative-dependencies-headers-*`); `verifyArtifactsAreOnMaven` now HEAD-checks every classifier tarball instead of only the POM. Stacked on https://github.com/react/react-native/issues/57440. The SwiftPM preview (https://github.com/react/react-native/issues/57332) rebases on top and wires the sidecar as its 5th binaryTarget. ## Changelog: [IOS] [CHANGED] - Prebuilt artifacts: ReactNativeHeaders is pure-RN; third-party deps headers ship in the new ReactNativeDependenciesHeaders.xcframework sidecar (and the ReactNativeDependencies pod), published standalone to Maven Pull Request resolved: https://github.com/react/react-native/pull/57442 Test Plan: - Headers gate: include-health, structural (deps absent from RNH, byte-matched module maps), and compile smokes (React module + 14 namespace modules + Expo-shape ObjC++/Swift fixtures vs the deps include path) — ALL PASSED - jest: 33/33 (`scripts/ios-prebuild/__tests__`, incl. new sidecar set-equality tests) - ESLint (`--max-warnings 0`), Prettier, Flow (`yarn flow-check`): clean - E2E (locally built artifacts): rn-tester prebuilt static ✅, prebuilt `USE_FRAMEWORKS=dynamic` ✅ (the regression config — verified `React-Core-prebuilt/Headers` contains no deps namespaces and the deps pod serves all seven), helloworld static ✅, source-core + prebuilt-deps ✅ (React compiled from source resolves folly via the deps pod), source-mode control with unchanged dependency graph ✅ - Sidecar inspected: per-slice `HeadersPath`, 7 namespaces, slice parity with the binary - Publication validated end-to-end with `publishReleasePublicationToMavenLocal`: all 12 files + POM land with the expected classifier names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: fabriziocucci Differential Revision: D111449462 Pulled By: cipolleschi fbshipit-source-id: e1217d14c0588d00a207622d346c9e6f4705a95d --- .github/actions/build-npm-package/action.yml | 9 + .github/actions/test-ios-rntester/action.yml | 43 ++- .../verifyArtifactsAreOnMaven-test.js | 67 ++++- .../verifyArtifactsAreOnMaven.js | 62 +++- .github/workflows/prebuild-ios-core.yml | 27 +- .../workflows/prebuild-ios-dependencies.yml | 18 +- .github/workflows/test-all.yml | 6 + .../react-native/React-Core-prebuilt.podspec | 14 +- .../external-artifacts/build.gradle.kts | 49 +++ .../cocoapods/__docs__/prebuilt-deps.md | 24 +- .../react-native/scripts/cocoapods/rncore.rb | 4 +- .../scripts/cocoapods/rndependencies.rb | 46 +++ .../ios-prebuild/__docs__/headers-rules.md | 11 +- .../__tests__/headers-compose-test.js | 48 +++ .../__tests__/headers-spec-test.js | 17 ++ .../__tests__/headers-xcframework-test.js | 113 +++++++ .../scripts/ios-prebuild/headers-compose.js | 212 +++++-------- .../scripts/ios-prebuild/headers-inventory.js | 16 +- .../scripts/ios-prebuild/headers-spec.js | 41 ++- .../scripts/ios-prebuild/headers-verify.js | 44 ++- .../ios-prebuild/headers-xcframework.js | 282 ++++++++++++++++++ .../ios-prebuild/reactNativeDependencies.js | 30 ++ .../scripts/ios-prebuild/xcframework.js | 13 +- .../react-native/scripts/react_native_pods.rb | 9 + .../ios-prebuild/compose-framework.js | 24 ++ 25 files changed, 975 insertions(+), 254 deletions(-) create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js create mode 100644 packages/react-native/scripts/ios-prebuild/headers-xcframework.js diff --git a/.github/actions/build-npm-package/action.yml b/.github/actions/build-npm-package/action.yml index 2c5dd61a7d67..e3771ce3d51a 100644 --- a/.github/actions/build-npm-package/action.yml +++ b/.github/actions/build-npm-package/action.yml @@ -31,6 +31,15 @@ runs: pattern: ReactCore* path: ./packages/react-native/ReactAndroid/external-artifacts/artifacts merge-multiple: true + # ReactNativeDependenciesHeaders* is already covered by the + # ReactNativeDependencies* pattern above; ReactNativeHeaders* needs its own. + - name: Download ReactNativeHeaders artifacts + if: ${{ inputs.skip-apple-prebuilts != 'true' }} + uses: actions/download-artifact@v7 + with: + pattern: ReactNativeHeaders* + path: ./packages/react-native/ReactAndroid/external-artifacts/artifacts + merge-multiple: true - name: Print Artifacts Directory if: ${{ inputs.skip-apple-prebuilts != 'true' }} shell: bash diff --git a/.github/actions/test-ios-rntester/action.yml b/.github/actions/test-ios-rntester/action.yml index fbb2d7474c38..2b62f554daa1 100644 --- a/.github/actions/test-ios-rntester/action.yml +++ b/.github/actions/test-ios-rntester/action.yml @@ -15,9 +15,18 @@ inputs: required: false default: false use-frameworks: - description: Whether we have to build with Dynamic Frameworks. If this is set to true, it builds from source + description: Whether we have to build with Dynamic Frameworks. required: false default: false + use-prebuilds: + description: >- + Whether to consume the prebuilt ReactCore/ReactNativeDependencies + artifacts. 'auto' (default) keeps the historical coupling: prebuilds for + static, source for dynamic frameworks. Pass 'true' with + use-frameworks:true for the prebuilt + dynamic-frameworks lane (the + config of the 2026-07-03 SocketRocket dual-copy regression). + required: false + default: auto runs: using: composite @@ -41,24 +50,44 @@ runs: - name: Prepare IOS Tests if: ${{ inputs.run-unit-tests == 'true' }} uses: ./.github/actions/prepare-ios-tests + - name: Resolve prebuilds mode + id: prebuilds + shell: bash + run: | + if [[ "${{ inputs.use-prebuilds }}" == "auto" ]]; then + # Historical coupling: prebuilds for static, source for dynamic frameworks. + if [[ "${{ inputs.use-frameworks }}" == "true" ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + elif [[ "${{ inputs.use-prebuilds }}" == "true" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + elif [[ "${{ inputs.use-prebuilds }}" == "false" ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + # Don't silently treat a typo as 'disabled' — surface it. + echo "::warning::Unexpected use-prebuilds value '${{ inputs.use-prebuilds }}' (expected auto/true/false); treating as disabled." + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi - name: Download ReactNativeDependencies - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/third-party/ - name: Print third-party folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/third-party - name: Download React Native Prebuilds - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} uses: actions/download-artifact@v7 with: name: ReactCore${{ inputs.flavor }}.xcframework.tar.gz path: /tmp/ReactCore - name: Print ReactCore folder - if: ${{ inputs.use-frameworks == 'false' }} + if: ${{ steps.prebuilds.outputs.enabled == 'true' }} shell: bash run: ls -lR /tmp/ReactCore - name: Install CocoaPods dependencies @@ -66,8 +95,8 @@ runs: run: | if [[ ${{ inputs.use-frameworks }} == "true" ]]; then export USE_FRAMEWORKS=dynamic - else - # If use-frameworks is false, let's use prebuilds + fi + if [[ "${{ steps.prebuilds.outputs.enabled }}" == "true" ]]; then export RCT_USE_LOCAL_RN_DEP="/tmp/third-party/ReactNativeDependencies${{ inputs.flavor }}.xcframework.tar.gz" export RCT_TESTONLY_RNCORE_TARBALL_PATH="/tmp/ReactCore/ReactCore${{ inputs.flavor }}.xcframework.tar.gz" fi diff --git a/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js b/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js index e77e7c4e2e49..8e9758f28609 100644 --- a/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js +++ b/.github/workflow-scripts/__tests__/verifyArtifactsAreOnMaven-test.js @@ -22,6 +22,28 @@ jest.mock('../utils.js', () => ({ process.exit = mockExit; global.fetch = mockFetch; +const BASE_URL = + 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts'; + +// The verifier HEAD-checks the POM plus every classifier tarball attached to +// the react-native-artifacts publication (external-artifacts/build.gradle.kts). +const expectedUrls = version => [ + `${BASE_URL}/${version}/react-native-artifacts-${version}.pom`, + ...[ + 'reactnative-core-debug', + 'reactnative-core-release', + 'reactnative-dependencies-debug', + 'reactnative-dependencies-release', + 'reactnative-headers-debug', + 'reactnative-headers-release', + 'reactnative-dependencies-headers-debug', + 'reactnative-dependencies-headers-release', + ].map( + classifier => + `${BASE_URL}/${version}/react-native-artifacts-${version}-${classifier}.tar.gz`, + ), +]; + describe('#verifyArtifactsAreOnMaven', () => { beforeEach(jest.clearAllMocks); @@ -29,17 +51,18 @@ describe('#verifyArtifactsAreOnMaven', () => { mockSleep.mockReturnValueOnce(Promise.resolve()).mockImplementation(() => { throw new Error('Should not be called again!'); }); + // First attempt: the POM is not there yet. Second attempt: every URL is. mockFetch .mockReturnValueOnce(Promise.resolve({status: 404})) - .mockReturnValueOnce(Promise.resolve({status: 200})); + .mockReturnValue(Promise.resolve({status: 200})); const version = '0.78.1'; await verifyArtifactsAreOnMaven(version); expect(mockSleep).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', - ); + for (const url of expectedUrls('0.78.1')) { + expect(mockFetch).toHaveBeenCalledWith(url, {method: 'HEAD'}); + } }); it('waits for the packages to be published on maven, when version starts with v', async () => { @@ -48,27 +71,46 @@ describe('#verifyArtifactsAreOnMaven', () => { }); mockFetch .mockReturnValueOnce(Promise.resolve({status: 404})) - .mockReturnValueOnce(Promise.resolve({status: 200})); + .mockReturnValue(Promise.resolve({status: 200})); const version = 'v0.78.1'; await verifyArtifactsAreOnMaven(version); expect(mockSleep).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledWith( - 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', - ); + for (const url of expectedUrls('0.78.1')) { + expect(mockFetch).toHaveBeenCalledWith(url, {method: 'HEAD'}); + } }); it('passes immediately if packages are already on Maven', async () => { - mockFetch.mockReturnValueOnce(Promise.resolve({status: 200})); + mockFetch.mockReturnValue(Promise.resolve({status: 200})); const version = '0.78.1'; await verifyArtifactsAreOnMaven(version); expect(mockSleep).toHaveBeenCalledTimes(0); - expect(mockFetch).toHaveBeenCalledWith( - 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', - ); + // All nine URLs (POM + 8 classifier tarballs) are verified in one pass. + expect(mockFetch).toHaveBeenCalledTimes(9); + for (const url of expectedUrls('0.78.1')) { + expect(mockFetch).toHaveBeenCalledWith(url, {method: 'HEAD'}); + } + }); + + it('waits when a classifier artifact is missing even though the POM exists', async () => { + mockSleep.mockReturnValueOnce(Promise.resolve()).mockImplementation(() => { + throw new Error('Should not be called again!'); + }); + // First attempt: POM ok, first classifier missing. Second attempt: all ok. + mockFetch + .mockReturnValueOnce(Promise.resolve({status: 200})) + .mockReturnValueOnce(Promise.resolve({status: 404})) + .mockReturnValue(Promise.resolve({status: 200})); + + const version = '0.78.1'; + await verifyArtifactsAreOnMaven(version); + + expect(mockSleep).toHaveBeenCalledTimes(1); + expect(mockExit).not.toHaveBeenCalled(); }); it('tries 90 times and then exits', async () => { @@ -82,6 +124,7 @@ describe('#verifyArtifactsAreOnMaven', () => { expect(mockExit).toHaveBeenCalledWith(1); expect(mockFetch).toHaveBeenCalledWith( 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.78.1/react-native-artifacts-0.78.1.pom', + {method: 'HEAD'}, ); }); }); diff --git a/.github/workflow-scripts/verifyArtifactsAreOnMaven.js b/.github/workflow-scripts/verifyArtifactsAreOnMaven.js index 1bb46163e0a2..bb76721ed219 100644 --- a/.github/workflow-scripts/verifyArtifactsAreOnMaven.js +++ b/.github/workflow-scripts/verifyArtifactsAreOnMaven.js @@ -7,6 +7,7 @@ * @format */ +// @flow const {log, sleep} = require('./utils'); const SLEEP_S = 60; // 1 minute @@ -15,23 +16,64 @@ const ARTIFACT_URL = 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/'; const ARTIFACT_NAME = 'react-native-artifacts-'; -async function verifyArtifactsAreOnMaven(version, retries = MAX_RETRIES) { +// The primary xcframework classifier tarballs attached to the +// react-native-artifacts publication (external-artifacts/build.gradle.kts). +// The 4 dSYM classifiers (core/deps dSYM debug+release) are intentionally +// excluded — they are debug-symbol sidecars, not consumed at install time. +// The POM check alone would pass even when a classifier artifact never made +// it to Maven. +const ARTIFACT_CLASSIFIERS = [ + 'reactnative-core-debug', + 'reactnative-core-release', + 'reactnative-dependencies-debug', + 'reactnative-dependencies-release', + 'reactnative-headers-debug', + 'reactnative-headers-release', + 'reactnative-dependencies-headers-debug', + 'reactnative-dependencies-headers-release', +]; + +async function verifyArtifactsAreOnMaven( + version: string, + retries: number = MAX_RETRIES, +): Promise { if (version.startsWith('v')) { version = version.substring(1); } - const artifactUrl = `${ARTIFACT_URL}${version}/${ARTIFACT_NAME}${version}.pom`; + const urls = [ + `${ARTIFACT_URL}${version}/${ARTIFACT_NAME}${version}.pom`, + ...ARTIFACT_CLASSIFIERS.map( + classifier => + `${ARTIFACT_URL}${version}/${ARTIFACT_NAME}${version}-${classifier}.tar.gz`, + ), + ]; for (let currentAttempt = 1; currentAttempt <= retries; currentAttempt++) { - const response = await fetch(artifactUrl); - - if (response.status !== 200) { - log( - `${currentAttempt}) Artifact's for version ${version} are not on maven yet.\nURL: ${artifactUrl}\nLet's wait a minute and try again.\n`, - ); - await sleep(SLEEP_S); - } else { + let missingUrl = null; + for (const url of urls) { + try { + const response = await fetch(url, {method: 'HEAD'}); + if (response.status === 200) { + continue; + } + log(`Got status ${response.status} while checking ${url}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Network error while checking ${url}: ${message}`); + missingUrl = url; + break; + } + missingUrl = url; + break; + } + + if (missingUrl == null) { return; } + log( + `${currentAttempt}) Artifact's for version ${version} are not on maven yet.\nURL: ${missingUrl}\nLet's wait a minute and try again.\n`, + ); + await sleep(SLEEP_S); } log( diff --git a/.github/workflows/prebuild-ios-core.yml b/.github/workflows/prebuild-ios-core.yml index 6809e7ffceff..36b2e13aada2 100644 --- a/.github/workflows/prebuild-ios-core.yml +++ b/.github/workflows/prebuild-ios-core.yml @@ -133,7 +133,7 @@ jobs: uses: actions/cache/restore@v5 with: path: packages/react-native/.build/output/xcframeworks - key: v2-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: 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/**/*') }} - name: Setup node.js if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' uses: ./.github/actions/setup-node @@ -167,9 +167,11 @@ jobs: if: steps.restore-ios-xcframework.outputs.cache-hit != 'true' shell: bash run: | - # ReactNativeHeaders.xcframework (built by the compose step) folds in - # the third-party deps namespaces (folly/glog/boost/...), so the deps - # headers must be staged here too — not just in build-slices. + # ReactNativeHeaders.xcframework is pure-RN (the deps namespaces ship + # in the ReactNativeDependenciesHeaders sidecar built by the deps + # prebuild), but the headers-verify compile gates still need the deps + # headers on their include path (folly/glog/... reached from RN's + # public headers), so the deps artifact is staged here too. 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 @@ -212,6 +214,15 @@ 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: | + # The compose step already tars ReactNativeHeaders.xcframework standalone; + # published as its own Maven artifact (classifier reactnative-headers-*) + # so SwiftPM consumers can wire it as a separate binaryTarget. It also + # ships inside the ReactCore tarball for the CocoaPods pod. + 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 XCFramework Artifact uses: actions/upload-artifact@v6 with: @@ -222,6 +233,11 @@ jobs: with: name: ReactCore${{ matrix.flavor }}.framework.dSYM.tar.gz path: packages/react-native/.build/output/xcframeworks/ReactCore${{matrix.flavor}}.framework.dSYM.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: Save cache if present if: ${{ github.ref == 'refs/heads/main' }} # To avoid that the cache explode uses: actions/cache/save@v5 @@ -229,4 +245,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: v2-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: 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/**/*') }} diff --git a/.github/workflows/prebuild-ios-dependencies.yml b/.github/workflows/prebuild-ios-dependencies.yml index ab9322da3dd8..b34fcf203eb7 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: v3-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }} + 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') }} # 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' @@ -164,7 +164,13 @@ jobs: if: steps.restore-xcframework.outputs.cache-hit != 'true' run: | 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/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: Show Symbol folder content if: steps.restore-xcframework.outputs.cache-hit != 'true' run: ls -lR packages/react-native/third-party/Symbols @@ -179,6 +185,11 @@ jobs: with: name: ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz path: packages/react-native/third-party/ReactNativeDependencies${{ matrix.flavor }}.xcframework.tar.gz + - 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: Upload dSYM Artifact uses: actions/upload-artifact@v6 with: @@ -191,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: v3-ios-dependencies-xcframework-${{ matrix.flavor }}-${{ hashfiles('scripts/releases/ios-prebuild/configuration.js') }} + 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') }} diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index ef4e0a50029f..b908d0f0061a 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -155,6 +155,12 @@ jobs: uses: ./.github/actions/test-ios-rntester with: use-frameworks: ${{ matrix.frameworks }} + # Consume the prebuilt artifacts in the dynamic-frameworks cells too: + # prebuilt + use_frameworks is the config of the 2026-07-03 + # SocketRocket dual-copy regression, previously covered by no lane + # (source-built dynamic frameworks stay covered by + # test_ios_rntester_dynamic_frameworks). + use-prebuilds: true flavor: ${{ matrix.flavor }} test_e2e_ios_rntester: diff --git a/packages/react-native/React-Core-prebuilt.podspec b/packages/react-native/React-Core-prebuilt.podspec index 36d191278a08..bc838fafef3a 100644 --- a/packages/react-native/React-Core-prebuilt.podspec +++ b/packages/react-native/React-Core-prebuilt.podspec @@ -22,11 +22,15 @@ Pod::Spec.new do |s| # - React.xcframework: the compiled core. Its per-slice React.framework carries # every header + the framework module map, so `#import ` # and `@import React;` resolve through FRAMEWORK_SEARCH_PATHS automatically. - # - ReactNativeHeaders.xcframework: headers-only. Carries every other namespace - # (, , folly, glog, ...). Its headers are flattened into a - # top-level Headers/ (see prepare_command) and exposed via the standard pod - # header search path. ( is supplied by the hermes-engine pod here; - # it is folded into ReactNativeHeaders only on the SwiftPM consumer side.) + # - ReactNativeHeaders.xcframework: headers-only, PURE-RN. Carries every other + # RN namespace (, , ...). Its headers are flattened into + # a top-level Headers/ (see prepare_command) and exposed via the standard pod + # header search path. The third-party deps namespaces (folly/glog/boost/...) + # are NOT here — the ReactNativeDependencies pod serves them from its own + # artifact (see scripts/cocoapods/__docs__/prebuilt-deps.md), wired through + # add_rn_third_party_dependencies below. ( is supplied by the + # hermes-engine pod here; it is folded into ReactNativeHeaders only on the + # SwiftPM consumer side.) # There is no clang VFS overlay. s.vendored_frameworks = "React.xcframework" diff --git a/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts b/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts index 1ed2851218df..48a676d31c64 100644 --- a/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts +++ b/packages/react-native/ReactAndroid/external-artifacts/build.gradle.kts @@ -53,6 +53,30 @@ val reactNativeDependenciesReleaseDSYMArtifact: PublishArtifact = classifier = "reactnative-dependencies-dSYM-release" } +// [iOS] React Native Dependencies Headers — the headers-only LIBRARY-type +// sidecar (per-slice Headers/ + HeadersPath) that SwiftPM auto-serves; the +// binary xcframework above is framework-type and cannot expose headers to +// SwiftPM binaryTargets. Also shipped INSIDE the deps tarball for CocoaPods. +val reactNativeDependenciesHeadersDebugArtifactFile: RegularFile = + layout.projectDirectory.file("artifacts/ReactNativeDependenciesHeadersDebug.xcframework.tar.gz") +val reactNativeDependenciesHeadersDebugArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeDependenciesHeadersDebugArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-dependencies-headers-debug" + } + +val reactNativeDependenciesHeadersReleaseArtifactFile: RegularFile = + layout.projectDirectory.file( + "artifacts/ReactNativeDependenciesHeadersRelease.xcframework.tar.gz" + ) +val reactNativeDependenciesHeadersReleaseArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeDependenciesHeadersReleaseArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-dependencies-headers-release" + } + // [iOS] React Native Core val reactCoreDebugArtifactFile: RegularFile = layout.projectDirectory.file("artifacts/ReactCoreDebug.xcframework.tar.gz") @@ -89,6 +113,27 @@ val reactCoreReleaseDSYMArtifact: PublishArtifact = classifier = "reactnative-core-dSYM-release" } +// [iOS] React Native Headers — the pure-RN headers-only xcframework, published +// standalone (it also ships inside the ReactCore tarball for CocoaPods) so +// SwiftPM consumers can wire it as its own binaryTarget. +val reactNativeHeadersDebugArtifactFile: RegularFile = + layout.projectDirectory.file("artifacts/ReactNativeHeadersDebug.xcframework.tar.gz") +val reactNativeHeadersDebugArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeHeadersDebugArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-headers-debug" + } + +val reactNativeHeadersReleaseArtifactFile: RegularFile = + layout.projectDirectory.file("artifacts/ReactNativeHeadersRelease.xcframework.tar.gz") +val reactNativeHeadersReleaseArtifact: PublishArtifact = + artifacts.add("externalArtifacts", reactNativeHeadersReleaseArtifactFile) { + type = "tgz" + extension = "tar.gz" + classifier = "reactnative-headers-release" + } + apply(from = "../publish.gradle") publishing { @@ -99,10 +144,14 @@ publishing { artifact(reactNativeDependenciesReleaseArtifact) artifact(reactNativeDependenciesDebugDSYMArtifact) artifact(reactNativeDependenciesReleaseDSYMArtifact) + artifact(reactNativeDependenciesHeadersDebugArtifact) + artifact(reactNativeDependenciesHeadersReleaseArtifact) artifact(reactCoreDebugArtifact) artifact(reactCoreReleaseArtifact) artifact(reactCoreDebugDSYMArtifact) artifact(reactCoreReleaseDSYMArtifact) + artifact(reactNativeHeadersDebugArtifact) + artifact(reactNativeHeadersReleaseArtifact) } } } diff --git a/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md index cfd171dae828..5d4970f00d6a 100644 --- a/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md +++ b/packages/react-native/scripts/cocoapods/__docs__/prebuilt-deps.md @@ -13,17 +13,21 @@ xcframework binary, and the artifact's own `Headers/{folly,glog,boost,fmt,double-conversion,fast_float,SocketRocket}` are flattened into the pod's `Headers/` by the podspec's `prepare_command`. Consumers resolve bare `` / `` via CocoaPods -public-header linkage from `s.dependency "ReactNativeDependencies"`, plus an -explicit `HEADER_SEARCH_PATHS` entry -(`$(PODS_ROOT)/ReactNativeDependencies/Headers`). The real source pods are -neither depended on nor searched. +public-header linkage from `s.dependency "ReactNativeDependencies"`, plus +`HEADER_SEARCH_PATHS` entries pointing at +`$(PODS_ROOT)/ReactNativeDependencies/Headers`: per-podspec via +`add_rn_third_party_dependencies`, and globally (aggregate + every pod target) +via `ReactNativeDependenciesUtils.configure_aggregate_xcconfig` at post-install +— ReactNativeHeaders is pure-RN, so this is the only global home of the deps +namespaces. The real source pods are neither depended on nor searched. -NOTE: this is a CocoaPods-level contract. The deps XCFRAMEWORK itself is NOT -self-serving: it is framework-type without `HeadersPath`, so its root `Headers/` -is invisible to SPM binaryTargets (verified 2026-07-04 — `HeadersPath` is -rejected on framework entries). In SPM the six C++ namespaces are served by -ReactNativeHeaders.xcframework; serving them from the deps side requires the -phase-2 headers-only library sidecar (see rn-deps-self-serving plan). +For SPM, the deps XCFRAMEWORK itself cannot serve headers: it is framework-type +without `HeadersPath`, and its root `Headers/` is invisible to SPM binaryTargets +(verified 2026-07-04 — `HeadersPath` is rejected on framework entries). The deps +prebuild therefore emits a headers-only LIBRARY-type sidecar, +`ReactNativeDependenciesHeaders.xcframework` (same recipe as ReactNativeHeaders: +stub archives + per-slice `Headers/`), which SPM auto-serves with zero flags. +The sidecar ships inside the deps tarball and as a standalone artifact. ## Why SocketRocket is vended here diff --git a/packages/react-native/scripts/cocoapods/rncore.rb b/packages/react-native/scripts/cocoapods/rncore.rb index 1ff33b69c135..00b9327871e4 100644 --- a/packages/react-native/scripts/cocoapods/rncore.rb +++ b/packages/react-native/scripts/cocoapods/rncore.rb @@ -513,7 +513,9 @@ def self.get_nightly_npm_version() # only declares the React-Core-prebuilt dependency; it no longer touches xcconfigs.) # # `` resolves through the vendored React.framework; this adds the search - # path to the flattened ReactNativeHeaders headers (every other namespace). There is + # path to the flattened ReactNativeHeaders headers (every other RN namespace — + # the third-party deps namespaces are served by the ReactNativeDependencies pod, + # see ReactNativeDependenciesUtils.configure_aggregate_xcconfig). There is # no clang VFS overlay. # # Parameters: diff --git a/packages/react-native/scripts/cocoapods/rndependencies.rb b/packages/react-native/scripts/cocoapods/rndependencies.rb index 483dfa92c9e9..a88fa6e483f9 100644 --- a/packages/react-native/scripts/cocoapods/rndependencies.rb +++ b/packages/react-native/scripts/cocoapods/rndependencies.rb @@ -154,6 +154,52 @@ def self.abort_if_use_local_rndeps_with_no_file() end end + # Single post-install injection site for the prebuilt deps header resolution. + # Adds the flattened ReactNativeDependencies/Headers search path to the + # aggregate (main app) target AND every pod target, mirroring + # ReactNativeCoreUtils.configure_aggregate_xcconfig. ReactNativeHeaders is + # pure-RN, so this path is the single global home of the third-party + # namespaces (folly/glog/boost/fmt/double-conversion/fast_float/ + # SocketRocket): pods that never call add_rn_third_party_dependencies (nor + # depend on a facade) still compile RN headers that textually reach + # . No module-map activation needed — the deps headers are + # served textually; modules come from the ReactNativeDependencies pod. + def self.configure_aggregate_xcconfig(installer) + return if @@build_from_source + + rndeps_log("Configuring xcconfig for prebuilt React Native Dependencies...") + headers_search_path = " \"$(PODS_ROOT)/ReactNativeDependencies/Headers\"" + + # Add the header search path to aggregate target xcconfigs (used by the main app target) + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.xcconfigs.each do |config_name, config_file| + ReactNativePodsUtils.add_flag_to_map_with_inheritance(config_file.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig_path = aggregate_target.xcconfig_path(config_name) + config_file.save_as(xcconfig_path) + end + end + + # Add the header search path to ALL pod targets (for pods that don't go + # through add_rn_third_party_dependencies) + installer.pod_targets.each do |pod_target| + pod_target.build_settings.each do |config_name, build_settings| + xcconfig_path = pod_target.xcconfig_path(config_name) + next unless File.exist?(xcconfig_path) + + xcconfig = Xcodeproj::Config.new(xcconfig_path) + + # Skip if the deps header search path is already present + header_search_paths = xcconfig.attributes["HEADER_SEARCH_PATHS"] || "" + next if header_search_paths.include?("ReactNativeDependencies/Headers") + + ReactNativePodsUtils.add_flag_to_map_with_inheritance(xcconfig.attributes, "HEADER_SEARCH_PATHS", headers_search_path) + xcconfig.save_as(xcconfig_path) + end + end + + rndeps_log("Prebuilt deps xcconfig configuration complete") + end + def self.podspec_source_download_prebuild_release_tarball() # Warn if @@react_native_path is not set if @@react_native_path == "" diff --git a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md index 6b58995f1696..3c4de65e246a 100644 --- a/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md +++ b/packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md @@ -356,9 +356,14 @@ These classes previously failed late (consumer CI lane) or not at all; the gate Proof the gate earns its keep — its FIRST run found two real shipping defects: the dual-identity redefinitions that became R11, and an undeclared `SocketRocket` namespace in the deps artifact (caught by the set-equality guard -the moment it was added). SocketRocket is deliberately NOT relocated -(`DEPS_NAMESPACES_NOT_RELOCATED`): the real pod vends it, and textual copies -collide with it under `use_frameworks` — the gate asserts its absence. +the moment it was added). SocketRocket now lives in `DEPS_NAMESPACES` alongside +the other third-party deps namespaces, with a single physical home in the +ReactNativeDependenciesHeaders sidecar: relocating a second textual copy into +ReactNativeHeaders collided with the real pod's own headers under +`use_frameworks` (the duplicate-`@interface` / poisoned-module-graph Expo +regression, 2026-07-03), so the set-equality gate asserts the declared namespace +set — `DEPS_NAMESPACES` — matches the deps artifact's namespaces exactly, in +both directions. ### D. Remaining silent gaps — allowlist maintenance (by design) 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 new file mode 100644 index 000000000000..d36ebcb51468 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-compose-test.js @@ -0,0 +1,48 @@ +/** + * 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 { + COMPOSE_TOOLING_FILES, + composeToolingHash, +} = require('../headers-compose'); +const fs = require('fs'); +const path = require('path'); + +describe('COMPOSE_TOOLING_FILES stays in sync with headers-compose.js requires', () => { + test('every local sibling require is covered by the hashed file list', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', 'headers-compose.js'), + 'utf8', + ); + const requireRe = /require\('\.\/([\w-]+)'\)/g; + const required = new Set(); + for (const match of source.matchAll(requireRe)) { + required.add(`${match[1]}.js`); + } + for (const name of required) { + expect(COMPOSE_TOOLING_FILES).toContain(name); + } + const listWithoutSelf = COMPOSE_TOOLING_FILES.filter( + name => name !== 'headers-compose.js', + ); + for (const name of listWithoutSelf) { + expect(required).toContain(name); + } + }); +}); + +describe('composeToolingHash', () => { + test('returns a 64-char hex sha256 digest', () => { + const hash = composeToolingHash(); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js index b1b7d530577b..ffc4c826d6f8 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js @@ -11,6 +11,7 @@ 'use strict'; const { + DEPS_NAMESPACES, planFromInventory, renderNamespaceModuleMap, renderReactModuleMap, @@ -238,6 +239,22 @@ describe('R11 redirect shims for dual-identity headers', () => { }); }); +describe('DEPS_NAMESPACES (R2 — the deps sidecar namespace set)', () => { + test('includes SocketRocket: one physical home, in the sidecar', () => { + // Pre-sidecar, SocketRocket was excluded from relocation because a REAL + // pod vended it (the 2026-07-03 dual-copy regression). With the sidecar + // being the deps' single header home, it must be declared like every + // other deps namespace. + expect(DEPS_NAMESPACES).toContain('SocketRocket'); + }); + + test('plan.depsNamespaces mirrors the spec list', () => { + expect(planFromInventoryForTest(validManifest()).depsNamespaces).toEqual( + DEPS_NAMESPACES, + ); + }); +}); + describe('headers-verify gate pieces', () => { const { diffAgainstBaseline, 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 new file mode 100644 index 000000000000..522675feaf2d --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-xcframework-test.js @@ -0,0 +1,113 @@ +/** + * 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 { + buildDepsHeadersXcframework, + stubSlicesFromXcframework, +} = require('../headers-xcframework'); +const childProcess = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('buildDepsHeadersXcframework set-equality gate', () => { + let tmp /*: string */; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'deps-headers-test-')); + }); + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + const mkHeaders = (namespaces /*: Array */) => { + const dir = path.join(tmp, 'Headers'); + fs.mkdirSync(dir, {recursive: true}); + for (const ns of namespaces) { + fs.mkdirSync(path.join(dir, ns), {recursive: true}); + } + return dir; + }; + + // Both gates throw BEFORE any staging or xcodebuild invocation, so these + // tests run without macOS tooling. + test('fails closed when a declared namespace is missing from the artifact', () => { + const headers = mkHeaders(['folly']); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []), + ).toThrow(/missing from .*Headers: glog/); + }); + + test('fails closed when the artifact ships an undeclared namespace', () => { + const headers = mkHeaders(['folly', 'brand-new-dep']); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly'], []), + ).toThrow(/undeclared in DEPS_NAMESPACES.*brand-new-dep/); + }); + + test('ignores loose files at the Headers root (directories are the namespace set)', () => { + const headers = mkHeaders(['folly']); + fs.writeFileSync(path.join(headers, 'stray.h'), ''); + expect(() => + buildDepsHeadersXcframework(tmp, headers, ['folly', 'glog'], []), + ).toThrow(/missing from .*Headers: glog/); // throws for glog, not stray.h + }); +}); + +describe('stubSlicesFromXcframework', () => { + // The plist shape is a pure function of plutil's JSON; mock it so the + // SupportedPlatform/Variant -> key mapping and the unknown-slice guard can be + // tested without a real xcframework or macOS tooling. + const mockPlist = (obj /*: unknown */) => + jest + .spyOn(childProcess, 'execFileSync') + .mockReturnValue(Buffer.from(JSON.stringify(obj) ?? '', 'utf8')); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('maps ios / ios-simulator slices to their stub recipes', () => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'ios', SupportedArchitectures: ['arm64']}, + { + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + SupportedArchitectures: ['arm64', 'x86_64'], + }, + ], + }); + const slices = stubSlicesFromXcframework('/fake.xcframework'); + expect(slices).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', + ], + }, + ]); + }); + + test('throws for an unknown slice, pointing at PLATFORM_STUB_RECIPES', () => { + mockPlist({ + AvailableLibraries: [ + {SupportedPlatform: 'watchos', SupportedArchitectures: ['arm64']}, + ], + }); + expect(() => stubSlicesFromXcframework('/fake.xcframework')).toThrow( + /no stub recipe for slice 'watchos'[\s\S]*PLATFORM_STUB_RECIPES/, + ); + }); +}); diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js index a67e74216e29..60651c69dd29 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-compose.js +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -11,10 +11,13 @@ /** * Headers compose — emits the headers-spec layout (rules R1–R8 in * headers-spec.js) into a React.xcframework and builds the headers-only - * ReactNativeHeaders.xcframework beside it. The prebuild path (xcframework.js) - * composes before signing (R7); `ensureHeadersLayout()` applies the same - * emission to an already-cached artifact. One projector, spec-driven, - * byte-identical output either way. + * ReactNativeHeaders.xcframework beside it (pure-RN: the third-party deps + * namespaces ship in the ReactNativeDependenciesHeaders sidecar instead — + * see headers-xcframework.js). The prebuild path (xcframework.js) composes + * before signing (R7); `ensureHeadersLayout()` applies the same emission to + * an already-cached artifact and builds the deps sidecar from the slot's + * deps headers. One projector, spec-driven, byte-identical output either + * way. */ const { @@ -26,12 +29,17 @@ const { const {computeInventory} = require('./headers-inventory'); const { DEPS_NAMESPACES, - DEPS_NAMESPACES_NOT_RELOCATED, planFromInventory, renderNamespaceModuleMap, renderReactModuleMap, renderUmbrellaHeader, } = require('./headers-spec'); +const { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, +} = require('./headers-xcframework'); const {execFileSync} = require('child_process'); const crypto = require('crypto'); const fs = require('fs'); @@ -46,13 +54,23 @@ const CP_FLAGS = process.platform === 'darwin' ? '-Rc' : '-R'; // forces a recompose even when the cached source xcframework is untouched — // the Info.plist mtime alone can't catch that. Stable across fresh checkouts // (content-based, not mtime-based). sha256 to avoid weak-hash lint. +// +// This list must contain every local sibling module headers-compose.js +// requires (i.e. every `require('./*.js')` above) — the guard test in +// __tests__/headers-compose-test.js enforces that by parsing this file's +// source and diffing it against this list, so an added require without a +// matching entry here fails the test instead of silently going stale. +const COMPOSE_TOOLING_FILES /*: Array */ = [ + 'framework-resources.js', + 'headers-inventory.js', + 'headers-spec.js', + 'headers-xcframework.js', + 'headers-compose.js', +]; + function composeToolingHash() /*: string */ { const hash = crypto.createHash('sha256'); - for (const name of [ - 'headers-inventory.js', - 'headers-spec.js', - 'headers-compose.js', - ]) { + for (const name of COMPOSE_TOOLING_FILES) { hash.update(fs.readFileSync(path.join(__dirname, name))); } return hash.digest('hex'); @@ -202,95 +220,29 @@ function emitReactFrameworkHeaders( ); } -/*:: -type StubSlice = { - name: string, // human label - sdk: string, // xcrun --sdk name - targets: Array, // clang -target triples (lipo'd when > 1) -}; -*/ - -const DEFAULT_STUB_SLICES /*: Array */ = [ - {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, - { - name: 'ios-simulator', - sdk: 'iphonesimulator', - targets: [ - 'arm64-apple-ios15.0-simulator', - 'x86_64-apple-ios15.0-simulator', - ], - }, -]; - -// Mac Catalyst slice — used by the real compose (the cached-artifact -// repackage path skips it to stay fast; React.xcframework carries it). -const CATALYST_STUB_SLICE /*: StubSlice */ = { - name: 'mac-catalyst', - sdk: 'macosx', - targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], -}; - /** * Builds ReactNativeHeaders.xcframework (R2, R5): a headers-only LIBRARY * xcframework (stub static archives — nothing embeds in apps) whose Headers - * root carries every non-React namespace incl. the third-party deps - * namespaces, plus module.modulemap with the plain per-namespace modules. - * SPM serves its Headers automatically to dependents — no flags. + * root carries every non-React RN namespace, plus module.modulemap with the + * plain per-namespace modules. PURE-RN: the third-party deps namespaces ship + * in the ReactNativeDependenciesHeaders sidecar built by the deps prebuild + * (and by ensureHeadersLayout on the consumer side) — never here. SPM serves + * its Headers automatically to dependents — no flags. */ function buildReactNativeHeadersXcframework( outDir /*: string */, plan /*: HeadersSpecPlan */, - depsHeaders /*: string */, rnRoot /*: string */, includeCatalyst /*: boolean */ = false, // Optional dir containing a `hermes/` namespace (Hermes public headers from // the hermes-ios tarball's destroot/include). Folded in as a textual - // namespace like folly/glog so `` resolves without per-library - // wiring. null when unstaged — then `` stays unavailable. + // namespace so `` resolves without per-library wiring. null + // when unstaged — then `` stays unavailable. hermesHeaders /*: ?string */ = null, ) /*: string */ { // ---- stage headers ---- const stage = fs.mkdtempSync(path.join(outDir, '.rnh-stage-')); stageEntries(stage, plan.reactNativeHeaders, rnRoot); - for (const ns of plan.depsNamespaces) { - const src = path.join(depsHeaders, ns); - // Fail closed: a declared deps namespace (folly/glog/boost/...) missing - // from the staged ReactNativeDependencies headers means the artifact would - // ship WITHOUT those ``-style headers — a silently-broken - // ReactNativeHeaders.xcframework (consumers lose third-party header - // resolution). Refuse rather than emit it. Stage - // third-party/ReactNativeDependencies.xcframework/Headers (full prebuild or - // cache slot) before composing. - if (!fs.existsSync(src)) { - throw new Error( - `headers-compose: deps namespace '${ns}' missing under ${depsHeaders}. ` + - `ReactNativeDependencies headers are not staged — refusing to ship an ` + - `incomplete ReactNativeHeaders.xcframework.`, - ); - } - execFileSync('/bin/cp', [CP_FLAGS, src, path.join(stage, ns)]); - } - // Set equality with the deps artifact: a namespace dir present in the - // artifact but neither declared for relocation (DEPS_NAMESPACES) nor - // explicitly excluded (DEPS_NAMESPACES_NOT_RELOCATED — namespaces a real - // consumer pod vends itself, e.g. SocketRocket) means a new third-party dep - // was added upstream — fail closed so a decision is made deliberately. - const foundDepsDirs = fs - .readdirSync(depsHeaders, {withFileTypes: true}) - .filter(e => e.isDirectory()) - .map(e => String(e.name)); - const undeclared = foundDepsDirs.filter( - d => - !plan.depsNamespaces.includes(d) && - !DEPS_NAMESPACES_NOT_RELOCATED.includes(d), - ); - if (undeclared.length > 0) { - throw new Error( - `headers-compose: deps artifact ships undeclared namespace(s): ` + - `${undeclared.join(', ')}. Add them to DEPS_NAMESPACES (relocated) or ` + - `DEPS_NAMESPACES_NOT_RELOCATED (vended by a real pod) in headers-spec.js.`, - ); - } // 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 @@ -323,66 +275,21 @@ function buildReactNativeHeadersXcframework( renderNamespaceModuleMap(plan.namespaceModules), ); - // ---- stub static archives per slice ---- - const work = fs.mkdtempSync(path.join(outDir, '.stub-work-')); - fs.writeFileSync( - path.join(work, 'stub.c'), - '// ReactNativeHeaders is headers-only; this stub satisfies xcframework tooling.\nstatic int RNHeadersStub __attribute__((unused)) = 0;\n', - ); + // ---- compose (stub archives + create-xcframework) ---- const slices = includeCatalyst ? [...DEFAULT_STUB_SLICES, CATALYST_STUB_SLICE] : DEFAULT_STUB_SLICES; - const libs = slices.map(slice => { - const sdkPath = execFileSync('xcrun', [ - '--sdk', - slice.sdk, - '--show-sdk-path', - ]) - .toString() - .trim(); - const thins = slice.targets.map((t, i) => { - const obj = path.join(work, `stub-${slice.name}-${i}.o`); - execFileSync('xcrun', [ - 'clang', - '-c', - '-target', - t, - '-isysroot', - sdkPath, - path.join(work, 'stub.c'), - '-o', - obj, - ]); - const lib = path.join(work, `stub-${slice.name}-${i}.a`); - execFileSync('xcrun', ['libtool', '-static', '-o', lib, obj], { - stdio: ['ignore', 'pipe', 'ignore'], - }); - return lib; - }); - const outLib = path.join(work, `libReactNativeHeaders-${slice.name}.a`); - if (thins.length === 1) { - fs.copyFileSync(thins[0], outLib); - } else { - execFileSync('xcrun', ['lipo', '-create', ...thins, '-output', outLib]); - } - return outLib; - }); - - // ---- compose ---- - const outXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); - fs.rmSync(outXcfw, {recursive: true, force: true}); - const xcframeworkArgs = ['-create-xcframework']; - for (const l of libs) { - xcframeworkArgs.push('-library', l, '-headers', stage); - } - xcframeworkArgs.push('-output', outXcfw); - execFileSync('xcodebuild', xcframeworkArgs, {stdio: 'pipe'}); + const outXcfw = composeHeadersOnlyXcframework( + outDir, + 'ReactNativeHeaders', + stage, + slices, + ); fs.rmSync(stage, {recursive: true, force: true}); - fs.rmSync(work, {recursive: true, force: true}); console.log( `headers-compose: ReactNativeHeaders.xcframework (${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + - `(${plan.reactNativeHeaders.length} RN headers + deps ${plan.depsNamespaces.join(', ')}` + - `${hermesFolded ? ', hermes' : ''}; ` + + `(${plan.reactNativeHeaders.length} RN headers, pure-RN` + + `${hermesFolded ? ' + hermes' : ''}; ` + `${Object.keys(plan.namespaceModules).length} namespace modules)`, ); return outXcfw; @@ -392,20 +299,21 @@ function buildReactNativeHeadersXcframework( * Ensures the headers-spec layout exists at `outDir`, composed from the cache * slot's artifacts: clones React.xcframework (APFS clonefile), strips the * stale signature (R7 — production signs after compose), emits the spec - * layout into every slice, and builds ReactNativeHeaders.xcframework from - * the plan + the slot's deps headers. + * layout into every slice, builds the pure-RN ReactNativeHeaders.xcframework + * from the plan, and builds the ReactNativeDependenciesHeaders sidecar from + * the slot's deps headers. * * Skips when the freshness marker matches the source artifact (same * realpath + Info.plist mtime) unless `force`. Any consumer with a cache slot - * gets composed artifacts automatically — no published ReactNativeHeaders - * required. + * gets composed artifacts automatically — no published ReactNativeHeaders / + * ReactNativeDependenciesHeaders required. */ function ensureHeadersLayout( artifactsDir /*: string */, rnRoot /*: string */, outDir /*: string */, force /*: boolean */ = false, -) /*: {reactXcfw: string, headersXcfw: string} */ { +) /*: {reactXcfw: string, headersXcfw: string, depsHeadersXcfw: string} */ { const sourceXcfw = fs.realpathSync( path.join(artifactsDir, 'React.xcframework'), ); @@ -424,6 +332,10 @@ function ensureHeadersLayout( : null; const reactXcfw = path.join(outDir, 'React.xcframework'); const headersXcfw = path.join(outDir, 'ReactNativeHeaders.xcframework'); + const depsHeadersXcfw = path.join( + outDir, + 'ReactNativeDependenciesHeaders.xcframework', + ); const markerPath = path.join(outDir, '.composed-from'); const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist')); @@ -437,10 +349,11 @@ function ensureHeadersLayout( !force && fs.existsSync(reactXcfw) && fs.existsSync(headersXcfw) && + fs.existsSync(depsHeadersXcfw) && fs.existsSync(markerPath) && fs.readFileSync(markerPath, 'utf8') === marker ) { - return {reactXcfw, headersXcfw}; + return {reactXcfw, headersXcfw, depsHeadersXcfw}; } console.log( @@ -460,13 +373,20 @@ function ensureHeadersLayout( buildReactNativeHeadersXcframework( outDir, plan, - depsHeaders, rnRoot, false, hermesHeaders, ); + // The deps sidecar (like ReactNativeHeaders here) skips the catalyst slice + // on the consumer repackage path to stay fast. + buildDepsHeadersXcframework( + outDir, + depsHeaders, + plan.depsNamespaces, + DEFAULT_STUB_SLICES, + ); fs.writeFileSync(markerPath, marker); - return {reactXcfw, headersXcfw}; + return {reactXcfw, headersXcfw, depsHeadersXcfw}; } module.exports = { @@ -475,4 +395,6 @@ module.exports = { buildReactNativeHeadersXcframework, ensureHeadersLayout, DEPS_NAMESPACES, + COMPOSE_TOOLING_FILES, + composeToolingHash, }; diff --git a/packages/react-native/scripts/ios-prebuild/headers-inventory.js b/packages/react-native/scripts/ios-prebuild/headers-inventory.js index a1bfe6757b06..4b514c35286c 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-inventory.js +++ b/packages/react-native/scripts/ios-prebuild/headers-inventory.js @@ -31,10 +31,7 @@ const {getHeaderFilesFromPodspecs} = require('./headers'); // headers-spec.js requires only fs/path, so this cannot cycle. -const { - DEPS_NAMESPACES, - DEPS_NAMESPACES_NOT_RELOCATED, -} = require('./headers-spec'); +const {DEPS_NAMESPACES} = require('./headers-spec'); const fs = require('fs'); const path = require('path'); @@ -77,13 +74,10 @@ type HeaderEntry = { */ // Third-party C++ libraries that RN's public headers re-expose (Tier 3 of the // modularization doc). Keyed by the first include-path segment. Single source -// of truth: the spec's DEPS_NAMESPACES (the namespaces relocated into -// ReactNativeHeaders) — a new third-party dep is declared ONCE and both the -// include classifier and the compose step follow. -const THIRD_PARTY_LIBS /*: Set */ = new Set([ - ...DEPS_NAMESPACES, - ...DEPS_NAMESPACES_NOT_RELOCATED, -]); +// of truth: the spec's DEPS_NAMESPACES (the ReactNativeDependenciesHeaders +// sidecar's namespace set) — a new third-party dep is declared ONCE and the +// include classifier, the sidecar emitter, and the headers gate all follow. +const THIRD_PARTY_LIBS /*: Set */ = new Set(DEPS_NAMESPACES); // Apple SDK / platform include roots (first path segment). Includes resolving // here are "system": always modular or always available, never our problem. diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js index c7f7371dc49c..ae22c53341f0 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-spec.js +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -25,12 +25,14 @@ * would require case-folding `react.framework` → `React.framework`, which * only works on case-insensitive filesystems; the header-search-path route * is exact and works everywhere. - * R2. Every other namespace (incl. `react/`) ships in ONE headers-only library - * xcframework ("ReactNativeHeaders"), namespace dirs at its Headers root, - * INCLUDING the third-party deps namespaces (folly/glog/boost/fmt/ - * double-conversion/fast_float, sourced from the deps artifact) — making - * ReactNativeDependencies binary-only. Served by exact header-search-path - * lookup, so resolution is filesystem-case-independent. + * R2. Every other RN namespace (incl. `react/`) ships in ONE headers-only + * library xcframework ("ReactNativeHeaders"), namespace dirs at its + * Headers root. PURE-RN: the third-party deps namespaces (DEPS_NAMESPACES) + * are NOT here — they ship in the ReactNativeDependenciesHeaders sidecar + * built by the deps prebuild from its own artifact headers, so every + * namespace has exactly ONE physical home (the SocketRocket dual-copy + * regression class is structurally impossible). Both are served by exact + * header-search-path lookup, so resolution is filesystem-case-independent. * R3. NO include rewriting anywhere — source headers are byte-identical to * the repo (content authority = source files; layout authority = this * spec). Consumers compile unchanged except bare-form angle includes @@ -116,10 +118,15 @@ export type HeadersSpecPlan = { }; */ -// R2: third-party namespaces relocated from the deps artifact. Kept in exact -// sync with the artifact's Headers/ dirs — compose fails closed on a missing -// OR an undeclared namespace, and the include classifier -// (headers-inventory.js THIRD_PARTY_LIBS) derives from this same list. +// R2: the third-party deps namespaces — the exact contents of the +// ReactNativeDependenciesHeaders sidecar, and the exact set of namespace dirs +// the deps artifact's Headers/ ships. The sidecar emitter fails closed on a +// missing OR an undeclared namespace (set equality), the headers gate asserts +// these stay ABSENT from ReactNativeHeaders (one physical home per +// namespace — relocated copies collided with real pods' own headers: the +// SocketRocket duplicate-@interface / poisoned-module-graph Expo regression, +// 2026-07-03), and the include classifier (headers-inventory.js +// THIRD_PARTY_LIBS) derives from this same list. const DEPS_NAMESPACES = [ 'folly', 'glog', @@ -127,20 +134,9 @@ const DEPS_NAMESPACES = [ 'fmt', 'double-conversion', 'fast_float', + 'SocketRocket', ]; -// Namespaces the deps artifact ships but which must NOT be relocated into -// ReactNativeHeaders: a REAL CocoaPods pod of the same name exists in consumer -// graphs and vends these headers itself (with its own module). Relocating -// textual copies puts them on every pod's HEADER_SEARCH_PATHS via the -// flattened React-Core-prebuilt/Headers and collides with the pod's own -// headers — duplicate @interface definitions compiling the SocketRocket pod, -// and a poisoned module graph in use_frameworks/explicit-modules apps -// (Expo regression, 2026-07-03). The compose set-equality guard treats these -// as declared (so only genuinely NEW namespaces fail closed), and the headers -// gate asserts they stay ABSENT from the artifact. -const DEPS_NAMESPACES_NOT_RELOCATED = ['SocketRocket']; - // R4/R5 umbrella exclusion: C extern-inline definitions. const EXTERN_INLINE_RE /*: RegExp */ = /\b(RCT_EXTERN\s+inline|extern\s+inline)\b/; @@ -476,7 +472,6 @@ function renderNamespaceModuleMap( module.exports = { planFromInventory, - DEPS_NAMESPACES_NOT_RELOCATED, renderReactModuleMap, renderUmbrellaHeader, renderNamespaceModuleMap, diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index a18143bb6262..79bebc800348 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -39,7 +39,6 @@ const {computeInventory} = require('./headers-inventory'); const { - DEPS_NAMESPACES_NOT_RELOCATED, planFromInventory, renderNamespaceModuleMap, renderReactModuleMap, @@ -227,18 +226,16 @@ function verifyStructural( `R10 umbrella ${u.relPath}`, ); } + // ReactNativeHeaders is PURE-RN: every deps namespace must stay ABSENT. + // They ship in the ReactNativeDependenciesHeaders sidecar (one physical + // home per namespace) — relocated copies collide with the real pods' own + // headers (SocketRocket / Expo use_frameworks regression, 2026-07-03). for (const ns of plan.depsNamespaces) { - if (!fs.existsSync(path.join(rnhHeaders, ns))) { - throw new Error(`deps namespace missing from artifact: ${ns}`); - } - } - // Excluded namespaces must stay ABSENT: relocating them collides with the - // real pod's own headers (SocketRocket / Expo use_frameworks regression). - for (const ns of DEPS_NAMESPACES_NOT_RELOCATED) { if (fs.existsSync(path.join(rnhHeaders, ns))) { throw new Error( - `excluded deps namespace '${ns}' found in the artifact — it must NOT ` + - `be relocated (a real pod vends it; textual copies collide).`, + `deps namespace '${ns}' found in ReactNativeHeaders — it must NOT be ` + + `relocated (it ships in ReactNativeDependenciesHeaders; textual ` + + `copies collide with the real pods' headers).`, ); } } @@ -319,6 +316,7 @@ function runCompileGates( plan /*: HeadersSpecPlan */, reactSlice /*: string */, rnhHeaders /*: string */, + depsHeaders /*: string */, ) /*: void */ { const sdk = execFileSync( 'xcrun', @@ -327,6 +325,10 @@ function runCompileGates( ).trim(); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'headers-verify-')); try { + // ReactNativeHeaders is pure-RN, so the deps headers (folly/glog/... — + // reached textually from RN's public headers) come from the deps + // artifact's Headers dir, exactly as consumers get them from the + // ReactNativeDependencies pod / ReactNativeDependenciesHeaders sidecar. const common = [ '-fsyntax-only', '-target', @@ -337,6 +339,8 @@ function runCompileGates( reactSlice, '-I', rnhHeaders, + '-I', + depsHeaders, ]; const objc = path.join(tmp, 'gate-modules.m'); @@ -394,6 +398,8 @@ function runCompileGates( reactSlice, '-I', rnhHeaders, + '-I', + depsHeaders, swift, ], 'Swift gate (import React + RCTBridge.moduleRegistry)', @@ -459,7 +465,23 @@ function main(argv /*:: ?: Array */) /*: void */ { if (args.skipCompile) { log('compile gates skipped (--skip-compile).'); } else { - runCompileGates(plan, reactSlice, rnhHeaders); + // ReactNativeHeaders is pure-RN — the compile gates additionally need the + // deps headers, served from the staged deps artifact (the same content + // the ReactNativeDependenciesHeaders sidecar ships). + const depsHeaders = path.join( + RN_ROOT, + 'third-party', + 'ReactNativeDependencies.xcframework', + 'Headers', + ); + if (!fs.existsSync(depsHeaders)) { + throw new Error( + `deps headers missing at ${depsHeaders} — stage the ` + + `ReactNativeDependencies artifact before running the compile gates ` + + `(or pass --skip-compile).`, + ); + } + runCompileGates(plan, reactSlice, rnhHeaders, depsHeaders); } log('ALL GATES PASSED.'); } diff --git a/packages/react-native/scripts/ios-prebuild/headers-xcframework.js b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js new file mode 100644 index 000000000000..2cdd85487f50 --- /dev/null +++ b/packages/react-native/scripts/ios-prebuild/headers-xcframework.js @@ -0,0 +1,282 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * Headers-only xcframework emitter — the shared recipe behind + * ReactNativeHeaders.xcframework (headers-compose.js) and the + * ReactNativeDependenciesHeaders.xcframework sidecar (the deps prebuild's + * compose-framework.js). A headers-only artifact is a LIBRARY-type + * xcframework: stub static archives (nothing embeds in apps) paired with a + * staged Headers dir. The per-slice Headers/ layout and the Info.plist + * `HeadersPath` key — what makes SwiftPM auto-serve the headers with zero + * flags — are produced by `xcodebuild -create-xcframework -library ... + * -headers ...` itself and are never hand-written (framework-type entries + * hard-reject `HeadersPath`; verified 2026-07-04). + * + * This module must stay dependency-light (fs/path/child_process only): the + * deps prebuild under scripts/releases requires it across the package + * boundary. + */ + +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// APFS clonefile (-c) is a macOS-only cp flag; plain -R elsewhere (Linux CI +// exercises these paths through the jest integration tests). +const CP_FLAGS = process.platform === 'darwin' ? '-Rc' : '-R'; + +/*:: +export type StubSlice = { + name: string, // human label + sdk: string, // xcrun --sdk name + targets: Array, // clang -target triples (lipo'd when > 1) +}; +*/ + +const DEFAULT_STUB_SLICES /*: Array */ = [ + {name: 'ios', sdk: 'iphoneos', targets: ['arm64-apple-ios15.0']}, + { + name: 'ios-simulator', + sdk: 'iphonesimulator', + targets: [ + 'arm64-apple-ios15.0-simulator', + 'x86_64-apple-ios15.0-simulator', + ], + }, +]; + +// Mac Catalyst slice — used by the real compose (the cached-artifact +// repackage path skips it to stay fast; React.xcframework carries it). +const CATALYST_STUB_SLICE /*: StubSlice */ = { + name: 'mac-catalyst', + sdk: 'macosx', + targets: ['arm64-apple-ios15.0-macabi', 'x86_64-apple-ios15.0-macabi'], +}; + +// 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 +// + archs. +const PLATFORM_STUB_RECIPES /*: { + [key: string]: {sdk: string, os: string, suffix: string}, +} */ = { + ios: {sdk: 'iphoneos', os: 'ios15.0', suffix: ''}, + 'ios-simulator': { + sdk: 'iphonesimulator', + os: 'ios15.0', + suffix: '-simulator', + }, + 'ios-maccatalyst': {sdk: 'macosx', os: 'ios15.0', suffix: '-macabi'}, + macos: {sdk: 'macosx', os: 'macosx11.0', suffix: ''}, + tvos: {sdk: 'appletvos', os: 'tvos15.1', suffix: ''}, + 'tvos-simulator': { + sdk: 'appletvsimulator', + os: 'tvos15.1', + suffix: '-simulator', + }, + xros: {sdk: 'xros', os: 'xros1.0', suffix: ''}, + 'xros-simulator': {sdk: 'xrsimulator', os: 'xros1.0', suffix: '-simulator'}, +}; + +/** + * Derives stub slices matching an existing (binary) xcframework's slice set, + * so a headers-only sidecar resolves for every platform the binary does. + */ +function stubSlicesFromXcframework( + xcfwPath /*: string */, +) /*: Array */ { + let plist; + try { + plist = JSON.parse( + execFileSync('plutil', [ + '-convert', + 'json', + '-o', + '-', + path.join(xcfwPath, 'Info.plist'), + ]).toString(), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `headers-xcframework: failed to parse Info.plist of ${xcfwPath}: ${message}`, + ); + } + return plist.AvailableLibraries.map(lib => { + const key = + lib.SupportedPlatformVariant != null + ? `${lib.SupportedPlatform}-${lib.SupportedPlatformVariant}` + : lib.SupportedPlatform; + const recipe = PLATFORM_STUB_RECIPES[key]; + if (recipe == null) { + throw new Error( + `headers-xcframework: no stub recipe for slice '${key}' of ` + + `${xcfwPath}. Add it to PLATFORM_STUB_RECIPES.`, + ); + } + return { + name: key, + sdk: recipe.sdk, + targets: lib.SupportedArchitectures.map( + a => `${a}-apple-${recipe.os}${recipe.suffix}`, + ), + }; + }); +} + +/** + * Composes `.xcframework` under `outDir` from an already-populated + * Headers stage dir: one stub static archive per slice, then + * `xcodebuild -create-xcframework` pairing every archive with the stage. + * The caller owns (and cleans) the stage dir. + */ +function composeHeadersOnlyXcframework( + outDir /*: string */, + name /*: string */, + stage /*: string */, + slices /*: Array */, +) /*: string */ { + 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. + try { + fs.writeFileSync( + path.join(work, 'stub.c'), + `// ${name} is headers-only; this stub satisfies xcframework tooling.\n` + + `static int ${name}Stub __attribute__((unused)) = 0;\n`, + ); + const libs = slices.map(slice => { + const sdkPath = execFileSync('xcrun', [ + '--sdk', + slice.sdk, + '--show-sdk-path', + ]) + .toString() + .trim(); + const thins = slice.targets.map((t, i) => { + const obj = path.join(work, `stub-${slice.name}-${i}.o`); + execFileSync('xcrun', [ + 'clang', + '-c', + '-target', + t, + '-isysroot', + sdkPath, + path.join(work, 'stub.c'), + '-o', + obj, + ]); + const lib = path.join(work, `stub-${slice.name}-${i}.a`); + execFileSync('xcrun', ['libtool', '-static', '-o', lib, obj], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + return lib; + }); + const outLib = path.join(work, `lib${name}-${slice.name}.a`); + if (thins.length === 1) { + fs.copyFileSync(thins[0], outLib); + } else { + execFileSync('xcrun', ['lipo', '-create', ...thins, '-output', outLib]); + } + return outLib; + }); + + const outXcfw = path.join(outDir, `${name}.xcframework`); + fs.rmSync(outXcfw, {recursive: true, force: true}); + const xcframeworkArgs = ['-create-xcframework']; + for (const l of libs) { + xcframeworkArgs.push('-library', l, '-headers', stage); + } + xcframeworkArgs.push('-output', outXcfw); + execFileSync('xcodebuild', xcframeworkArgs, {stdio: 'pipe'}); + return outXcfw; + } finally { + fs.rmSync(work, {recursive: true, force: true}); + } +} + +const DEPS_HEADERS_XCFRAMEWORK_NAME = 'ReactNativeDependenciesHeaders'; + +/** + * Builds ReactNativeDependenciesHeaders.xcframework: the headers-only sidecar + * serving the third-party deps namespaces (folly/glog/boost/fmt/ + * double-conversion/fast_float/SocketRocket). The binary + * ReactNativeDependencies.xcframework is FRAMEWORK-type, so its root Headers/ + * dir is invisible to SwiftPM — this LIBRARY-type sidecar is what makes the + * deps headers auto-served, keeping ReactNativeHeaders pure-RN. + * + * Set-equality with `namespaces` (headers-spec.js DEPS_NAMESPACES) is + * enforced fail-closed in BOTH directions: a declared namespace missing from + * `depsHeaders` would ship a silently-broken sidecar; an undeclared dir means + * a new third-party dep was added without a spec decision. + */ +function buildDepsHeadersXcframework( + outDir /*: string */, + depsHeaders /*: string */, + namespaces /*: Array */, + slices /*: Array */, +) /*: string */ { + const found = fs + .readdirSync(depsHeaders, {withFileTypes: true}) + .filter(e => e.isDirectory()) + .map(e => String(e.name)); + const missing = namespaces.filter(ns => !found.includes(ns)); + const undeclared = found.filter(d => !namespaces.includes(d)); + if (missing.length > 0 || undeclared.length > 0) { + throw new Error( + `headers-xcframework: deps namespaces out of sync with the spec.\n` + + (missing.length > 0 + ? ` missing from ${depsHeaders}: ${missing.join(', ')}\n` + : '') + + (undeclared.length > 0 + ? ` undeclared in DEPS_NAMESPACES (headers-spec.js): ${undeclared.join(', ')}\n` + : '') + + `Declare new deps deliberately — the sidecar and the spec must agree.`, + ); + } + + const stage = fs.mkdtempSync(path.join(outDir, '.deps-headers-stage-')); + // try/finally so a cp/compose failure doesn't leave the .deps-headers-stage-* + // dir behind in outDir (= third-party/ for the deps path). + let outXcfw; + try { + for (const ns of namespaces) { + execFileSync('/bin/cp', [ + CP_FLAGS, + path.join(depsHeaders, ns), + path.join(stage, ns), + ]); + } + outXcfw = composeHeadersOnlyXcframework( + outDir, + DEPS_HEADERS_XCFRAMEWORK_NAME, + stage, + slices, + ); + } finally { + fs.rmSync(stage, {recursive: true, force: true}); + } + console.log( + `headers-xcframework: ${DEPS_HEADERS_XCFRAMEWORK_NAME}.xcframework ` + + `(${slices.map(s => s.name).join(', ')}) -> ${outXcfw} ` + + `(namespaces: ${namespaces.join(', ')})`, + ); + return outXcfw; +} + +module.exports = { + CATALYST_STUB_SLICE, + DEFAULT_STUB_SLICES, + DEPS_HEADERS_XCFRAMEWORK_NAME, + buildDepsHeadersXcframework, + composeHeadersOnlyXcframework, + stubSlicesFromXcframework, +}; diff --git a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js index 24cba8083234..92ca219a68cf 100644 --- a/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js +++ b/packages/react-native/scripts/ios-prebuild/reactNativeDependencies.js @@ -92,6 +92,36 @@ async function prepareReactNativeDependenciesArtifactsAsync( stdio: 'inherit', }); + // The headers-only ReactNativeDependenciesHeaders.xcframework sidecar ships + // alongside the binary in the same tarball — it is what serves the deps + // namespaces to SwiftPM (the binary is framework-type; its root Headers/ is + // invisible to binaryTargets). Absent only in pre-sidecar tarballs (pinned + // RN_DEP_VERSION): CocoaPods still works (the pod flattens the binary's + // root Headers/), SwiftPM consumers regain it via ensureHeadersLayout. + // + // Slice coverage: this tarball sidecar carries ALL slices the deps prebuild + // produced. The ensureHeadersLayout fallback (headers-compose.js) rebuilds it + // from DEFAULT_STUB_SLICES (ios + ios-simulator only) — sufficient for a + // SwiftPM iOS build, which is the only supported SPM target today. The two + // paths therefore agree on the iOS slices; the fallback simply omits the + // extra slices (catalyst/tvos/...) that no SPM consumer needs yet. Headers + // are slice-uniform, so a consumer never sees divergent content per slice. + const headersSidecarSource = path.join( + path.dirname(xcframeworkSource), + 'ReactNativeDependenciesHeaders.xcframework', + ); + if (fs.existsSync(headersSidecarSource)) { + execSync(`cp -R "${headersSidecarSource}" "${artifactsPath}"`, { + stdio: 'inherit', + }); + } else { + dependencyLog( + 'ReactNativeDependenciesHeaders.xcframework not present in the tarball ' + + '(pre-sidecar artifact) — continuing with the binary xcframework only.', + 'warning', + ); + } + // Delete the tarball after extraction if (!process.env.HERMES_ENGINE_TARBALL_PATH) { fs.unlinkSync(localPath); diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index 347d0942fbba..f370ec768d47 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -86,22 +86,19 @@ function buildXCFrameworks( computeSpecPlan, emitReactFrameworkHeaders, } = require('./headers-compose'); - const depsHeaders = path.join( - rootFolder, - 'third-party', - 'ReactNativeDependencies.xcframework', - 'Headers', - ); const plan = computeSpecPlan(rootFolder); emitReactFrameworkHeaders(outputPath, plan, rootFolder); + // 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 + // deps artifact's headers. // NOTE: Hermes public headers (``) are folded into // ReactNativeHeaders on the consumer side by ensureHeadersLayout. When this // publish path is productionized, pass the prebuild's hermes destroot/include - // as the 6th arg so the PUBLISHED ReactNativeHeaders carries hermes too. + // as the 5th arg so the PUBLISHED ReactNativeHeaders carries hermes too. const headersXcfw = buildReactNativeHeadersXcframework( path.dirname(outputPath), plan, - depsHeaders, rootFolder, true, // include the mac-catalyst slice in the real compose ); diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 4214e23b20da..3b6d3142b38e 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -621,6 +621,15 @@ def react_native_post_install( ReactNativeCoreUtils.configure_aggregate_xcconfig(installer) end + if !ReactNativeDependenciesUtils.build_react_native_deps_from_source() + # Prebuilt-deps mode: make the deps artifact headers (folly/glog/...) + # resolvable from the aggregate and every pod target, mirroring the rncore + # injection above. ReactNativeHeaders is pure-RN, so the flattened + # ReactNativeDependencies/Headers dir is the single global home of the + # third-party namespaces (see scripts/cocoapods/__docs__/prebuilt-deps.md). + ReactNativeDependenciesUtils.configure_aggregate_xcconfig(installer) + end + SPM.apply_on_post_install(installer) if privacy_file_aggregation_enabled diff --git a/scripts/releases/ios-prebuild/compose-framework.js b/scripts/releases/ios-prebuild/compose-framework.js index fd0e1287d1f3..12f026148550 100644 --- a/scripts/releases/ios-prebuild/compose-framework.js +++ b/scripts/releases/ios-prebuild/compose-framework.js @@ -8,6 +8,15 @@ * @format */ +// Shared with the core prebuild (kept dependency-light for this cross-package +// require): the headers-only xcframework recipe and the deps namespace spec. +const { + DEPS_NAMESPACES, +} = require('../../../packages/react-native/scripts/ios-prebuild/headers-spec'); +const { + buildDepsHeadersXcframework, + stubSlicesFromXcframework, +} = require('../../../packages/react-native/scripts/ios-prebuild/headers-xcframework'); const {HEADERS_FOLDER, TARGET_FOLDER} = require('./constants'); const {execSync} = require('child_process'); const fs = require('fs'); @@ -70,8 +79,23 @@ async function createFramework( // Copy headers to the framework - start by building the Header folder copyHeaders(scheme, dependencies, rootFolder); + // Emit the headers-only ReactNativeDependenciesHeaders.xcframework sidecar + // from the root Headers/ just assembled. The binary xcframework is + // FRAMEWORK-type, so its root Headers/ is invisible to SwiftPM + // (`HeadersPath` is rejected on framework entries) — the LIBRARY-type + // sidecar is what auto-serves the deps namespaces. Root Headers/ is + // slice-uniform (copied once after create-xcframework), so the sidecar + // stages it per slice, with slice parity derived from the binary artifact. + const headersXcfw = buildDepsHeadersXcframework( + rootFolder, + path.join(output, 'Headers'), + DEPS_NAMESPACES, + stubSlicesFromXcframework(output), + ); + if (identity) { signXCFramework(identity, output); + signXCFramework(identity, headersXcfw); } } From 47fad096a4f89adf8f10121556d942e9cc06a8dd Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Wed, 15 Jul 2026 05:08:52 -0700 Subject: [PATCH 071/834] feat(iOS) swiftpm support in iOS (#57332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: ⚠️ This is a stacked PR — review/merge the base PRs first Builds on, and should land after (bottom-up): 1. https://github.com/react/react-native/issues/57285 — Remove the Clang VFS overlay / modularize React headers (base of the stack). via the framework module map; lowercase namespaces (react/, yoga/, jsi/, …) via the ReactNativeHeaders module map. 2. https://github.com/react/react-native/issues/57305 — Prebuilt artifact resources + ReactNativeHeaders.xcframework. Ships the prebuilt core artifacts (incl. ReactNativeHeaders in the tarball + embedded React.framework resources) and the CocoaPods React-Core-prebuilt facades. 3. https://github.com/react/react-native/issues/57440 — Self-serving prebuilt ReactNativeDependencies + dependency-only facades. The deps pod becomes the single header authority for the third-party namespaces (RCT-Folly/glog/boost/…); community `s.dependency "RCT-Folly"` resolves to local facades instead of trunk source pods. 4. https://github.com/react/react-native/issues/57442 — ReactNativeDependenciesHeaders sidecar + pure-RN ReactNativeHeaders (immediate base). The deps prebuild emits a headers-only LIBRARY-type sidecar (the binary deps xcframework is framework-type — invisible to SwiftPM binaryTargets), ReactNativeHeaders drops the third-party namespaces, and both headers artifacts publish standalone to Maven. The whole stack is rebased onto current main (2026-07-06). This PR consumes the final **five-artifact set**: React, ReactNativeHeaders (pure-RN), ReactNativeDependencies, ReactNativeDependenciesHeaders, hermes-engine. Adds npx react-native spm and the package-generation tooling that turns an app into a SwiftPM-integrated RN app using the base stack's prebuilt XCFrameworks. CocoaPods stays supported — additive, opt-in, no Ruby toolchain. Integration is injected into the existing .xcodeproj in place (nothing generated/renamed/replaced), recorded in .spm-injected.json so it reverses exactly. ### What this PR adds - The spm CLI (add/update/deinit/scaffold + hidden sync/codegen/download), zero-arg auto-resolution, --deintegrate for CocoaPods→SwiftPM. - SwiftPM package generation (scripts/spm/): autolinking→Package.swift, Codegen→React-GeneratedCode, core XCFramework binary targets, artifact download/cache, surgical pbxproj inject/remove. - ReactNativeDependenciesHeaders wired as the 5th binaryTarget: the headers-only companions (ReactNativeHeaders from the core tarball, the deps sidecar from the deps tarball) are staged automatically out of their parent tarballs — no --headers-tarball priming; REQUIRED_ARTIFACTS covers all five. - Community-library scaffolding from podspecs (incl. root-level-source podspecs via generated include// shims, sibling wiring for transitive spm.dependencies, self-ingestion guards). - In-place Xcode integration (XCLocalSwiftPackageReference + Sync build phase + scheme pre-action + auto-sync; ${PODS_ROOT}-anchored REACT_NATIVE_PATH replaced on --deintegrate with exact deinit rollback). - rn-tester + helloworld SwiftPM consumption + test library; npm-packaging hygiene; a small RNCoreFacades.podspec_dir fix. ## Documentation - **Tool docs** (usage, quick start, architecture): [`scripts/spm/__doc__/spm-scripts.md`](https://github.com/react/react-native/blob/chrfalch/swift-package-manager/packages/react-native/scripts/spm/__doc__/spm-scripts.md) - **Header-paths contract** (how the five artifacts serve headers): [`scripts/spm/__doc__/spm-header-paths-contract.md`](https://github.com/react/react-native/blob/chrfalch/swift-package-manager/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md) - **RFC** (updated): [react-native-community/discussions-and-proposals#994](https://github.com/react-native-community/discussions-and-proposals/pull/994) ## Changelog: [IOS] [ADDED] - `npx react-native spm` command + SwiftPM package-generation tooling (opt-in; CocoaPods stays supported) Pull Request resolved: https://github.com/react/react-native/pull/57332 Test Plan: 350 scripts/spm unit tests (incl. byte-identical add→deinit round-trip); manual E2E against the five-artifact set: fresh app via `cli init`→`spm add --deintegrate`→build (artifact resources verified in the app), rn-tester in-place migration (RNTesterPods.xcodeproj, test libraries + spmModules), helloworld in-place migration — all BUILD SUCCEEDED. Verified `npx react-native spm ` from the command line in each journey. ## Scope & limitations iOS, prebuilt-only (no build-from-source yet); full spm.xcframework/spm.source library metadata not yet (app-local spm.modules + scaffolding are); Expo not yet. ## Follow-ups Remote-mode ReactNative Package.swift must vend the ReactNativeDependenciesHeaders product (spm-distribution repo); library self-containment for repo-portable manifests; build-from-source. Reviewed By: mdvacca Differential Revision: D111449548 Pulled By: cipolleschi fbshipit-source-id: 87a873bf5c8be2d60f7893611e16924bfa730a46 --- package.json | 1 + packages/react-native-test-library/.gitignore | 11 + .../apple/TestLibraryApple.h | 11 + .../apple/TestLibraryApple.mm | 27 + .../apple/TestLibraryApple.podspec | 29 + .../apple/__tests__/TestLibraryAppleTests.cpp | 12 + .../apple/index.d.ts | 10 + .../react-native-test-library/apple/index.js | 28 + .../apple/package.json | 31 + .../apple/react-native.config.js | 22 + .../common/TestLibraryCommon.h | 15 + .../common/TestLibraryCommon.mm | 24 + .../common/TestLibraryCommon.podspec | 23 + .../common/index.d.ts | 14 + .../react-native-test-library/common/index.js | 34 + .../common/package.json | 24 + .../common/react-native.config.js | 19 + packages/react-native/package.json | 2 + packages/react-native/react-native.config.js | 81 + .../templates/Package.swift.spm-template | 97 + .../scripts/ios-prebuild/xcframework.js | 22 +- .../scripts/react-native-xcode.sh | 20 + .../react-native/scripts/setup-apple-spm.js | 1165 +++++++++ .../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 | 451 ++++ .../__tests__/__fixtures__/plain-app.pbxproj | 171 ++ .../spm/__tests__/autolinking-plugins-test.js | 343 +++ .../__tests__/download-spm-artifacts-test.js | 788 ++++++ .../__tests__/expand-spm-dependencies-test.js | 393 +++ .../flavored-frameworks-plist-dep-test.js | 102 + .../spm/__tests__/flavored-frameworks-test.js | 439 ++++ .../generate-spm-autolinking-config-test.js | 259 ++ .../generate-spm-autolinking-test.js | 1415 +++++++++++ .../__tests__/generate-spm-package-test.js | 282 +++ .../__tests__/generate-spm-xcodeproj-test.js | 228 ++ .../__tests__/inject-spm-xcodeproj-test.js | 444 ++++ .../spm/__tests__/read-podspec-test.js | 487 ++++ .../__tests__/remove-spm-injection-test.js | 395 +++ .../__tests__/scaffold-package-swift-test.js | 1138 +++++++++ .../spm/__tests__/setup-apple-spm-test.js | 292 +++ .../scripts/spm/__tests__/spm-pbxproj-test.js | 516 ++++ .../scripts/spm/__tests__/spm-utils-test.js | 621 +++++ .../spm/__tests__/swift-tools-version-test.js | 97 + .../__tests__/sync-spm-autolinking-test.js | 118 + .../scripts/spm/autolinking-plugins.js | 331 +++ .../scripts/spm/download-spm-artifacts.js | 1409 +++++++++++ .../scripts/spm/expand-spm-dependencies.js | 216 ++ .../scripts/spm/flavored-frameworks.js | 1008 ++++++++ .../spm/generate-spm-autolinking-config.js | 161 ++ .../scripts/spm/generate-spm-autolinking.js | 1888 ++++++++++++++ .../scripts/spm/generate-spm-package.js | 302 +++ .../scripts/spm/generate-spm-xcodeproj.js | 2224 +++++++++++++++++ .../react-native/scripts/spm/read-podspec.js | 695 ++++++ .../scripts/spm/scaffold-package-swift.js | 1206 +++++++++ .../react-native/scripts/spm/spm-pbxproj.js | 654 +++++ .../react-native/scripts/spm/spm-types.js | 517 ++++ .../react-native/scripts/spm/spm-utils.js | 645 +++++ .../scripts/spm/sync-spm-autolinking.js | 160 ++ packages/rn-tester/RNTester/AppDelegate.h | 9 + .../TestLibrary/TestLibraryExample.ios.js | 102 + .../rn-tester/js/utils/RNTesterList.ios.js | 4 + packages/rn-tester/metro.config.js | 2 + packages/rn-tester/package.json | 4 +- packages/rn-tester/react-native.config.js | 28 + 67 files changed, 23437 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-test-library/.gitignore create mode 100644 packages/react-native-test-library/apple/TestLibraryApple.h create mode 100644 packages/react-native-test-library/apple/TestLibraryApple.mm create mode 100644 packages/react-native-test-library/apple/TestLibraryApple.podspec create mode 100644 packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp create mode 100644 packages/react-native-test-library/apple/index.d.ts create mode 100644 packages/react-native-test-library/apple/index.js create mode 100644 packages/react-native-test-library/apple/package.json create mode 100644 packages/react-native-test-library/apple/react-native.config.js create mode 100644 packages/react-native-test-library/common/TestLibraryCommon.h create mode 100644 packages/react-native-test-library/common/TestLibraryCommon.mm create mode 100644 packages/react-native-test-library/common/TestLibraryCommon.podspec create mode 100644 packages/react-native-test-library/common/index.d.ts create mode 100644 packages/react-native-test-library/common/index.js create mode 100644 packages/react-native-test-library/common/package.json create mode 100644 packages/react-native-test-library/common/react-native.config.js create mode 100644 packages/react-native/scripts/codegen/templates/Package.swift.spm-template create mode 100644 packages/react-native/scripts/setup-apple-spm.js create mode 100644 packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md create mode 100644 packages/react-native/scripts/spm/__doc__/spm-scripts.md create mode 100644 packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj create mode 100644 packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/read-podspec-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/spm-utils-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js create mode 100644 packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js create mode 100644 packages/react-native/scripts/spm/autolinking-plugins.js create mode 100644 packages/react-native/scripts/spm/download-spm-artifacts.js create mode 100644 packages/react-native/scripts/spm/expand-spm-dependencies.js create mode 100644 packages/react-native/scripts/spm/flavored-frameworks.js create mode 100644 packages/react-native/scripts/spm/generate-spm-autolinking-config.js create mode 100644 packages/react-native/scripts/spm/generate-spm-autolinking.js create mode 100644 packages/react-native/scripts/spm/generate-spm-package.js create mode 100644 packages/react-native/scripts/spm/generate-spm-xcodeproj.js create mode 100644 packages/react-native/scripts/spm/read-podspec.js create mode 100644 packages/react-native/scripts/spm/scaffold-package-swift.js create mode 100644 packages/react-native/scripts/spm/spm-pbxproj.js create mode 100644 packages/react-native/scripts/spm/spm-types.js create mode 100644 packages/react-native/scripts/spm/spm-utils.js create mode 100644 packages/react-native/scripts/spm/sync-spm-autolinking.js create mode 100644 packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js diff --git a/package.json b/package.json index b073df203c47..11d3afeb2505 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ }, "workspaces": [ "packages/*", + "packages/react-native-test-library/*", "private/*", "!private/helloworld" ], diff --git a/packages/react-native-test-library/.gitignore b/packages/react-native-test-library/.gitignore new file mode 100644 index 000000000000..531ed06a6fec --- /dev/null +++ b/packages/react-native-test-library/.gitignore @@ -0,0 +1,11 @@ +# Generated SPM artifacts (written by setup-ios-spm.js's earlier in-place +# layout). The current autolinker emits these under the consumer app's +# build/generated/autolinking/ tree instead, so any copy that lands here is +# stale and should not be committed. +Package.swift +Package.resolved +include/ + +# SwiftPM caches +.build/ +.swiftpm/ diff --git a/packages/react-native-test-library/apple/TestLibraryApple.h b/packages/react-native-test-library/apple/TestLibraryApple.h new file mode 100644 index 000000000000..fe19590fdc6d --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.h @@ -0,0 +1,11 @@ +/* + * 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. + */ + +#import + +@interface TestLibraryApple : NSObject +@end diff --git a/packages/react-native-test-library/apple/TestLibraryApple.mm b/packages/react-native-test-library/apple/TestLibraryApple.mm new file mode 100644 index 000000000000..2fa7f1fcefb1 --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.mm @@ -0,0 +1,27 @@ +/* + * 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. + */ + +#import "TestLibraryApple.h" + +// Synth library products are emitted as .library(type: .dynamic, ...), so SPM +// wraps each autolinked dep as a Foo.framework under PackageFrameworks/. That +// gives angle-bracket imports the standard resolution path, +// matching how most React Native libraries already organize their headers. +#import + +@implementation TestLibraryApple + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD( + echo : (NSString *)message resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + NSString *prefix = [TestLibraryCommon defaultPrefix]; + resolve([NSString stringWithFormat:@"%@apple: %@", prefix, message]); +} + +@end diff --git a/packages/react-native-test-library/apple/TestLibraryApple.podspec b/packages/react-native-test-library/apple/TestLibraryApple.podspec new file mode 100644 index 000000000000..02cef6573b26 --- /dev/null +++ b/packages/react-native-test-library/apple/TestLibraryApple.podspec @@ -0,0 +1,29 @@ +# 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 "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "TestLibraryApple" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://github.com/facebook/react-native" + s.license = "MIT" + s.platforms = min_supported_versions + s.author = "Meta Platforms, Inc. and its affiliates" + s.source = { :git => "https://github.com/facebook/react-native.git", :tag => "#{s.version}" } + s.source_files = "*.{h,m,mm,swift}" + s.requires_arc = true + + # TestLibraryApple.mm imports . + # CocoaPods resolves it leniently through the shared Public headers dir, but the + # dependency edge must be declared for SwiftPM (the scaffolder wires sibling + # packages from podspec dependencies). + s.dependency "TestLibraryCommon" + + install_modules_dependencies(s) +end diff --git a/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp b/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp new file mode 100644 index 000000000000..a9c5b63cca3d --- /dev/null +++ b/packages/react-native-test-library/apple/__tests__/TestLibraryAppleTests.cpp @@ -0,0 +1,12 @@ +/* + * 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. + */ + +#include + +static_assert( + false, + "TestLibraryAppleTests.cpp must not be compiled by the SPM autolinker"); diff --git a/packages/react-native-test-library/apple/index.d.ts b/packages/react-native-test-library/apple/index.d.ts new file mode 100644 index 000000000000..9c911b2d99b7 --- /dev/null +++ b/packages/react-native-test-library/apple/index.d.ts @@ -0,0 +1,10 @@ +/** + * 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. + */ + +import type {Greeting} from '../common'; + +export function greet(g: Greeting): Promise; diff --git a/packages/react-native-test-library/apple/index.js b/packages/react-native-test-library/apple/index.js new file mode 100644 index 000000000000..028d0e9450fa --- /dev/null +++ b/packages/react-native-test-library/apple/index.js @@ -0,0 +1,28 @@ +/** + * 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'; + +import type {Greeting} from '../common'; + +import {formatGreeting} from '../common'; +import {NativeModules, Platform} from 'react-native'; + +export function greet(g: Greeting): Promise { + const TestLibraryApple = NativeModules.TestLibraryApple; + if (TestLibraryApple == null) { + return Promise.reject( + new Error( + `react-native-test-library-apple: native module unavailable on ${Platform.OS}. This package is iOS-only; install a platform-specific sibling (e.g. react-native-test-library-android) for cross-platform coverage.`, + ), + ); + } + return TestLibraryApple.echo(formatGreeting(g)); +} diff --git a/packages/react-native-test-library/apple/package.json b/packages/react-native-test-library/apple/package.json new file mode 100644 index 000000000000..8dda511cbefa --- /dev/null +++ b/packages/react-native-test-library/apple/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-native-test-library-apple", + "version": "0.87.0-main", + "description": "Apple platform implementation for the React Native autolinking fixture. Depends on react-native-test-library-common; used to validate iOS/macOS autolinking discovery and transitive native dependency resolution.", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "files": [ + "index.js", + "index.d.ts", + "react-native.config.js", + "TestLibraryApple.podspec", + "TestLibraryApple.h", + "TestLibraryApple.mm" + ], + "keywords": [ + "react-native", + "fixture", + "autolinking", + "ios", + "macos" + ], + "dependencies": { + "react-native-test-library-common": "0.87.0-main" + }, + "peerDependencies": { + "react": "*", + "react-native": "1000.0.0" + } +} diff --git a/packages/react-native-test-library/apple/react-native.config.js b/packages/react-native-test-library/apple/react-native.config.js new file mode 100644 index 000000000000..982ed353de95 --- /dev/null +++ b/packages/react-native-test-library/apple/react-native.config.js @@ -0,0 +1,22 @@ +/** + * 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'; + +module.exports = { + dependency: { + platforms: { + ios: {}, + }, + }, + spm: { + dependencies: ['react-native-test-library-common'], + }, +}; diff --git a/packages/react-native-test-library/common/TestLibraryCommon.h b/packages/react-native-test-library/common/TestLibraryCommon.h new file mode 100644 index 000000000000..a84f31084b51 --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.h @@ -0,0 +1,15 @@ +/* + * 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. + */ + +#import + +@interface TestLibraryCommon : NSObject + +/** Shared prefix used by other test-library packages that depend on common. */ ++ (NSString *)defaultPrefix; + +@end diff --git a/packages/react-native-test-library/common/TestLibraryCommon.mm b/packages/react-native-test-library/common/TestLibraryCommon.mm new file mode 100644 index 000000000000..19ced66cde8c --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.mm @@ -0,0 +1,24 @@ +/* + * 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. + */ + +#import "TestLibraryCommon.h" + +@implementation TestLibraryCommon + +RCT_EXPORT_MODULE() + ++ (NSString *)defaultPrefix +{ + return @"[common] "; +} + +RCT_EXPORT_METHOD(version : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + resolve(@"common@0.87.0-main"); +} + +@end diff --git a/packages/react-native-test-library/common/TestLibraryCommon.podspec b/packages/react-native-test-library/common/TestLibraryCommon.podspec new file mode 100644 index 000000000000..4218183fb657 --- /dev/null +++ b/packages/react-native-test-library/common/TestLibraryCommon.podspec @@ -0,0 +1,23 @@ +# 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 "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "TestLibraryCommon" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://github.com/facebook/react-native" + s.license = "MIT" + s.platforms = min_supported_versions + s.author = "Meta Platforms, Inc. and its affiliates" + s.source = { :git => "https://github.com/facebook/react-native.git", :tag => "#{s.version}" } + s.source_files = "*.{h,m,mm,swift}" + s.requires_arc = true + + install_modules_dependencies(s) +end diff --git a/packages/react-native-test-library/common/index.d.ts b/packages/react-native-test-library/common/index.d.ts new file mode 100644 index 000000000000..c765deb0688f --- /dev/null +++ b/packages/react-native-test-library/common/index.d.ts @@ -0,0 +1,14 @@ +/** + * 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. + */ + +export type Greeting = Readonly<{ + name: string; + language: string; +}>; + +export function formatGreeting(g: Greeting): string; +export function getVersion(): Promise; diff --git a/packages/react-native-test-library/common/index.js b/packages/react-native-test-library/common/index.js new file mode 100644 index 000000000000..71eeefcf1ba2 --- /dev/null +++ b/packages/react-native-test-library/common/index.js @@ -0,0 +1,34 @@ +/** + * 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'; + +import {NativeModules, Platform} from 'react-native'; + +export type Greeting = Readonly<{ + name: string, + language: string, +}>; + +export function formatGreeting(g: Greeting): string { + return `[${g.language}] Hello, ${g.name}!`; +} + +export function getVersion(): Promise { + const TestLibraryCommon = NativeModules.TestLibraryCommon; + if (TestLibraryCommon == null) { + return Promise.reject( + new Error( + `react-native-test-library-common: native module unavailable on ${Platform.OS}.`, + ), + ); + } + return TestLibraryCommon.version(); +} diff --git a/packages/react-native-test-library/common/package.json b/packages/react-native-test-library/common/package.json new file mode 100644 index 000000000000..0a911df6a303 --- /dev/null +++ b/packages/react-native-test-library/common/package.json @@ -0,0 +1,24 @@ +{ + "name": "react-native-test-library-common", + "version": "0.87.0-main", + "description": "Shared JS utilities consumed by react-native-test-library-apple. Used as a fixture for validating autolinking discovery and transitive native dependency resolution in the React Native monorepo.", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "license": "MIT", + "files": [ + "index.js", + "index.d.ts", + "react-native.config.js", + "TestLibraryCommon.podspec", + "TestLibraryCommon.h", + "TestLibraryCommon.mm" + ], + "keywords": [ + "react-native", + "fixture", + "autolinking", + "ios", + "macos" + ] +} diff --git a/packages/react-native-test-library/common/react-native.config.js b/packages/react-native-test-library/common/react-native.config.js new file mode 100644 index 000000000000..3de9a9829be7 --- /dev/null +++ b/packages/react-native-test-library/common/react-native.config.js @@ -0,0 +1,19 @@ +/** + * 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'; + +module.exports = { + dependency: { + platforms: { + ios: {}, + }, + }, +}; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index b1abfed6201f..21a28b378269 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -112,6 +112,8 @@ "scripts/react_native_pods_utils/script_phases.sh", "scripts/react_native_pods.rb", "scripts/react-native-xcode.sh", + "scripts/setup-apple-spm.js", + "scripts/spm", "scripts/xcode/ccache-clang.sh", "scripts/xcode/ccache-clang++.sh", "scripts/xcode/ccache.conf", diff --git a/packages/react-native/react-native.config.js b/packages/react-native/react-native.config.js index 713290231165..44fb2a25229b 100644 --- a/packages/react-native/react-native.config.js +++ b/packages/react-native/react-native.config.js @@ -112,6 +112,87 @@ const codegenCommand /*: Command */ = { commands.push(codegenCommand); +const spmCommand /*: Command */ = { + name: 'spm [action]', + description: + 'Set up or maintain Swift Package Manager support for the iOS/macOS app. ' + + 'Actions: add, update, deinit, scaffold. With no action: add (or update ' + + 'if SPM is already set up).', + options: [ + { + name: '--version ', + description: + 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json.', + }, + { + name: '--yes', + description: 'Skip the dirty-pbxproj confirmation prompt.', + }, + { + name: '--xcodeproj ', + description: + '[add] Path to the .xcodeproj to inject SPM packages into ' + + '(disambiguates when several exist).', + }, + { + name: '--productName ', + description: + '[add] App target to inject into (disambiguates when several exist).', + }, + { + name: '--deintegrate', + description: + '[add] Run `pod deintegrate` and strip React Native from the Podfile ' + + 'before injecting (CocoaPods → SwiftPM migration).', + }, + { + name: '--artifacts ', + description: + '[advanced] Local artifact root containing complete debug/ and release/ slots.', + }, + { + name: '--download ', + description: + '[advanced] Artifact download policy: auto (default), skip, or force.', + }, + { + name: '--skipCodegen', + description: '[advanced] Skip the react-native codegen step.', + }, + ], + func: async (argv, _config, args) => { + const passthrough /*: Array */ = []; + if (argv[0] != null) { + passthrough.push(argv[0]); + } + const stringOpts /*: Array<[string, string]> */ = [ + ['version', '--version'], + ['productName', '--product-name'], + ['xcodeproj', '--xcodeproj'], + ['artifacts', '--artifacts'], + ['download', '--download'], + ]; + for (const [key, flag] of stringOpts) { + if (args[key] != null) { + passthrough.push(flag, String(args[key])); + } + } + const boolOpts /*: Array<[string, string]> */ = [ + ['skipCodegen', '--skip-codegen'], + ['deintegrate', '--deintegrate'], + ['yes', '--yes'], + ]; + for (const [key, flag] of boolOpts) { + if (args[key]) { + passthrough.push(flag); + } + } + await require('./scripts/setup-apple-spm').main(passthrough); + }, +}; + +commands.push(spmCommand); + const config = { commands, platforms: {} /*:: as {[string]: Readonly<{ diff --git a/packages/react-native/scripts/codegen/templates/Package.swift.spm-template b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template new file mode 100644 index 000000000000..97cdb78fd54f --- /dev/null +++ b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template @@ -0,0 +1,97 @@ +// swift-tools-version: 6.0 +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// AUTO-GENERATED by scripts/setup-apple-spm.js – do not edit manually. +// This SPM-specific template replaces the CocoaPods codegen template +// with xcframework configuration built in. + +import PackageDescription + +// React headers need NO search paths: the React module comes from the +// invariant ReactHeaders Clang target, +// every other RN namespace from the ReactNativeHeaders binaryTarget +// (pure-RN), the third-party deps namespaces (folly/glog/boost/...) from the +// ReactNativeDependenciesHeaders sidecar, and the per-app generated headers +// from the ReactAppHeaders target below — all auto-served by SPM through +// product/target dependencies. +let headersDep: [Target.Dependency] = [ + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + "ReactAppHeaders", +] + +let package = Package( + name: "React-GeneratedCode", + platforms: [.iOS(.v15), .macCatalyst(SupportedPlatform.MacCatalystVersion.v13)], + products: [ + .library( + name: "ReactCodegen", + targets: ["ReactCodegen"]), + .library( + name: "ReactAppDependencyProvider", + targets: ["ReactAppDependencyProvider"]), + .library( + name: "ReactAppHeaders", + targets: ["ReactAppHeaders"]), + ], + dependencies: [ + .package(name: "ReactNative", path: "../../xcframeworks"), + ], + targets: [ + .target( + name: "ReactCodegen", + dependencies: headersDep, + path: "ReactCodegen", + exclude: ["ReactCodegen.podspec"], + publicHeadersPath: ".", + cSettings: [.headerSearchPath("headers")], + // DEBUG/NDEBUG match the prebuilt React.framework's config-gated C++ + // ABI: NDEBUG (Release) strips DebugStringConvertible's vtable and + // shifts the ShadowNode layout that codegen'd Props inherit, so this + // target must compile with the same NDEBUG state or the Release link + // fails (undefined "vtable for DebugStringConvertible"). Mirrors + // packages/react-native/Package.swift. + cxxSettings: [ + .headerSearchPath("headers"), + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ], + linkerSettings: [ + .linkedFramework("Foundation") + ] + ), + .target( + name: "ReactAppDependencyProvider", + dependencies: ["ReactCodegen"] + headersDep, + path: "ReactAppDependencyProvider", + exclude: ["ReactAppDependencyProvider.podspec"], + publicHeadersPath: ".", + cSettings: [.headerSearchPath(".."), .headerSearchPath("headers")], + cxxSettings: [ + .headerSearchPath(".."), + .headerSearchPath("headers"), + // Match the prebuilt React.framework NDEBUG-gated C++ ABI (see above). + .define("DEBUG", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ], + linkerSettings: [ + .linkedFramework("Foundation") + ] + ), + // The per-app generated-headers farm (built by spm sync), vended as a + // normal SPM headers target. + .target( + name: "ReactAppHeaders", + path: "ReactAppHeaders", + publicHeadersPath: "." + ), + ], + // React Native headers require C++20 (concepts, std::optional, etc.) + cxxLanguageStandard: .cxx20 +) diff --git a/packages/react-native/scripts/ios-prebuild/xcframework.js b/packages/react-native/scripts/ios-prebuild/xcframework.js index f370ec768d47..700d460bcd2e 100644 --- a/packages/react-native/scripts/ios-prebuild/xcframework.js +++ b/packages/react-native/scripts/ios-prebuild/xcframework.js @@ -92,15 +92,29 @@ function buildXCFrameworks( // the ReactNativeDependenciesHeaders sidecar built by the deps prebuild // (scripts/releases/ios-prebuild), so the core compose no longer needs the // deps artifact's headers. - // NOTE: Hermes public headers (``) are folded into - // ReactNativeHeaders on the consumer side by ensureHeadersLayout. When this - // publish path is productionized, pass the prebuild's hermes destroot/include - // as the 5th arg so the PUBLISHED ReactNativeHeaders carries hermes too. + // Fold the Hermes public headers (``) into the PUBLISHED + // 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; const headersXcfw = buildReactNativeHeadersXcframework( path.dirname(outputPath), plan, rootFolder, true, // include the mac-catalyst slice in the real compose + hermesHeaders, ); if (identity) { diff --git a/packages/react-native/scripts/react-native-xcode.sh b/packages/react-native/scripts/react-native-xcode.sh index 1dcdf55e1902..f54a970f0318 100755 --- a/packages/react-native/scripts/react-native-xcode.sh +++ b/packages/react-native/scripts/react-native-xcode.sh @@ -80,6 +80,26 @@ source "$REACT_NATIVE_DIR/scripts/node-binary.sh" HERMES_ENGINE_PATH="$PODS_ROOT/hermes-engine" [ -z "$HERMES_CLI_PATH" ] && HERMES_CLI_PATH="$HERMES_ENGINE_PATH/destroot/bin/hermesc" +# SwiftPM consumers have no Pods directory to provide hermesc. When there is +# no Pods installation at all and the current HERMES_CLI_PATH does not exist +# (unset, or an injector-baked path gone stale after a package-store move), +# resolve the hermes-compiler npm package THROUGH react-native's own dependency +# graph — react-native pins the hermes-compiler version, so the compiler's +# bytecode version always matches the prebuilt hermes VM artifacts (a +# mismatched pair crashes at launch with "Wrong bytecode version"). +# STRICTLY SwiftPM-scoped: in a CocoaPods build the hermes-engine pod owns both +# the VM and hermesc, and its hermes train may legitimately differ from the npm +# hermes-compiler — falling back there would compile bundles the podded VM +# rejects. +if [[ ( -z "$PODS_ROOT" || ! -d "$PODS_ROOT" ) && ! -f "$HERMES_CLI_PATH" ]]; then + NODE_HERMESC=$(RN_DIR="$REACT_NATIVE_DIR" "$NODE_BINARY" --print \ + "require('path').join(require('path').dirname(require.resolve('hermes-compiler/package.json', {paths: [process.env.RN_DIR]})), 'hermesc', 'osx-bin', 'hermesc')" \ + 2>/dev/null || true) + if [[ -n "$NODE_HERMESC" && -f "$NODE_HERMESC" ]]; then + HERMES_CLI_PATH="$NODE_HERMESC" + fi +fi + # If hermesc is not available and USE_HERMES is not set to false, show error. if [[ $USE_HERMES != false && -f "$HERMES_ENGINE_PATH" && ! -f "$HERMES_CLI_PATH" ]]; then echo "error: Hermes is enabled but the hermesc binary could not be found at ${HERMES_CLI_PATH}." \ diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js new file mode 100644 index 000000000000..578ebc5c63da --- /dev/null +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -0,0 +1,1165 @@ +/** + * 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'; + +/*:: import type {CliConfigJson, SetupArgs} from './spm/spm-types'; */ + +/** + * setup-apple-spm.js – Entry point for setting up Swift Package Manager support + * in a React Native app using prebuilt XCFrameworks. + * + * Usage (from your app directory): + * node node_modules/react-native/scripts/setup-apple-spm.js [action] [options] + * (or `npx react-native spm [action]`) + * + * Actions: + * add Inject SPM packages (package refs, build + * settings, the Sync build phase) into your + * existing .xcodeproj, in place. Idempotent. + * Default on first run. `--deintegrate` first + * runs `pod deintegrate` + strips React Native + * from the Podfile (CocoaPods → SPM migration). + * 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. + * scaffold Generate Package.swift for community deps that + * lack SPM support. + * sync / codegen / download Advanced/internal: `sync` is invoked by the + * generated Xcode build phase; `codegen` and + * `download` run a single pipeline step. + * + * Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods + * project (clean tree, stock Podfile) → `add --deintegrate`; an injected + * project → `update`; otherwise → `add` (fails loud on a CocoaPods project, + * directing you to `--deintegrate`). + * + * Options: + * --version React Native version (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). + * [add] --deintegrate Run `pod deintegrate` + strip RN from the + * Podfile before injecting. + * [advanced] --artifacts Local two-flavor artifact root; + * must contain debug/ and release/ cache slots. + * [advanced] --download Artifact policy (default: auto). + * [advanced] --skip-codegen Skip the react-native codegen step. + * + * Steps performed (add/update): + * 1. react-native codegen → build/generated/ios/ + install SPM codegen template + * 2. generate-spm-autolinking-config.js → build/generated/autolinking/autolinking.json + * 3. generate-spm-autolinking.js → build/generated/autolinking/Package.swift + * 4. download-spm-artifacts.js → cache dir (per --download policy) + * 5. generate-spm-package.js → build/xcframeworks/Package.swift + symlinks + * 6. inject SPM packages into the existing .xcodeproj (in place) + * + * The injection is committed with your project; its XCLocalSwiftPackageReference + * entries point at stable sub-package paths under build/ (xcframeworks, + * generated/autolinking, generated/ios), so adding/removing community deps + * changes those sub-packages (gitignored) and never re-injects. No app-level + * Package.swift is generated or required. + */ + +const { + main: downloadArtifacts, + resolveCacheSlotVersion, + validateArtifactsCache, +} = require('./spm/download-spm-artifacts'); +const {finalizeArtifactPublication} = require('./spm/flavored-frameworks'); +const { + MissingManifestError, + main: generateAutolinking, +} = require('./spm/generate-spm-autolinking'); +const { + generateAutolinkingConfig, +} = require('./spm/generate-spm-autolinking-config'); +const {main: generatePackage} = require('./spm/generate-spm-package'); +const {findSourcePath} = require('./spm/generate-spm-package'); +const { + cleanupDanglingJavaScriptCoreRef, + cleanupLeftoverPodsGroup, + findInjectedXcodeproj, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, +} = require('./spm/generate-spm-xcodeproj'); +const {scaffoldAll} = require('./spm/scaffold-package-swift'); +const { + RemoteVersionError, + buildPerAppHeaderTree, + defaultCacheDir, + deriveAppName, + displayPath, + findProjectRoot, + installSpmCodegenTemplate, + makeLogger, + readPackageJson, + remotePackageConfig, + runCodegenAndInstallTemplate, +} = require('./spm/spm-utils'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const yargs = require('yargs'); + +const {log, warn: logError} = makeLogger('setup-apple-spm'); + +const VALID_ACTIONS = new Set([ + 'add', + 'update', + 'deinit', + 'sync', + 'codegen', + 'download', + 'scaffold', +]); + +/*:: +type AutolinkingConfigResult = { + config: CliConfigJson, + outputPath: string, + rawJson: string, +}; +*/ + +function parseArgs(argv /*: Array */) /*: SetupArgs */ { + const parsed = yargs(argv) + .version(false) + .command('$0 [action]', 'Set up Apple SPM support') + .positional('action', { + type: 'string', + choices: Array.from(VALID_ACTIONS), + describe: + 'Action to run: add, update, deinit, or scaffold. ' + + 'Defaults to add (or update if SPM is already set up).', + }) + .option('version', { + type: 'string', + describe: + 'React Native version (e.g. 0.80.0). Defaults to the version in node_modules/react-native/package.json', + }) + .option('yes', { + type: 'boolean', + default: false, + describe: 'Skip the dirty-pbxproj confirmation prompt', + }) + .option('xcodeproj', { + type: 'string', + describe: + '[add] Path to the .xcodeproj to inject SPM packages into (disambiguates when several exist).', + }) + .option('product-name', { + type: 'string', + describe: + '[add] App target to inject into (disambiguates when several exist).', + }) + .option('deintegrate', { + type: 'boolean', + default: false, + describe: + '[add] Run `pod deintegrate` + strip React Native from the Podfile before injecting (CocoaPods → SPM migration).', + }) + .option('artifacts', { + type: 'string', + describe: + '[advanced] Local artifact root containing complete debug/ and release/ cache slots.', + }) + .option('download', { + type: 'string', + choices: ['auto', 'skip', 'force'], + default: 'auto', + describe: + '[advanced] Artifact download policy: auto (fetch if missing), skip (never fetch), force (clear cache + refetch).', + }) + .option('skip-codegen', { + type: 'boolean', + default: false, + describe: '[advanced] Skip the react-native codegen step', + }) + .usage( + 'Usage: $0 [action] [options]\n\nSets up Swift Package Manager support in a React Native app.', + ) + .strictOptions() + .help() + .parseSync(); + + const positional = parsed._.map(String); + const requestedAction = parsed.action ?? positional[0] ?? null; + if (positional.length > 1) { + throw new Error( + `Expected at most one action, got: ${positional.join(', ')}`, + ); + } + if (requestedAction != null && !VALID_ACTIONS.has(requestedAction)) { + throw new Error( + `Unknown action "${requestedAction}". Expected one of: ${Array.from( + VALID_ACTIONS, + ).join(', ')}`, + ); + } + + return { + action: requestedAction, + version: parsed.version ?? null, + artifacts: parsed.artifacts ?? null, + skipCodegen: parsed['skip-codegen'], + downloadPolicy: parsed.download, + productName: parsed['product-name'] ?? null, + xcodeprojPath: parsed.xcodeproj ?? null, + deintegrate: parsed.deintegrate, + yes: parsed.yes, + }; +} + +const SPM_GITIGNORE_ENTRIES = [ + 'Package.resolved', + 'build/generated/', + 'build/xcframeworks/', + '.build/', +]; + +/** + * Ensure the project's .gitignore contains entries for SPM-generated + * directories. Called during init so that generated artifacts are not + * accidentally committed. + */ +function ensureGitignoreSpmEntries(appRoot /*: string */) { + const gitignorePath = path.join(appRoot, '.gitignore'); + let content = ''; + if (fs.existsSync(gitignorePath)) { + content = fs.readFileSync(gitignorePath, 'utf8'); + } + + const existingEntries = new Set(content.split('\n').map(l => l.trim())); + const missing = SPM_GITIGNORE_ENTRIES.filter(e => !existingEntries.has(e)); + + if (missing.length === 0) { + return; + } + + const block = [ + '', + '# SPM – auto-generated at build time (do not commit)', + ...missing, + ].join('\n'); + + // Append, ensuring we start on a fresh line + const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; + fs.writeFileSync(gitignorePath, content + separator + block + '\n', 'utf8'); + log(`Updated .gitignore with SPM entries: ${missing.join(', ')}`); +} + +/** + * Single TTY-gated Y/N prompt helper used by every interactive confirmation + * in this file. Non-TTY (CI / piped stdin) auto-confirms — every callsite + * either opted into the action explicitly or is downstream of an opt-in. + */ +function promptYesNo( + question /*: string */, + defaultYes /*: boolean */, +) /*: Promise */ { + // $FlowFixMe[prop-missing] process.stdin.isTTY not in Flow stubs + if (process.stdin.isTTY !== true) { + return Promise.resolve(true); + } + const suffix = defaultYes ? '[Y/n]' : '[y/N]'; + return new Promise(resolve => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${question} ${suffix} `, answer => { + rl.close(); + const a = answer.trim().toLowerCase(); + const yes = a === 'y' || a === 'yes'; + resolve(defaultYes ? a === '' || yes : yes); + }); + }); +} + +function resolveAction( + requestedAction /*: SetupArgs['action'] */, + appRoot /*: string */, +) /*: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' */ { + if (requestedAction != null) { + return requestedAction; + } + // Zero-arg default. Once SPM has been injected, default to `update` (regen + + // refresh). Otherwise it's a first run → `add`. Whether `add` should imply + // `--deintegrate` (fresh CocoaPods project) is decided by the safe-gate in + // main(), which only applies on this implicit path. + return findInjectedXcodeproj(appRoot) != null ? 'update' : 'add'; +} + +/** + * Detects the JS-root-vs-ios-dir mismatch that produces silently-broken + * builds for standard RN apps. The community CLI writes + * `autolinking.json` under `/build/generated/autolinking/` + * (i.e. `/ios/...`), while every SPM script anchors its + * inputs/outputs on `process.cwd()`. Running from the JS root therefore + * (a) writes outputs at `/build/...` — away from the iOS + * project, and (b) makes the autolinker miss `autolinking.json` and + * silently skip every npm native dep. The build "succeeds" but anything + * touching a native module crashes at runtime. + * + * Returns the absolute path to the redirected app root (`/ios`) + * when the redirect heuristic applies, else null. Pure: no side effects. + * The caller decides whether to auto-redirect (non-destructive actions) or + * refuse (destructive actions like `clean`). + */ +function detectStandardRnLayoutRedirect( + appRoot /*: string */, + projectRoot /*: string */, +) /*: string | null */ { + // Only relevant when cwd === projectRoot (i.e. user is at the JS root + // of their RN app). If they've already cd'd into a subdir, projectRoot + // walks up to find package.json and the two paths differ — leave alone. + if (path.resolve(appRoot) !== path.resolve(projectRoot)) { + return null; + } + // Standard RN layout has an `ios/` subdir holding the native project. + // Without it (e.g. rn-tester's flat layout), no mismatch to flag. + const iosSubdir = path.join(projectRoot, 'ios'); + try { + if (!fs.statSync(iosSubdir).isDirectory()) { + return null; + } + } catch { + return null; + } + return iosSubdir; +} + +function resolveReactNativeRoot( + autolinkingConfigResult /*: ?AutolinkingConfigResult */, + projectRoot /*: string */, +) /*: string */ { + // Prefer the React Native path resolved by the CLI config we already run for + // autolinking. Fall back to this script's package root for direct repo usage. + let reactNativeRoot = path.resolve(__dirname, '..'); + const cliConfig = autolinkingConfigResult?.config; + const cliReactNativePath = cliConfig?.reactNativePath; + const cliConfigRoot = cliConfig?.root; + if (typeof cliReactNativePath === 'string' && cliReactNativePath.length > 0) { + reactNativeRoot = path.resolve( + typeof cliConfigRoot === 'string' && cliConfigRoot.length > 0 + ? cliConfigRoot + : projectRoot, + cliReactNativePath, + ); + } + return reactNativeRoot; +} + +function determineVersion( + args /*: SetupArgs */, + reactNativeRoot /*: 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'), + ); + version = pkgJson.version; + } + return version; +} + +function runCodegenStep( + projectRoot /*: string */, + appRoot /*: string */, + reactNativeRoot /*: string */, + skipCodegen /*: boolean */, +) /*: void */ { + if (skipCodegen) { + // Output dir may already exist from a previous run; still refresh the + // SPM template so cache-slot changes propagate. + log('Skipping codegen (--skip-codegen)'); + installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + return; + } + log('Running react-native codegen...'); + try { + runCodegenAndInstallTemplate(projectRoot, appRoot, reactNativeRoot, {log}); + } catch { + logError('Codegen failed. Continuing anyway...'); + } +} + +/** + * Walks autolinking.json and writes a Package.swift into each community RN + * package that ships a podspec but no SPM manifest. Reuses the dep's + * podspec (via `pod ipc spec` when available) so the scaffolded file + * captures the dep's actual sources, header search paths, frameworks, and + * dependencies. Files carrying the scaffolder's own marker are regenerated + * when the cache slot changes (manifest-hash bump); files without the + * marker are left alone (upstream-shipped or user-managed). + * + * Runs as part of `init` / `update` / `scaffold` actions. Each invocation + * is a no-op for deps already in a clean state. + */ +// Scaffolding is an EXPLICIT, manual step (`npx react-native spm scaffold`) and +// is NEVER run automatically by init/update/sync. A missing Package.swift is a +// real gap that must surface as a hard build error (see reportMissingManifests) +// so the user fixes it deliberately: scaffold, then persist with patch-package +// (node_modules is not committed), and ideally get it fixed upstream. There is +// intentionally no prompt and no auto-restore — auto-scaffolding would hide the +// error, and a wiped scaffold SHOULD re-surface it. +async function runScaffold( + args /*: SetupArgs */, + appRoot /*: string */, + projectRoot /*: string */, + reactNativeRoot /*: string */, +) /*: Promise */ { + // Resolve the cache slot identifier so the scaffolded files carry it as + // 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 slotVersion = await resolveCacheSlotVersion(rawVersion); + cacheSlotLabel = `${slotVersion}/dual-flavor`; + } catch { + // Without a slot label the scaffolder still works; the file just + // doesn't get the slot-bump comment. + } + + let results; + try { + results = scaffoldAll({ + appRoot, + projectRoot, + reactNativeRoot, + cacheSlotLabel, + // Always force a re-render so re-running after editing a podspec picks + // up the new content. + force: true, + }); + } catch (e) { + logError(`scaffold failed: ${e.message}.`); + process.exitCode = 1; + return; + } + + const written = results.filter(r => r.status === 'written'); + const errored = results.filter(r => r.status === 'error'); + const warned = results.filter( + r => r.status === 'written' && r.warnings && r.warnings.length > 0, + ); + + if (written.length > 0) { + log(`Scaffolded Package.swift for ${written.length} dep(s):`); + for (const r of written) { + log(` • ${r.depName}`); + } + log(''); + log( + 'node_modules is NOT committed and is wiped by `npm install`. To keep\n' + + 'these manifests, create and commit a patch with a tool like patch-package:\n' + + ' • `npx patch-package ` for each scaffolded dep, then commit the patch.\n' + + 'Also consider asking the maintainer to ship a Package.swift upstream.\n' + + 'Without a committed patch the build will hard-error again after a fresh install.', + ); + log(''); + } + + for (const r of warned) { + if (r.status !== 'written') continue; + for (const w of r.warnings) { + log(` ! ${r.depName}: ${w}`); + } + } + + for (const r of errored) { + if (r.status !== 'error') continue; + logError(` ! ${r.depName}: ${r.reason}`); + } +} + +async function ensureArtifactFlavor( + args /*: SetupArgs */, + rawVersion /*: string */, + slotVersion /*: string */, + flavor /*: 'debug' | 'release' */, +) /*: Promise */ { + if (args.artifacts != null && args.artifacts.endsWith('.xcframework')) { + throw new Error( + '--artifacts must be a directory containing debug/ and release/ slots; ' + + 'a single XCFramework cannot satisfy automatic switching', + ); + } + const resolvedArtifactsDir = + args.artifacts != null + ? path.resolve(args.artifacts, flavor) + : defaultCacheDir(slotVersion, flavor); + + if (args.downloadPolicy === 'force' && resolvedArtifactsDir != null) { + log('Clearing cached artifacts (--download force)...'); + fs.rmSync(resolvedArtifactsDir, {recursive: true, force: true}); + } + + if (args.downloadPolicy === 'skip') { + const error = validateArtifactsCache(resolvedArtifactsDir); + if (error != null) { + throw new Error( + `--download skip requires a complete ${flavor} slot: ${error}`, + ); + } + log(`Using ${flavor} artifacts (--download skip)`); + return resolvedArtifactsDir; + } + + // Validate the cache before trusting it. A bare existsSync(artifacts.json) + // check would accept a partial write from a prior failed download (e.g. + // hermes-engine 404 on a not-yet-published nightly) and silently propagate + // the gap into the xcodeproj, surfacing only as "Missing package product" + // in Xcode. validateArtifactsCache reads the JSON and confirms every + // REQUIRED_ARTIFACT has a present xcframework on disk. + const cacheError = validateArtifactsCache(resolvedArtifactsDir); + if (cacheError == null) { + log( + `${flavor} artifacts already present in ${displayPath(resolvedArtifactsDir)}`, + ); + return resolvedArtifactsDir; + } + log(`Cache incomplete (${cacheError}); re-downloading...`); + log(`Downloading ${flavor} xcframework artifacts (slot: ${slotVersion})...`); + await downloadArtifacts([ + '--version', + rawVersion, + '--flavor', + flavor, + '--output', + resolvedArtifactsDir, + ]); + const downloadedError = validateArtifactsCache(resolvedArtifactsDir); + if (downloadedError != null) { + throw new Error( + `downloaded ${flavor} slot is incomplete: ${downloadedError}`, + ); + } + return resolvedArtifactsDir; +} + +async function ensureBothArtifactFlavors( + args /*: SetupArgs */, + version /*: string */, +) /*: Promise<{debug: string, release: string}> */ { + // Resolve aliases/nightly labels exactly once so both flavors are guaranteed + // to come from the same selected artifact version. + const rawVersion = args.version ?? version; + const slotVersion = await resolveCacheSlotVersion(rawVersion); + const debug = await ensureArtifactFlavor( + args, + rawVersion, + slotVersion, + 'debug', + ); + const release = await ensureArtifactFlavor( + args, + rawVersion, + slotVersion, + 'release', + ); + return {debug, release}; +} + +function generateXcframeworksPackage( + args /*: SetupArgs */, + appRoot /*: string */, + reactNativeRoot /*: string */, + version /*: string */, + artifactDirs /*: {debug: string, release: string} */, +) { + log('Generating xcframeworks sub-package...'); + const packageArgs = [ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + '--version', + version, + ]; + packageArgs.push('--debug-artifacts-dir', artifactDirs.debug); + packageArgs.push('--release-artifacts-dir', artifactDirs.release); + generatePackage(packageArgs); +} + +// True when the chosen pbxproj is still CocoaPods-integrated (its build configs +// layer a `Pods-*.xcconfig`) — the real blocker for SPM injection. +function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { + try { + const t = fs.readFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + 'utf8', + ); + return /\bPods[-/][^\n]*\.xcconfig\b/.test(t); + } catch { + return false; + } +} + +// True when the Podfile still declares React Native integration — a latent +// landmine even after `pod deintegrate` (a future `pod install` re-breaks the +// SPM graph). Warned about (not refused) once the pbxproj itself is clean. +function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { + const podfilePath = path.join(appRoot, 'Podfile'); + if (!fs.existsSync(podfilePath)) { + return false; + } + return /use_react_native!|use_native_modules!|prepare_react_native_project!/.test( + fs.readFileSync(podfilePath, 'utf8'), + ); +} + +// True when the Podfile declares any explicit `pod '...'` (third-party pods). +function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { + const podfilePath = path.join(appRoot, 'Podfile'); + if (!fs.existsSync(podfilePath)) { + return false; + } + return /^\s*pod\s+['"]/m.test(fs.readFileSync(podfilePath, 'utf8')); +} + +// The zero-arg "fresh project" safe-gate: auto-`add --deintegrate` ONLY when it +// is provably safe — a first-run CocoaPods RN project with a stock Podfile (no +// third-party pods) whose pbxproj AND Podfile are git-tracked and clean, so the +// conversion is fully revertible. We check only the two files `deintegrate` +// mutates (not the whole tree) — a fresh app typically has a dirty +// node_modules/lockfile/patches after `npm install` + `spm scaffold`, none of +// which affect the revertibility of the CocoaPods → SwiftPM conversion. +// Otherwise false → strict `add` (which fails loud on a CocoaPods project). +function shouldAutoDeintegrate( + appRoot /*: string */, + xcodeprojPath /*: string | null */, +) /*: boolean */ { + if (xcodeprojPath == null || !pbxprojUsesCocoaPods(xcodeprojPath)) { + return false; + } + if (podfileHasThirdPartyPods(appRoot)) { + return false; + } + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (gitTrackedAndClean(appRoot, pbxprojPath) !== true) { + return false; + } + const podfilePath = path.join(appRoot, 'Podfile'); + if ( + fs.existsSync(podfilePath) && + gitTrackedAndClean(appRoot, podfilePath) !== true + ) { + return false; + } + return true; +} + +// Run `pod deintegrate` then strip React Native from the Podfile (leaving any +// non-RN pods). Requires CocoaPods on PATH (fail-loud otherwise). Flag-gated ⇒ +// no prompt ⇒ CI-safe. Does NOT touch the .xcworkspace. +function runDeintegrate(appRoot /*: string */) /*: void */ { + try { + execFileSync('pod', ['--version'], {stdio: 'ignore'}); + } catch { + logError( + '`--deintegrate` needs CocoaPods (`pod`) on PATH. Remove the React ' + + 'Native integration from your project manually, then run `spm add`.', + ); + process.exitCode = 1; + throw new Error('pod not found'); + } + log('Running `pod deintegrate`...'); + execFileSync('pod', ['deintegrate'], {cwd: appRoot, stdio: 'inherit'}); + + const podfilePath = path.join(appRoot, 'Podfile'); + if (fs.existsSync(podfilePath)) { + const orig = fs.readFileSync(podfilePath, 'utf8'); + const stripped = orig + .split('\n') + .filter( + l => + !/use_react_native!|use_native_modules!|prepare_react_native_project!/.test( + l, + ), + ) + .join('\n'); + if (stripped !== orig) { + fs.writeFileSync(podfilePath, stripped, 'utf8'); + log('Stripped React Native integration from Podfile.'); + } + } +} + +// Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place +// target (re-run) > the single .xcodeproj in appRoot. Returns an error string +// (ambiguous / none) so the caller fails loud. +function resolveInjectionTarget( + args /*: SetupArgs */, + appRoot /*: string */, +) /*: {path: string, error?: void} | {error: string, path?: void} */ { + if (args.xcodeprojPath != null) { + const p = path.resolve(appRoot, args.xcodeprojPath); + return fs.existsSync(p) + ? {path: p} + : {error: `--xcodeproj not found: ${p}`}; + } + const injected = findInjectedXcodeproj(appRoot); + if (injected != null) { + return {path: injected}; + } + const names /*: Array */ = []; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(appRoot, {withFileTypes: true}); + } catch {} + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (name.endsWith('.xcodeproj')) { + names.push(name); + } + } + if (names.length === 0) { + return { + error: + 'no .xcodeproj found. Create an app first (e.g. `npx ' + + '@react-native-community/cli init`) or make one in Xcode, then `spm add`.', + }; + } + if (names.length > 1) { + return { + error: `multiple .xcodeproj found (${names.join(', ')}); pass --xcodeproj to pick one.`, + }; + } + return {path: path.join(appRoot, names[0])}; +} + +/** + * Inject SPM packages into the user's existing .xcodeproj, in place — the only + * xcodeproj strategy (`add` and `update` both run this; there is no + * from-scratch generation). Fails loud rather than silently retargeting. With + * `--deintegrate`, removes CocoaPods first. + */ +async function setupXcodeproj( + args /*: SetupArgs */, + appRoot /*: string */, + reactNativeRoot /*: string */, + action /*: string */, +) /*: Promise */ { + const target = resolveInjectionTarget(args, appRoot); + if (target.error != null) { + logError(`Cannot set up SPM: ${target.error}`); + process.exitCode = 1; + throw new Error(target.error); + } + const xcodeprojPath = target.path; + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + + // Snapshot the pbxproj's git state BEFORE deintegrate — `pod deintegrate` + // rewrites it (removing the Pods xcconfig layering), so checking afterward + // would always look dirty and trigger a spurious confirmation prompt. + const cleanBeforeEdits = gitTrackedAndClean(appRoot, pbxprojPath); + + if (args.deintegrate) { + runDeintegrate(appRoot); + // `pod deintegrate` strips the build integration but can leave an empty + // `Pods` group in the navigator — remove it so the converted project is + // visually clean. + if (cleanupLeftoverPodsGroup(xcodeprojPath)) { + log('Removed the leftover empty `Pods` group from the project.'); + } + } + + // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. + if (pbxprojUsesCocoaPods(xcodeprojPath)) { + logError( + `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + + '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + + 'strips React Native from the Podfile), or run `pod deintegrate` ' + + 'yourself first. Side-by-side non-RN pods are fine.', + ); + process.exitCode = 1; + throw new Error('CocoaPods-integrated project'); + } + if (podfileHasRnIntegration(appRoot)) { + log( + '\x1b[33mNote: your Podfile still declares React Native integration. ' + + 'Remove it and avoid `pod install`, or it will re-break the SPM ' + + 'package graph.\x1b[0m', + ); + } + + // No backup is made — git is the safety net. Refuse on a dirty/untracked + // pbxproj (as it was BEFORE any deintegrate edits) unless --yes, so a bad + // inject is always `git checkout`-able. + const clean = cleanBeforeEdits; + if (clean === false && !args.yes) { + const proceed = await promptYesNo( + `${path.basename(xcodeprojPath)} has uncommitted changes and no ` + + `backup is made (git is the only undo). Inject SPM packages anyway?`, + false, + ); + if (!proceed) { + log('Aborted. Commit or stash the project, then re-run `spm add`.'); + process.exitCode = 1; + throw new Error('In-place injection declined (dirty working tree)'); + } + } else if (clean === null) { + log( + `\x1b[33mNote: ${path.basename(xcodeprojPath)} is not in a git ` + + `repo — no backup is made before in-place injection.\x1b[0m`, + ); + } + + const result = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot, + xcodeprojPath, + appName: args.productName, + // Only an EXPLICIT `--version` pins the artifacts-cache slot in the + // marker; omitting it (null) leaves any previously-recorded pin alone + // (injectSpmIntoExistingXcodeproj preserves it — see + // generate-spm-xcodeproj.js). + artifactsVersionOverride: args.version ?? null, + }); + if (result.status !== 'injected') { + logError(`SPM injection failed: ${result.reason}`); + process.exitCode = 1; + throw new Error(result.reason); + } + + // The community template has carried a dangling `JavaScriptCore.framework` + // reference since RN 0.60 (navigator-only, unlinked, meaningless under + // Hermes) — one-time migration hygiene at conversion time, like the empty + // Pods group above. `add` only; `update` stays a minimal re-sync. + if (action === 'add' && cleanupDanglingJavaScriptCoreRef(xcodeprojPath)) { + log( + "Removed the template's dangling JavaScriptCore.framework reference " + + '(unused; React Native uses Hermes).', + ); + } +} + +// True when `git status --porcelain` reports the path dirty/untracked. Returns +// null when git is unavailable or the path is outside a repo (no safety net). +function gitTrackedAndClean( + appRoot /*: string */, + targetPath /*: string */, +) /*: boolean | null */ { + try { + const out = execFileSync( + 'git', + ['status', '--porcelain', '--', targetPath], + {cwd: appRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}, + ); + return out.trim() === ''; + } catch { + return null; // not a git repo / git missing + } +} + +function logNextSteps( + projectRoot /*: string */, + appRoot /*: string */, + productName /*: string | null */, +) { + const appPkgJson = readPackageJson(projectRoot); + const rawName = + (appPkgJson != null ? appPkgJson.name : null) ?? path.basename(projectRoot); + const sourcePath = findSourcePath(appRoot, rawName); + const appDisplayName = productName ?? deriveAppName(rawName, sourcePath); + + log(''); + log('SPM setup complete!'); + log(''); + log('Next steps:'); + log(` • Open ${appDisplayName}.xcodeproj in Xcode (or \`npm run ios\`)`); + log(' • Set your Development Team in Signing & Capabilities'); + log(' • Build and run on Simulator or device'); + log(''); + log('To remove SPM later: `npx react-native spm deinit`'); +} + +async function main(argv /*:: ?: Array */) /*: Promise */ { + let appRoot = process.cwd(); + const projectRoot = findProjectRoot(appRoot); + const args = parseArgs(argv ?? process.argv.slice(2)); + + // Standard-RN-layout redirect: if invoked from the JS root and there's an + // `ios/` subdir, route the run there. Runs BEFORE resolveAction so the + // first-run heuristic checks the correct directory. + const redirectTo = detectStandardRnLayoutRedirect(appRoot, projectRoot); + if (redirectTo != null) { + const redirectAction = args.action ?? 'add'; + log( + `\x1b[33mDetected standard RN layout — running ${redirectAction} in ${displayPath(redirectTo)} ` + + `instead of ${displayPath(appRoot)}.\x1b[0m`, + ); + appRoot = redirectTo; + } + + const action = resolveAction(args.action, appRoot); + + // Zero-arg safe-gate: when `add` was resolved implicitly (no action typed) + // on a freshly-scaffolded CocoaPods project, imply `--deintegrate` so the + // common "new app → SPM" path is one command. Never on an explicit `spm add` + // (that stays strict and fails loud on CocoaPods). + if ( + action === 'add' && + args.action == null && + !args.deintegrate && + shouldAutoDeintegrate( + appRoot, + resolveInjectionTarget(args, appRoot).path ?? null, + ) + ) { + log( + 'Detected a freshly-scaffolded CocoaPods project — converting to SwiftPM ' + + '(running `pod deintegrate`). Revert with `git` or `spm deinit`.', + ); + args.deintegrate = true; + } + + log(`Running SPM ${action} in: ${displayPath(appRoot)}`); + if (projectRoot !== appRoot) { + log(`Project root (package.json): ${displayPath(projectRoot)}`); + } + + if (action === 'deinit') { + const xcodeprojPath = + args.xcodeprojPath != null + ? path.resolve(appRoot, args.xcodeprojPath) + : findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + log('No SPM injection found — nothing to remove.'); + return; + } + const result = removeSpmInjection({appRoot, xcodeprojPath}); + log( + result.status === 'removed' + ? `Removed SPM packages from ${path.basename(xcodeprojPath)}.` + : 'No SPM injection found — nothing to remove.', + ); + return; + } + + // Fail fast on a CocoaPods-integrated target BEFORE the (expensive) pipeline, + // so the user isn't made to wait through codegen + artifact download only to + // be told to re-run with --deintegrate. Skipped when --deintegrate is set + // (explicitly or via the safe-gate) — deintegration happens in setupXcodeproj. + if ((action === 'add' || action === 'update') && !args.deintegrate) { + const target = resolveInjectionTarget(args, appRoot); + if (target.path != null && pbxprojUsesCocoaPods(target.path)) { + logError( + `${path.basename(target.path)} is CocoaPods-integrated. Re-run ` + + '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + + 'strips React Native from the Podfile), or run `pod deintegrate` ' + + 'yourself first. Side-by-side non-RN pods are fine.', + ); + process.exitCode = 1; + return; + } + } + + const needsCliConfig = + action === 'add' || + action === 'update' || + action === 'sync' || + action === 'scaffold'; + 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.`, + ); + } + } + const reactNativeRoot = resolveReactNativeRoot( + autolinkingConfigResult, + projectRoot, + ); + const version = determineVersion(args, reactNativeRoot); + log(`React Native version: ${version}`); + + // Resolve remote SPM mode ONCE up front. remotePackageConfig throws + // RemoteVersionError when remote mode is active but no usable RN version can + // be derived (e.g. the monorepo '1000.0.0' placeholder with no override). + // The downstream scaffold/autolinker/package steps all call it internally; + // surfacing it here gives a single, predictable failure point before any of + // them run. Exit 2 (same as a missing manifest) so the Xcode build phase + // turns it into a hard build error while staying lenient on transient sync + // failures. No-op in local mode (returns null). + try { + remotePackageConfig(appRoot); + } catch (e) { + if (e instanceof RemoteVersionError) { + logError(e.message); + process.exitCode = 2; + return; + } + throw e; + } + // The artifact cache directory is resolved later in ensureArtifacts so the + // nightly hash can be folded in for dev / nightly labels. That branch logs + // either "Downloading xcframework artifacts (slot: ...)" or + // "Artifacts already present in ...". + + if (action === 'codegen') { + runCodegenStep(projectRoot, appRoot, reactNativeRoot, false); + return; + } + + if (action === 'sync') { + const {main: runSync} = require('./spm/sync-spm-autolinking'); + try { + await runSync([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + } catch (e) { + if (e instanceof MissingManifestError) { + // The per-dep `error:` lines were already printed by the autolinker. + // Exit 2 (distinct from generic failure) so the Xcode build phase can + // turn this into a hard build error while staying lenient on transient + // sync failures. + process.exitCode = 2; + } else { + logError(`SPM sync failed: ${e.message}`); + process.exitCode = 1; + } + } + return; + } + + if (action === 'download') { + try { + await ensureBothArtifactFlavors(args, version); + } catch (e) { + logError(`Artifact setup failed: ${e.message}`); + process.exitCode = 1; + } + return; + } + + // Scaffold Package.swift for community RN packages that don't ship SPM + // support — ONLY for the explicit `scaffold` action. init/update never + // auto-scaffold: a missing manifest must surface as a hard error (the + // autolinker below throws MissingManifestError → exit 2) so the gap is + // visible and fixed deliberately (scaffold + patch-package, or upstream). + // Auto-scaffolding would silently hide that real error. + if (action === 'scaffold') { + await runScaffold(args, appRoot, projectRoot, reactNativeRoot); + } + + runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen); + log('Generating build/generated/autolinking/Package.swift...'); + try { + generateAutolinking([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + } catch (e) { + if (e instanceof MissingManifestError) { + // Per-dep `error:` lines already printed by the autolinker. This happens + // on init/update when the user declined the scaffold prompt — surface it + // as a hard failure (exit 2) directing them to scaffold. + process.exitCode = 2; + } else { + logError(`generate-spm-autolinking.js failed: ${e.message}`); + process.exitCode = 1; + } + return; + } + + const remote = remotePackageConfig(appRoot); + if (remote != null) { + log(`Remote ReactNative package: ${remote.url} @ ${remote.version}`); + } + let artifactDirs; + try { + artifactDirs = await ensureBothArtifactFlavors(args, version); + } catch (e) { + logError(`Artifact setup failed: ${e.message}`); + process.exitCode = 1; + return; + } + try { + generateXcframeworksPackage( + args, + appRoot, + reactNativeRoot, + version, + artifactDirs, + ); + } catch (e) { + logError(`generate-spm-package.js failed: ${e.message}`); + process.exitCode = 1; + return; + } + + // (Re)install the static codegen Package.swift template once build/generated/ios exists. + installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + + // Build the per-app generated-headers farm (vended as the ReactAppHeaders + // SPM target inside the codegen package). React core headers need no trees + // — they are vended by invariant SwiftPM compile products. The + // generated manifests are fully declarative (fixed-relative package paths), + // so no path-locator JSON is written. + buildPerAppHeaderTree(appRoot, {log}); + + // First-time setup only adds the gitignore entries on `add`. + if (action === 'add') { + ensureGitignoreSpmEntries(appRoot); + } + + // Xcodeproj setup: in-place injection into the existing project (the only + // strategy — no rename, no from-scratch; git is the safety net). + try { + await setupXcodeproj(args, appRoot, reactNativeRoot, action); + } catch (e) { + logError(`xcodeproj setup failed: ${e.message}`); + if (process.exitCode == null) { + process.exitCode = 1; + } + return; + } + + finalizeArtifactPublication(appRoot); + + logNextSteps(projectRoot, appRoot, args.productName); +} + +if (require.main === module) { + void main(); +} + +module.exports = { + main, + detectStandardRnLayoutRedirect, + findInjectedXcodeproj, + resolveAction, + shouldAutoDeintegrate, + ensureBothArtifactFlavors, +}; diff --git a/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md new file mode 100644 index 000000000000..86116637e3f7 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/rfc-spm-xcframework.md @@ -0,0 +1,707 @@ +--- +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 new file mode 100644 index 000000000000..c8ac204ca06d --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-autolinking-plugins.md @@ -0,0 +1,244 @@ +# 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 new file mode 100644 index 000000000000..c62c46e0b781 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-header-paths-contract.md @@ -0,0 +1,97 @@ +# 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 new file mode 100644 index 000000000000..e20e4c93f672 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-plugins-assessment.md @@ -0,0 +1,128 @@ +# 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 new file mode 100644 index 000000000000..d0d0f9cbf7a0 --- /dev/null +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -0,0 +1,451 @@ +# 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 | + +### 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. | +| `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 | +| 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/__tests__/__fixtures__/plain-app.pbxproj b/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj new file mode 100644 index 000000000000..ee9fbf60dcf8 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/__fixtures__/plain-app.pbxproj @@ -0,0 +1,171 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AA00000000000000000000A1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AA00000000000000000000B1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AA00000000000000000000C1 /* MyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MyApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA00000000000000000000D1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA00000000000000000000E1 = { + isa = PBXGroup; + children = ( + AA00000000000000000000B1 /* AppDelegate.swift */, + AA00000000000000000000F1 /* Products */, + ); + sourceTree = ""; + }; + AA00000000000000000000F1 /* Products */ = { + isa = PBXGroup; + children = ( + AA00000000000000000000C1 /* MyApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000101 /* MyApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000201 /* Build configuration list for PBXNativeTarget "MyApp" */; + buildPhases = ( + AA0000000000000000000301 /* Sources */, + AA00000000000000000000D1 /* Frameworks */, + AA0000000000000000000401 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MyApp; + productName = MyApp; + productReference = AA00000000000000000000C1 /* MyApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000501 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1600; + }; + buildConfigurationList = AA0000000000000000000601 /* Build configuration list for PBXProject "MyApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA00000000000000000000E1; + productRefGroup = AA00000000000000000000F1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000101 /* MyApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA0000000000000000000401 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA0000000000000000000301 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA00000000000000000000A1 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AA0000000000000000000701 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AA0000000000000000000801 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + AA0000000000000000000901 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + AA00000000000000000000A2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA0000000000000000000601 /* Build configuration list for PBXProject "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000701 /* Debug */, + AA0000000000000000000801 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA0000000000000000000201 /* Build configuration list for PBXNativeTarget "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000901 /* Debug */, + AA00000000000000000000A2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000501 /* Project object */; +} diff --git a/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js new file mode 100644 index 000000000000..f45cc5b04a81 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/autolinking-plugins-test.js @@ -0,0 +1,343 @@ +/** + * 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 {discoverPlugins, invokePlugins} = require('../autolinking-plugins'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('discoverPlugins', () => { + let tmp; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-plugins-')); + }); + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + // Writes a dep dir with a plugin module and returns its {name, root}. + function makeDep(name, pluginBody /*: ?string */) { + const root = path.join(tmp, name); + fs.mkdirSync(root, {recursive: true}); + if (pluginBody != null) { + fs.writeFileSync(path.join(root, 'plugin.js'), pluginBody, 'utf8'); + } + return {name, root}; + } + + // readConfig fake: a dep opts in when opted[name] is truthy. + const readConfigFor = opted => root => { + const name = path.basename(root); + return opted[name] ? {spm: {autolinkingPlugin: './plugin.js'}} : null; + }; + + it('discovers a plugin declared via react-native.config.js', () => { + const dep = makeDep('expo', 'module.exports = () => ({});'); + const found = discoverPlugins([dep], readConfigFor({expo: true})); + expect(found).toHaveLength(1); + expect(found[0].depName).toBe('expo'); + expect(typeof found[0].plugin).toBe('function'); + }); + + it('skips deps that do not declare a plugin', () => { + const dep = makeDep('react-native-svg', null); + expect(discoverPlugins([dep], readConfigFor({}))).toHaveLength(0); + }); + + it('honors the app deny-list (opt-out, no allowlist needed)', () => { + const dep = makeDep('expo', 'module.exports = () => ({});'); + const found = discoverPlugins([dep], readConfigFor({expo: true}), ['expo']); + expect(found).toHaveLength(0); + }); + + it('accepts default/plugin export interop', () => { + const a = makeDep('a', 'module.exports.default = () => ({});'); + const b = makeDep('b', 'module.exports.plugin = () => ({});'); + const found = discoverPlugins([a, b], readConfigFor({a: true, b: true})); + expect(found.map(f => f.depName).sort()).toEqual(['a', 'b']); + }); + + it('fails closed when the plugin module is missing', () => { + const dep = makeDep('expo', null); // opted in below but no plugin.js + expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow( + /failed to load the autolinking plugin for 'expo'/, + ); + }); + + it('fails closed when the module does not export a function', () => { + const dep = makeDep('expo', 'module.exports = {nope: 1};'); + expect(() => discoverPlugins([dep], readConfigFor({expo: true}))).toThrow( + /does not export a function/, + ); + }); +}); + +describe('invokePlugins', () => { + const ctx = { + appRoot: '/app/ios', + projectRoot: '/app', + reactNativeRoot: '/app/node_modules/react-native', + autolinking: {}, + outputDir: '/app/ios/build/generated/autolinking', + react: { + packageRef: {name: 'ReactNative', path: '../build/xcframeworks'}, + products: [{name: 'ReactHeaders', package: 'ReactNative'}], + }, + }; + const mk = (depName, fn) => ({ + depName, + pluginPath: `/x/${depName}.js`, + plugin: fn, + }); + + it('merges package/product/generated contributions', () => { + const res = invokePlugins( + [ + mk('expo', () => ({ + packageDependencies: [{name: 'ExpoModulesCore', path: '../expo'}], + productDependencies: [ + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ], + generatedSources: [{path: 'ExpoModulesProvider.swift'}], + })), + ], + ctx, + ); + expect(res.packageDependencies).toEqual([ + {name: 'ExpoModulesCore', path: '../expo'}, + ]); + expect(res.productDependencies).toEqual([ + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ]); + expect(res.generatedSources).toEqual([{path: 'ExpoModulesProvider.swift'}]); + }); + + it('passes the invariant React descriptor without a flavor', () => { + let seen; + invokePlugins( + [ + mk('expo', c => { + seen = c; + return {}; + }), + ], + ctx, + ); + expect(seen.flavor).toBeUndefined(); + expect(seen.projectRoot).toBe('/app'); + expect(seen.autolinking).toBe(ctx.autolinking); + // react descriptor is forwarded so plugins depend on React via one source. + expect(seen.react.packageRef).toEqual({ + name: 'ReactNative', + path: '../build/xcframeworks', + }); + }); + + it('dedupes packages and products by name across plugins', () => { + const res = invokePlugins( + [ + mk('a', () => ({ + packageDependencies: [{name: 'Dup', path: './a'}], + productDependencies: [{name: 'P', package: 'Dup'}], + })), + mk('b', () => ({ + packageDependencies: [{name: 'Dup', path: './b'}], + productDependencies: [{name: 'P', package: 'Dup'}], + })), + ], + ctx, + ); + expect(res.packageDependencies).toHaveLength(1); + expect(res.productDependencies).toHaveLength(1); + }); + + it('tolerates a plugin returning null/undefined', () => { + const res = invokePlugins([mk('a', () => undefined)], ctx); + expect(res.packageDependencies).toEqual([]); + }); + + it('fails closed and names the plugin when it throws', () => { + expect(() => + invokePlugins( + [ + mk('expo', () => { + throw new Error('boom'); + }), + ], + ctx, + ), + ).toThrow(/plugin for 'expo'.*threw: boom/); + }); + + it('rejects a package dep without a path or url+version', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({packageDependencies: [{name: 'X'}]}))], + ctx, + ), + ).toThrow(/needs either a path or a url\+version/); + }); + + it('rejects a product dep missing name or package', () => { + expect(() => + invokePlugins( + [mk('expo', () => ({productDependencies: [{name: 'X'}]}))], + ctx, + ), + ).toThrow(/productDependency needing name \+ package/); + }); + + it('merges valid flavoredFrameworks', () => { + const res = invokePlugins( + [ + mk('expo', () => ({ + flavoredFrameworks: [ + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/o/debug/ExpoModulesCore.xcframework', + release: '/o/release/ExpoModulesCore.xcframework', + }, + }, + ], + })), + ], + ctx, + ); + expect(res.flavoredFrameworks).toEqual([ + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/o/debug/ExpoModulesCore.xcframework', + release: '/o/release/ExpoModulesCore.xcframework', + }, + }, + ]); + }); + + it.each([ + [{id: 'a', frameworkName: 'A', linkage: 'dynamic', flavors: {debug: '/d'}}], + [ + { + id: 'a', + frameworkName: 'A', + linkage: 'static', + flavors: {debug: '/d', release: '/r'}, + }, + ], + [ + { + id: 'a', + frameworkName: 'A', + linkage: 'dynamic', + flavors: {debug: 'relative', release: '/r'}, + }, + ], + ])('rejects malformed or incomplete flavoredFrameworks', entry => { + expect(() => + invokePlugins([mk('expo', () => ({flavoredFrameworks: [entry]}))], ctx), + ).toThrow(/invalid flavoredFramework/); + }); + + it('rejects duplicate embedded framework destinations', () => { + const makeFramework = id => ({ + id, + frameworkName: 'Dup', + linkage: 'dynamic', + flavors: {debug: `/a/${id}`, release: `/b/${id}`}, + }); + expect(() => + invokePlugins( + [ + mk('a', () => ({flavoredFrameworks: [makeFramework('a')]})), + mk('b', () => ({flavoredFrameworks: [makeFramework('b')]})), + ], + ctx, + ), + ).toThrow(/multiple plugins embed 'Dup\.framework'/); + }); + + it('defaults flavoredFrameworks to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.flavoredFrameworks).toEqual([]); + }); + + it('rejects a non-array flavoredFrameworks declaration', () => { + expect(() => + invokePlugins([mk('expo', () => ({flavoredFrameworks: {id: 'X'}}))], ctx), + ).toThrow(/non-array flavoredFrameworks/); + }); + + it('keeps valid absolute watchPaths (dirs or files) across plugins', () => { + const res = invokePlugins( + [ + mk('expo', () => ({ + watchPaths: [ + '/app/node_modules/expo/Package.swift', + '/app/node_modules/expo/expo-module.config.json', + ], + })), + mk('b', () => ({watchPaths: ['/app/node_modules/b']})), + ], + ctx, + ); + expect(res.watchPaths).toEqual([ + '/app/node_modules/expo/Package.swift', + '/app/node_modules/expo/expo-module.config.json', + '/app/node_modules/b', + ]); + }); + + it('defaults watchPaths to [] when no plugin declares any', () => { + const res = invokePlugins([mk('a', () => ({}))], ctx); + expect(res.watchPaths).toEqual([]); + }); + + it('drops relative / empty / non-string watchPaths with a per-entry warning', () => { + const warnings = []; + const res = invokePlugins( + [ + mk('expo', () => ({ + watchPaths: [ + '/app/node_modules/expo/Package.swift', // kept + 'relative/Package.swift', // relative → dropped + '', // empty → dropped + 42, // non-string → dropped + ], + })), + ], + ctx, + {warn: m => warnings.push(m)}, + ); + expect(res.watchPaths).toEqual(['/app/node_modules/expo/Package.swift']); + expect(warnings).toHaveLength(3); + expect(warnings.every(w => /invalid watchPath/.test(w))).toBe(true); + }); + + it('ignores a non-array watchPaths with a warning (never throws)', () => { + const warnings = []; + let res; + expect(() => { + res = invokePlugins( + [mk('expo', () => ({watchPaths: '/app/x'}))], // string, not array + ctx, + {warn: m => warnings.push(m)}, + ); + }).not.toThrow(); + expect(res.watchPaths).toEqual([]); + expect(warnings.some(w => /non-array watchPaths/.test(w))).toBe(true); + }); +}); 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 new file mode 100644 index 000000000000..113bbee91606 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/download-spm-artifacts-test.js @@ -0,0 +1,788 @@ +/** + * 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 { + exists, + extractXCFramework, + findFirst, + formatBytes, + formatSpeed, + hermesReleaseUrl, + resolveCacheSlotVersion, + resolveHermesArtifact, + resolveLatestV1Version, + resolveNightlyVersion, + resolveRNCoreArtifact, + resolveRNDepsArtifact, + resolveSnapshotUrl, + rnCoreReleaseUrl, + rnDepsReleaseUrl, + validateArtifactsCache, +} = require('../download-spm-artifacts'); +const {execSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Shared fetch router used by the URL-resolution tests below. Each key is a +// URL substring; the matched value describes the response. Anything unmatched +// returns 404 (the "release not found, fall back to snapshot" path). +function routerFetch(routes /*: {[string]: any} */) { + return jest.fn(async (url, opts) => { + for (const [key, resp] of Object.entries(routes)) { + if (String(url).includes(key)) { + return { + ok: resp.ok ?? true, + status: resp.status ?? 200, + json: async () => resp.json, + text: async () => resp.text ?? '', + }; + } + } + return { + ok: false, + status: 404, + json: async () => ({}), + text: async () => '', + }; + }); +} + +// --------------------------------------------------------------------------- +// resolveHermesArtifact — hermes uses its own version space, decoupled from +// React Native's nightly cadence. The default behavior mirrors RN's +// CocoaPods prebuild (HERMES_VERSION='latest-v1'): resolve via the +// hermes-compiler npm dist-tag instead of trying to download a hermes-ios +// artifact at the RN nightly version (which won't exist on Maven). +// --------------------------------------------------------------------------- + +describe('resolveHermesArtifact', () => { + let origFetch; + let origHermesEnv; + + beforeEach(() => { + origFetch = globalThis.fetch; + origHermesEnv = process.env.HERMES_VERSION; + delete process.env.HERMES_VERSION; + }); + + afterEach(() => { + globalThis.fetch = origFetch; + if (origHermesEnv !== undefined) { + process.env.HERMES_VERSION = origHermesEnv; + } else { + delete process.env.HERMES_VERSION; + } + }); + + // Mock fetch with a router: each entry's key is a URL substring; the value + // describes the response. Anything not matched returns 404 (mimicking the + // "release not found, try snapshot" path). + function mockFetch(routes /*: {[string]: any} */) { + globalThis.fetch = routerFetch(routes); + } + + describe('default behavior (no HERMES_VERSION set)', () => { + it('resolves to the latest-v1 hermes-compiler dist-tag, NOT the RN version', async () => { + 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}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + null, + ); + 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 () => { + mockFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + 'hermes-ios/0.13.0/hermes-ios-0.13.0': {ok: true}, + }); + // Caller passes the original RN --version verbatim; hermes should + // still default to latest-v1 instead of using this. + const result = await resolveHermesArtifact( + '0.87.0-nightly-20260519-58cd1bf58', + 'debug', + '0.87.0-nightly-20260519-58cd1bf58', + ); + expect(result.version).toBe('0.13.0'); + expect(result.url).not.toContain('20260519'); + }); + }); + + describe('HERMES_VERSION escape hatches', () => { + it('HERMES_VERSION= uses it verbatim', async () => { + process.env.HERMES_VERSION = '0.13.5'; + mockFetch({ + 'hermes-ios/0.13.5/hermes-ios-0.13.5': {ok: true}, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + 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 () => { + process.env.HERMES_VERSION = 'latest-v1'; + 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-anything', + 'debug', + null, + ); + expect(result.version).toBe('0.13.0'); + }); + + it('HERMES_VERSION=nightly resolves hermes-compiler@nightly from npm', async () => { + process.env.HERMES_VERSION = 'nightly'; + 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': { + ok: true, + }, + }); + const result = await resolveHermesArtifact( + '0.87.0-nightly-anything', + 'debug', + null, + ); + 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'; + globalThis.fetch = jest.fn(async (url, opts) => { + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + return { + ok: true, + status: 200, + text: async () => + '20260303.000000' + + '2', + }; + }); + const result = await resolveHermesArtifact('0.87.0', 'debug', null); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('hermes-ios-debug.tar.gz'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Maven URL builders — pure string composition. +// --------------------------------------------------------------------------- + +describe('release URL builders', () => { + it('rnCoreReleaseUrl points at the reactnative-core classifier on Maven Central', () => { + const url = rnCoreReleaseUrl('0.85.0', 'debug'); + expect(url).toBe( + 'https://repo1.maven.org/maven2/com/facebook/react/react-native-artifacts/0.85.0/' + + 'react-native-artifacts-0.85.0-reactnative-core-debug.tar.gz', + ); + }); + + it('rnDepsReleaseUrl points at the reactnative-dependencies classifier', () => { + const url = rnDepsReleaseUrl('0.85.0', 'release'); + expect(url).toContain('react-native-artifacts/0.85.0/'); + expect(url).toContain('reactnative-dependencies-release.tar.gz'); + }); + + it('hermesReleaseUrl points at the hermes-ios coordinate', () => { + const url = hermesReleaseUrl('0.13.0', 'debug'); + expect(url).toBe( + 'https://repo1.maven.org/maven2/com/facebook/hermes/hermes-ios/0.13.0/' + + 'hermes-ios-0.13.0-hermes-ios-debug.tar.gz', + ); + }); + + it('honors ENTERPRISE_REPOSITORY for the release base URL', () => { + jest.isolateModules(() => { + const prev = process.env.ENTERPRISE_REPOSITORY; + process.env.ENTERPRISE_REPOSITORY = 'https://maven.internal.example'; + try { + const mod = require('../download-spm-artifacts'); + expect(mod.rnCoreReleaseUrl('0.85.0', 'debug')).toContain( + 'https://maven.internal.example/com/facebook/react/', + ); + } finally { + if (prev !== undefined) { + process.env.ENTERPRISE_REPOSITORY = prev; + } else { + delete process.env.ENTERPRISE_REPOSITORY; + } + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// formatBytes / formatSpeed — pure formatting with a 1 MB unit boundary. +// --------------------------------------------------------------------------- + +describe('formatBytes', () => { + it('renders sub-megabyte sizes in KB', () => { + expect(formatBytes(512)).toBe('0.5 KB'); + expect(formatBytes(1024)).toBe('1.0 KB'); + }); + + it('renders megabyte-and-larger sizes in MB', () => { + expect(formatBytes(1024 * 1024)).toBe('1.0 MB'); + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB'); + }); +}); + +describe('formatSpeed', () => { + it('renders sub-megabyte rates in KB/s (no decimals)', () => { + expect(formatSpeed(2048)).toBe('2 KB/s'); + }); + + it('renders megabyte-and-larger rates in MB/s', () => { + expect(formatSpeed(5 * 1024 * 1024)).toBe('5.0 MB/s'); + }); +}); + +// --------------------------------------------------------------------------- +// exists — HEAD probe used to choose release vs. snapshot. +// --------------------------------------------------------------------------- + +describe('exists', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('returns true on a 200 HEAD response', async () => { + globalThis.fetch = jest.fn(async () => ({status: 200})); + expect(await exists('https://example/x.tar.gz')).toBe(true); + expect(globalThis.fetch).toHaveBeenCalledWith('https://example/x.tar.gz', { + method: 'HEAD', + }); + }); + + it('returns false on a non-200 response', async () => { + globalThis.fetch = jest.fn(async () => ({status: 404})); + expect(await exists('https://example/missing.tar.gz')).toBe(false); + }); + + it('returns false when fetch rejects (offline)', async () => { + globalThis.fetch = jest.fn(async () => { + throw new Error('network down'); + }); + expect(await exists('https://example/x.tar.gz')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resolveSnapshotUrl — parses maven-metadata.xml into a timestamped URL. +// --------------------------------------------------------------------------- + +describe('resolveSnapshotUrl', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + const METADATA = ` + + + 20260101.123456 + 7 + + `; + + it('builds a fully-versioned snapshot URL from timestamp + buildNumber', async () => { + globalThis.fetch = routerFetch({'maven-metadata.xml': {text: METADATA}}); + const url = await resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'reactnative-core-debug.tar.gz', + ); + expect(url).toContain('0.85.0-SNAPSHOT/'); + expect(url).toContain( + 'react-native-artifacts-0.85.0-20260101.123456-7-reactnative-core-debug.tar.gz', + ); + }); + + it('throws when the metadata request fails', async () => { + globalThis.fetch = routerFetch({ + 'maven-metadata.xml': {ok: false, status: 500}, + }); + await expect( + resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'x.tar.gz', + ), + ).rejects.toThrow(/Failed to fetch snapshot metadata/); + }); + + it('throws when timestamp/buildNumber are absent', async () => { + globalThis.fetch = routerFetch({ + 'maven-metadata.xml': {text: ''}, + }); + await expect( + resolveSnapshotUrl( + '0.85.0', + 'react', + 'react-native-artifacts', + 'x.tar.gz', + ), + ).rejects.toThrow(/Could not parse timestamp\/buildNumber/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveNightlyVersion / resolveLatestV1Version — npm dist-tag lookups. +// --------------------------------------------------------------------------- + +describe('npm dist-tag resolvers', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('resolveNightlyVersion returns the version from the npm registry', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-xyz'}}, + }); + expect(await resolveNightlyVersion('react-native')).toBe( + '0.86.0-nightly-xyz', + ); + }); + + it('resolveNightlyVersion throws on a failed npm lookup', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {ok: false, status: 404}, + }); + await expect(resolveNightlyVersion('react-native')).rejects.toThrow( + /npm lookup failed/, + ); + }); + + it('resolveLatestV1Version reads hermes-compiler/latest-v1', async () => { + globalThis.fetch = routerFetch({ + 'hermes-compiler/latest-v1': {json: {version: '0.13.0'}}, + }); + expect(await resolveLatestV1Version()).toBe('0.13.0'); + }); + + it('resolveLatestV1Version throws on a failed lookup', async () => { + globalThis.fetch = routerFetch({ + 'hermes-compiler/latest-v1': {ok: false, status: 500}, + }); + await expect(resolveLatestV1Version()).rejects.toThrow(/npm lookup failed/); + }); +}); + +// --------------------------------------------------------------------------- +// resolveRNCoreArtifact / resolveRNDepsArtifact — release-then-snapshot. +// --------------------------------------------------------------------------- + +describe('resolveRNCoreArtifact', () => { + let origFetch; + let tempDir; + beforeEach(() => { + origFetch = globalThis.fetch; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-core-')); + }); + afterEach(() => { + globalThis.fetch = origFetch; + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('uses the stable release URL when it exists', async () => { + globalThis.fetch = routerFetch({ + 'reactnative-core-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.version).toBe('0.85.0'); + expect(result.url).toContain('repo1.maven.org'); + expect(result.url).toContain('reactnative-core-debug.tar.gz'); + }); + + it('falls back to the snapshot URL when the release is missing', async () => { + globalThis.fetch = jest.fn(async (url, opts) => { + // HEAD probe of the release URL → 404. + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + // GET of maven-metadata.xml → snapshot coordinates. + return { + ok: true, + status: 200, + text: async () => + '20260101.000000' + + '1', + }; + }); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', null); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('20260101.000000-1'); + }); + + it('uses a local tarball override when the file exists', async () => { + const tarball = path.join(tempDir, 'core.tar.gz'); + fs.writeFileSync(tarball, 'x'); + const result = await resolveRNCoreArtifact('0.85.0', 'debug', tarball); + expect(result.url).toBe(tarball); + expect(result.version).toBe('0.85.0-local'); + }); + + it('throws when the local tarball override is missing', async () => { + await expect( + resolveRNCoreArtifact( + '0.85.0', + 'debug', + path.join(tempDir, 'nope.tar.gz'), + ), + ).rejects.toThrow(/does not exist/); + }); +}); + +describe('resolveRNDepsArtifact', () => { + let origFetch; + let origDepEnv; + beforeEach(() => { + origFetch = globalThis.fetch; + origDepEnv = process.env.RN_DEP_VERSION; + delete process.env.RN_DEP_VERSION; + }); + afterEach(() => { + globalThis.fetch = origFetch; + if (origDepEnv !== undefined) { + process.env.RN_DEP_VERSION = origDepEnv; + } else { + delete process.env.RN_DEP_VERSION; + } + }); + + it('uses the RN version by default', async () => { + globalThis.fetch = routerFetch({ + 'reactnative-dependencies-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.85.0'); + expect(result.url).toContain('react-native-artifacts/0.85.0/'); + }); + + it('honors RN_DEP_VERSION override', async () => { + process.env.RN_DEP_VERSION = '0.84.2'; + globalThis.fetch = routerFetch({ + 'reactnative-dependencies-debug.tar.gz': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.84.2'); + expect(result.url).toContain('react-native-artifacts/0.84.2/'); + }); + + it('uses a local deps tarball override when the file exists', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deps-local-')); + try { + const tarball = path.join(dir, 'deps.tar.gz'); + fs.writeFileSync(tarball, 'x'); + const result = await resolveRNDepsArtifact('0.85.0', 'debug', tarball); + expect(result.url).toBe(tarball); + expect(result.version).toBe('0.85.0-local'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('throws when the local deps tarball override is missing', async () => { + await expect( + resolveRNDepsArtifact( + '0.85.0', + 'debug', + path.join(os.tmpdir(), 'spm-deps-nope', 'nope.tar.gz'), + ), + ).rejects.toThrow(/does not exist/); + }); + + it('resolves RN_DEP_VERSION=nightly via the npm registry', async () => { + process.env.RN_DEP_VERSION = 'nightly'; + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-dep'}}, + 'react-native-artifacts/0.86.0-nightly-dep/': {status: 200}, + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.version).toBe('0.86.0-nightly-dep'); + }); + + it('falls back to the deps snapshot URL when the release is missing', async () => { + globalThis.fetch = jest.fn(async (url, opts) => { + if (opts && opts.method === 'HEAD') { + return {status: 404}; + } + return { + ok: true, + status: 200, + text: async () => + '20260202.000000' + + '3', + }; + }); + const result = await resolveRNDepsArtifact('0.85.0', 'debug'); + expect(result.url).toContain('maven-snapshots'); + expect(result.url).toContain('reactnative-dependencies-debug.tar.gz'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCacheSlotVersion — stable label passthrough vs. nightly resolution. +// --------------------------------------------------------------------------- + +describe('resolveCacheSlotVersion', () => { + let origFetch; + beforeEach(() => { + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + it('returns a stable version label unchanged (no npm lookup)', async () => { + globalThis.fetch = jest.fn(async () => { + throw new Error('should not be called'); + }); + expect(await resolveCacheSlotVersion('0.85.0')).toBe('0.85.0'); + }); + + it('resolves the 1000.0.0 dev label to the current nightly slot', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {json: {version: '0.86.0-nightly-zzz'}}, + }); + expect(await resolveCacheSlotVersion('1000.0.0')).toBe( + '0.86.0-nightly-zzz', + ); + }); + + it('falls back to the raw label when the nightly lookup fails', async () => { + globalThis.fetch = routerFetch({ + 'react-native/nightly': {ok: false, status: 503}, + }); + expect(await resolveCacheSlotVersion('nightly')).toBe('nightly'); + }); +}); + +// --------------------------------------------------------------------------- +// findFirst — bounded recursive search. +// --------------------------------------------------------------------------- + +describe('findFirst', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-findfirst-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds a nested entry matching the predicate', () => { + const nested = path.join(tempDir, 'a', 'b'); + fs.mkdirSync(nested, {recursive: true}); + fs.writeFileSync(path.join(nested, 'Target.xcframework'), ''); + const hit = findFirst(tempDir, n => n.endsWith('.xcframework'), 8); + expect(hit).toBe(path.join(nested, 'Target.xcframework')); + }); + + it('returns null when depth is exhausted before the match', () => { + const nested = path.join(tempDir, 'a', 'b', 'c'); + fs.mkdirSync(nested, {recursive: true}); + fs.writeFileSync(path.join(nested, 'Target.xcframework'), ''); + // depth 1 only inspects the immediate children of tempDir. + expect(findFirst(tempDir, n => n.endsWith('.xcframework'), 1)).toBeNull(); + }); + + it('returns null for a nonexistent directory', () => { + expect(findFirst(path.join(tempDir, 'ghost'), () => true, 4)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractXCFramework — untar + locate the .xcframework dir. +// --------------------------------------------------------------------------- + +describe('extractXCFramework', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-extract-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('extracts a tarball and returns the contained .xcframework path', () => { + // Build a tarball whose payload contains a Foo.xcframework directory. + const payload = path.join(tempDir, 'payload'); + const xcfw = path.join(payload, 'Foo.xcframework'); + fs.mkdirSync(xcfw, {recursive: true}); + fs.writeFileSync(path.join(xcfw, 'Info.plist'), ''); + const tarPath = path.join(tempDir, 'foo.tar.gz'); + execSync(`tar -czf "${tarPath}" -C "${payload}" Foo.xcframework`); + + const extractDir = path.join(tempDir, 'out'); + const found = extractXCFramework(tarPath, extractDir); + expect(found).toBe(path.join(extractDir, 'Foo.xcframework')); + expect(fs.existsSync(path.join(found, 'Info.plist'))).toBe(true); + }); + + it('throws when the tarball contains no .xcframework', () => { + const payload = path.join(tempDir, 'payload'); + fs.mkdirSync(payload, {recursive: true}); + fs.writeFileSync(path.join(payload, 'readme.txt'), 'hi'); + const tarPath = path.join(tempDir, 'plain.tar.gz'); + execSync(`tar -czf "${tarPath}" -C "${payload}" readme.txt`); + + expect(() => + extractXCFramework(tarPath, path.join(tempDir, 'out')), + ).toThrow(/No .xcframework found/); + }); +}); + +// --------------------------------------------------------------------------- +// validateArtifactsCache — guards against stale / partial cache slots. +// --------------------------------------------------------------------------- + +describe('validateArtifactsCache', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-validate-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + // Writes artifacts.json plus the on-disk xcframework dir for each entry. + function seedCache(entries /*: {[string]: boolean} */) { + const json = {}; + for (const [name, onDisk] of Object.entries(entries)) { + const xcfwPath = path.join(tempDir, `${name}.xcframework`); + if (onDisk) { + fs.mkdirSync(xcfwPath, {recursive: true}); + } + json[name] = {xcframeworkPath: xcfwPath, url: 'https://example'}; + } + fs.writeFileSync( + path.join(tempDir, 'artifacts.json'), + JSON.stringify(json), + 'utf8', + ); + } + + // Stages the hermes public headers that validateArtifactsCache also requires. + function seedHermesHeaders() { + fs.mkdirSync(path.join(tempDir, 'hermes-headers', 'hermes'), { + recursive: true, + }); + } + + it('returns null when the cache is complete and on disk', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + 'hermes-engine': true, + }); + seedHermesHeaders(); + expect(validateArtifactsCache(tempDir)).toBeNull(); + }); + + it('reports unstaged Hermes public headers', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + 'hermes-engine': true, + }); + // No hermes-headers/hermes dir. + expect(validateArtifactsCache(tempDir)).toMatch( + /Hermes public headers not staged/, + ); + }); + + it('reports a missing headers companion (pure-RN split)', () => { + // ReactNativeDependenciesHeaders is the deps sidecar — without it no + // -style include resolves (ReactNativeHeaders is pure-RN). + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + 'hermes-engine': true, + }); + seedHermesHeaders(); + expect(validateArtifactsCache(tempDir)).toMatch( + /missing entry for "ReactNativeDependenciesHeaders"/, + ); + }); + + it('reports a missing artifacts.json', () => { + expect(validateArtifactsCache(tempDir)).toMatch(/artifacts.json missing/); + }); + + it('reports unreadable JSON', () => { + fs.writeFileSync(path.join(tempDir, 'artifacts.json'), '{not json', 'utf8'); + expect(validateArtifactsCache(tempDir)).toMatch(/unreadable/); + }); + + it('reports a missing required entry', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + }); + expect(validateArtifactsCache(tempDir)).toMatch( + /missing entry for "hermes-engine"/, + ); + }); + + it('reports an entry whose xcframework dir is gone', () => { + seedCache({ + React: true, + ReactNativeHeaders: true, + ReactNativeDependencies: true, + ReactNativeDependenciesHeaders: true, + 'hermes-engine': false, + }); + expect(validateArtifactsCache(tempDir)).toMatch( + /xcframework for "hermes-engine" not found/, + ); + }); +}); 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 new file mode 100644 index 000000000000..49e91fdd6404 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/expand-spm-dependencies-test.js @@ -0,0 +1,393 @@ +/** + * 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'; + +/** + * Red tests for the transitive dependency expander. + * + * Library authors declare transitive native deps in their react-native.config.js: + * + * module.exports = { + * dependency: { platforms: { ios: {} } }, + * spm: { dependencies: ['react-native-test-library-common'] }, + * }; + * + * The expander takes the directly-autolinked deps (from autolinking.json) and + * follows each one's spm.dependencies recursively, resolving names to package + * roots via Node module resolution. Behavior mirrors podspec `s.dependency`: + * + * - Transitive deps with iOS native code → added as autolinked targets + * - Transitive deps without iOS native code → silently skipped + * - Deduped by package name (first occurrence wins) + * - Cycles are detected (visited set keyed on name) + * - Unresolvable names throw with a clear message + * + * I/O is injected (readConfig, resolveDep) so the tests stay pure. + */ + +const { + expandSpmDependencies, + resolveSwiftName, +} = require('../expand-spm-dependencies'); +const {toSwiftName} = require('../spm-utils'); + +function makeReadConfig(configs /*: {[string]: ?Object} */) { + return (root /*: string */) => + Object.prototype.hasOwnProperty.call(configs, root) ? configs[root] : null; +} + +function makeResolveDep(resolutions /*: {[string]: ?string} */) { + return (name /*: string */) => + Object.prototype.hasOwnProperty.call(resolutions, name) + ? resolutions[name] + : null; +} + +// --------------------------------------------------------------------------- +// expandSpmDependencies +// --------------------------------------------------------------------------- + +describe('expandSpmDependencies', () => { + it('returns direct deps with auto-derived swiftName when none declare spm.dependencies', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {}}), + resolveDep: makeResolveDep({}), + }); + expect(result).toEqual([ + {...direct[0], swiftName: toSwiftName('a'), spmDependencies: []}, + ]); + }); + + it('pulls in one transitive dep declared by a direct dep', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common'}), + }); + expect(result.map(d => d.name)).toEqual(['apple', 'common']); + expect(result[1].root).toBe('/common'); + expect(result[1].platforms.ios).toBeDefined(); + }); + + it('recurses through a chain (A → B → C)', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['b']}}, + '/b': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['c']}, + }, + '/c': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({b: '/b', c: '/c'}), + }); + expect(result.map(d => d.name)).toEqual(['a', 'b', 'c']); + }); + + it('handles cycles without infinite recursion (A → B → A)', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['b']}, + }, + '/b': { + dependency: {platforms: {ios: {}}}, + spm: {dependencies: ['a']}, + }, + }), + resolveDep: makeResolveDep({a: '/a', b: '/b'}), + }); + expect(result.map(d => d.name).sort()).toEqual(['a', 'b']); + }); + + it('dedups a diamond (A → X, B → X) — X appears exactly once', () => { + const direct = [ + {name: 'a', root: '/a', platforms: {ios: {}}}, + {name: 'b', root: '/b', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['x']}}, + '/b': {spm: {dependencies: ['x']}}, + '/x': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({x: '/x'}), + }); + expect(result.filter(d => d.name === 'x')).toHaveLength(1); + expect(result.map(d => d.name).sort()).toEqual(['a', 'b', 'x']); + }); + + it('throws with a clear message when a declared transitive cannot be resolved', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['ghost']}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/ghost.*apple|apple.*ghost/i); + }); + + it('silently skips transitives that have no iOS native code (matches autolinkingDepToSpmTarget behavior)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['js-only']}}, + // js-only has no dependency.platforms.ios — pure JS package + '/js-only': {}, + }), + resolveDep: makeResolveDep({'js-only': '/js-only'}), + }); + expect(result.map(d => d.name)).toEqual(['apple']); + }); + + it('does not re-add a transitive that is already a direct dep (first occurrence wins)', () => { + const direct = [ + {name: 'apple', root: '/apple', platforms: {ios: {}}}, + {name: 'common', root: '/common-direct', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common-other': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common-other'}), + }); + expect(result.filter(d => d.name === 'common')).toHaveLength(1); + // The direct-dep entry should be preserved, not overwritten by the transitive + expect(result.find(d => d.name === 'common').root).toBe('/common-direct'); + }); + + // ------------------------------------------------------------------------- + // spmDependencies field: each entry should carry the names of its iOS-native + // transitive deps, so the downstream emitter can wire SPM target-level deps + // (e.g. apple's .target(dependencies: [.target(name: "...Common")])). + // ------------------------------------------------------------------------- + + it('attaches spmDependencies: [] when the dep declares none', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + const [a] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {}}), + resolveDep: makeResolveDep({}), + }); + expect(a.spmDependencies).toEqual([]); + }); + + it('attaches spmDependencies with the declared transitive names (preserving declaration order)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const [apple, common] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common', 'extra']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + '/extra': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({common: '/common', extra: '/extra'}), + }); + expect(apple.spmDependencies).toEqual(['common', 'extra']); + expect(common.spmDependencies).toEqual([]); + }); + + it('omits JS-only transitives from spmDependencies (only iOS-native names appear)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + const [apple] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['js-only', 'common']}}, + '/js-only': {}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({'js-only': '/js-only', common: '/common'}), + }); + expect(apple.spmDependencies).toEqual(['common']); + }); + + it('records spmDependencies on both sides of a diamond (A→X, B→X)', () => { + const direct = [ + {name: 'a', root: '/a', platforms: {ios: {}}}, + {name: 'b', root: '/b', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/a': {spm: {dependencies: ['x']}}, + '/b': {spm: {dependencies: ['x']}}, + '/x': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: makeResolveDep({x: '/x'}), + }); + const a = result.find(d => d.name === 'a'); + const b = result.find(d => d.name === 'b'); + expect(a.spmDependencies).toEqual(['x']); + expect(b.spmDependencies).toEqual(['x']); + }); + + it('passes the declaring dep root as the second argument to resolveDep (for Node resolution paths)', () => { + const direct = [{name: 'apple', root: '/apple', platforms: {ios: {}}}]; + let receivedFromRoot /*: ?string */ = null; + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/apple': {spm: {dependencies: ['common']}}, + '/common': {dependency: {platforms: {ios: {}}}}, + }), + resolveDep: (name, fromRoot) => { + if (name === 'common') { + receivedFromRoot = fromRoot; + return '/common'; + } + return null; + }, + }); + expect(receivedFromRoot).toBe('/apple'); + }); + + // ------------------------------------------------------------------------- + // swiftName resolution: each dep gets a Swift target name on the way out. + // Default is toSwiftName(npmName); the dep's react-native.config.js + // `spm.name` overrides it. Required for libraries whose import prefix + // differs from the auto-derived name (e.g. `react-native-worklets` + // publishes headers under ``). + // ------------------------------------------------------------------------- + + it('populates swiftName via toSwiftName when no spm.name override is set', () => { + const direct = [ + {name: 'react-native-foo', root: '/foo', platforms: {ios: {}}}, + ]; + const [foo] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/foo': {}}), + resolveDep: makeResolveDep({}), + }); + expect(foo.swiftName).toBe(toSwiftName('react-native-foo')); + expect(foo.swiftName).toBe('ReactNativeFoo'); + }); + + it('uses spm.name as swiftName when the direct dep declares one', () => { + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + ]; + const [w] = expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/w': {spm: {name: 'worklets'}}}), + resolveDep: makeResolveDep({}), + }); + expect(w.swiftName).toBe('worklets'); + }); + + it('applies spm.name override to transitive deps too', () => { + const direct = [ + {name: 'react-native-reanimated', root: '/r', platforms: {ios: {}}}, + ]; + const result = expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/r': { + dependency: {platforms: {ios: {}}}, + spm: {name: 'reanimated', dependencies: ['react-native-worklets']}, + }, + '/w': { + dependency: {platforms: {ios: {}}}, + spm: {name: 'worklets'}, + }, + }), + resolveDep: makeResolveDep({'react-native-worklets': '/w'}), + }); + const reanimated = result.find(d => d.name === 'react-native-reanimated'); + const worklets = result.find(d => d.name === 'react-native-worklets'); + expect(reanimated.swiftName).toBe('reanimated'); + expect(worklets.swiftName).toBe('worklets'); + }); + + it('throws on swiftName collision between two deps (override vs auto-derived)', () => { + // 'react-native-worklets' would auto-derive to 'ReactNativeWorklets', but + // here a second dep overrides its spm.name to that same value. + 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(/ReactNativeWorklets/); + }); + + 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. + const direct = [ + {name: 'react-native-worklets', root: '/w', platforms: {ios: {}}}, + {name: 'other-worklets', root: '/o', platforms: {ios: {}}}, + ]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({ + '/w': {spm: {name: 'worklets'}}, + '/o': {spm: {name: 'Worklets'}}, + }), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/case/i); + }); + + it('rejects empty-string spm.name with a clear error citing the npm name', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {spm: {name: ''}}}), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/'a' has an invalid 'spm.name'/); + }); + + it('rejects non-string spm.name (e.g. number, object) with a clear error', () => { + const direct = [{name: 'a', root: '/a', platforms: {ios: {}}}]; + expect(() => + expandSpmDependencies(direct, { + readConfig: makeReadConfig({'/a': {spm: {name: 42}}}), + resolveDep: makeResolveDep({}), + }), + ).toThrow(/invalid 'spm.name'/); + }); + + it('rejects spm.name with disallowed characters (spaces, slashes, dots)', () => { + expect(() => resolveSwiftName('a', {spm: {name: 'foo bar'}})).toThrow( + /invalid 'spm.name'/, + ); + expect(() => resolveSwiftName('a', {spm: {name: 'foo/bar'}})).toThrow( + /invalid 'spm.name'/, + ); + expect(() => resolveSwiftName('a', {spm: {name: 'foo.bar'}})).toThrow( + /invalid 'spm.name'/, + ); + }); + + it('accepts lowercase-with-hyphen and CamelCase spm.name values', () => { + expect(resolveSwiftName('a', {spm: {name: 'reanimated'}})).toBe( + 'reanimated', + ); + expect(resolveSwiftName('a', {spm: {name: 'hermes-engine'}})).toBe( + 'hermes-engine', + ); + expect(resolveSwiftName('a', {spm: {name: 'RNWorklets'}})).toBe( + 'RNWorklets', + ); + expect(resolveSwiftName('a', {spm: {name: 'react_native_foo'}})).toBe( + 'react_native_foo', + ); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js new file mode 100644 index 000000000000..303e5852bc18 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-plist-dep-test.js @@ -0,0 +1,102 @@ +/** + * 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'; + +// Regression test for the fresh-consumer-app failure: `plist` is NOT a +// dependency of the react-native package, so it only resolves inside this +// monorepo via hoisting. A generated app running `spm add` gets +// "Cannot find module 'plist'". flavored-frameworks.js must therefore never +// require it — Info.plist parsing goes through plutil instead. +jest.mock('plist', () => { + throw new Error( + "Cannot find module 'plist' — flavored-frameworks.js must not depend on it", + ); +}); + +// plutil is macOS-only; stand in with a portable plist-parse so the suite is +// hermetic on Linux CI. Module-level mock because flavored-frameworks.js +// destructures execFileSync at require time (same pattern the old +// swap-flavor-test used). +jest.mock('child_process', () => { + const actual = jest.requireActual<$FlowFixMe>('child_process'); + return { + ...actual, + execFileSync: (cmd, args, opts) => { + if (cmd === 'plutil') { + const fs = require('fs'); + const plist = jest.requireActual<$FlowFixMe>('plist'); + const file = args[args.length - 1]; + return Buffer.from( + JSON.stringify(plist.parse(fs.readFileSync(file, 'utf8'))), + ); + } + return actual.execFileSync(cmd, args, opts); + }, + }; +}); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const realPlist = jest.requireActual<$FlowFixMe>('plist'); + +function makeXcframework(root /*: string */) /*: string */ { + const xcframework = path.join(root, 'React.xcframework'); + const sliceId = 'ios-arm64'; + fs.mkdirSync(path.join(xcframework, sliceId, 'React.framework'), { + recursive: true, + }); + fs.writeFileSync( + path.join(xcframework, sliceId, 'React.framework', 'React'), + 'not-a-real-mach-o', + ); + fs.writeFileSync( + path.join(xcframework, 'Info.plist'), + realPlist.build({ + AvailableLibraries: [ + { + LibraryIdentifier: sliceId, + LibraryPath: 'React.framework', + SupportedPlatform: 'ios', + SupportedArchitectures: ['arm64'], + }, + ], + CFBundlePackageType: 'XFWK', + XCFrameworkFormatVersion: '1.0', + }), + ); + return xcframework; +} + +describe('flavored-frameworks without the plist module', () => { + let tmp; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ff-plist-dep-')); + }); + + afterEach(() => { + fs.rmSync(tmp, {recursive: true, force: true}); + }); + + it('loads without requiring plist', () => { + expect(() => require('../flavored-frameworks')).not.toThrow(); + }); + + it('parses an xcframework Info.plist via plutil', () => { + const {parseXcframework} = require('../flavored-frameworks'); + const parsed = parseXcframework(makeXcframework(tmp)); + expect(parsed.slices).toHaveLength(1); + expect(parsed.slices[0].libraryIdentifier).toBe('ios-arm64'); + expect(parsed.slices[0].platform).toBe('ios'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js new file mode 100644 index 000000000000..ab537391e5a4 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/flavored-frameworks-test.js @@ -0,0 +1,439 @@ +/** + * 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'; + +// flavored-frameworks.js shells out to `plutil` to read xcframework +// Info.plists as JSON — plutil is macOS-only, so stand in with a portable +// plist-parse for Linux CI. Module-level mock because the module destructures +// `execFileSync` at require time (same pattern as the old swap-flavor-test). +jest.mock('child_process', () => { + const actual = jest.requireActual('child_process'); + return { + ...actual, + execFileSync: (cmd, args, opts) => { + if (cmd === 'plutil') { + const fsActual = require('fs'); + const plistActual = jest.requireActual('plist'); + const file = args[args.length - 1]; + return Buffer.from( + JSON.stringify( + plistActual.parse(fsActual.readFileSync(file, 'utf8')), + ), + ); + } + return actual.execFileSync(cmd, args, opts); + }, + }; +}); + +const { + PLUGIN_FRAMEWORKS_MANIFEST, + finalizeArtifactPublication, + prepareFlavoredFrameworks, + sdkConditionForSlice, + transformReactModuleMap, +} = require('../flavored-frameworks'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const plist = require('plist'); + +const BUILTINS = [ + ['React', 'React'], + ['ReactNativeDependencies', 'ReactNativeDependencies'], + ['hermes-engine', 'hermesvm'], +]; + +function writeFrameworkXcframework( + root, + artifactName, + frameworkName, + options = {}, +) { + const xcframework = path.join(root, `${artifactName}.xcframework`); + const architectures = options.architectures ?? ['arm64', 'x86_64']; + const identifier = options.identifier ?? 'ios-arm64_x86_64-simulator'; + const framework = path.join( + xcframework, + identifier, + `${frameworkName}.framework`, + ); + fs.mkdirSync(path.join(framework, 'Headers'), {recursive: true}); + fs.mkdirSync(path.join(framework, 'Modules'), {recursive: true}); + fs.writeFileSync( + path.join(framework, 'Headers', `${frameworkName}.h`), + options.header ?? `void ${frameworkName}Header(void);\n`, + ); + fs.writeFileSync( + path.join(framework, 'Modules', 'module.modulemap'), + `framework module ${frameworkName} {\n umbrella header "${frameworkName}.h"\n export *\n}\n`, + ); + if (options.swiftInterface != null) { + fs.writeFileSync( + path.join(framework, 'Modules', `${frameworkName}.swiftinterface`), + options.swiftInterface, + ); + } + fs.writeFileSync(path.join(framework, frameworkName), 'not-a-real-mach-o'); + fs.writeFileSync( + path.join(xcframework, 'Info.plist'), + plist.build({ + CFBundlePackageType: 'XFWK', + XCFrameworkFormatVersion: '1.0', + AvailableLibraries: [ + { + LibraryIdentifier: identifier, + LibraryPath: `${frameworkName}.framework`, + SupportedArchitectures: architectures, + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + }, + ], + }), + ); + return xcframework; +} + +function writeHeadersXcframework(root, name, header = 'void Header(void);\n') { + const xcframework = path.join(root, `${name}.xcframework`); + const identifier = 'ios-arm64_x86_64-simulator'; + const headers = path.join(xcframework, identifier, 'Headers'); + fs.mkdirSync(headers, {recursive: true}); + fs.writeFileSync(path.join(headers, `${name}.h`), header); + fs.writeFileSync( + path.join(xcframework, 'Info.plist'), + plist.build({ + CFBundlePackageType: 'XFWK', + XCFrameworkFormatVersion: '1.0', + AvailableLibraries: [ + { + LibraryIdentifier: identifier, + LibraryPath: `lib${name}.a`, + HeadersPath: 'Headers', + SupportedArchitectures: ['arm64', 'x86_64'], + SupportedPlatform: 'ios', + SupportedPlatformVariant: 'simulator', + }, + ], + }), + ); + return xcframework; +} + +function writeArtifactSlot(root, options = {}) { + fs.mkdirSync(root, {recursive: true}); + const artifacts = {}; + for (const [artifactName, frameworkName] of BUILTINS) { + if (options.missing === artifactName) { + continue; + } + artifacts[artifactName] = { + xcframeworkPath: writeFrameworkXcframework( + root, + artifactName, + frameworkName, + artifactName === 'React' + ? { + header: options.reactHeader, + swiftInterface: options.reactSwiftInterface, + identifier: options.reactIdentifier, + architectures: options.reactArchitectures, + } + : {}, + ), + }; + } + for (const name of ['ReactNativeHeaders', 'ReactNativeDependenciesHeaders']) { + if (options.missing === name) { + continue; + } + artifacts[name] = { + xcframeworkPath: writeHeadersXcframework( + root, + name, + options[`${name}Header`], + ), + }; + } + fs.writeFileSync( + path.join(root, 'artifacts.json'), + `${JSON.stringify(artifacts, null, 2)}\n`, + ); + return artifacts; +} + +describe('flavored framework preparation', () => { + let root; + let appRoot; + let debugRoot; + let releaseRoot; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-flavored-frameworks-')); + appRoot = path.join(root, 'app'); + debugRoot = path.join(root, 'artifacts', 'debug'); + releaseRoot = path.join(root, 'artifacts', 'release'); + fs.mkdirSync(appRoot, {recursive: true}); + }); + + afterEach(() => fs.rmSync(root, {recursive: true, force: true})); + + function prepare() { + return prepareFlavoredFrameworks({ + appRoot, + debugArtifactsDir: debugRoot, + releaseArtifactsDir: releaseRoot, + validateBinaries: false, + }); + } + + it('publishes immutable Debug/Release slots and invariant React headers', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + + const manifest = prepare(); + const packageRoot = path.join(appRoot, 'build', 'xcframeworks'); + expect(manifest.frameworks.map(f => f.frameworkName)).toEqual([ + 'React', + 'ReactNativeDependencies', + 'hermesvm', + ]); + for (const flavor of ['debug', 'release']) { + expect( + fs + .lstatSync(path.join(packageRoot, flavor, 'React.xcframework')) + .isSymbolicLink(), + ).toBe(true); + } + expect( + fs.readFileSync( + path.join( + packageRoot, + 'ReactHeadersTarget', + 'include', + 'module.modulemap', + ), + 'utf8', + ), + ).toContain('module React'); + expect( + fs.existsSync( + path.join( + packageRoot, + 'ReactHeadersTarget', + 'include', + 'React', + 'React.h', + ), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(packageRoot, '.artifact-stamp.pending')), + ).toBe(true); + expect(fs.existsSync(path.join(packageRoot, '.artifact-stamp'))).toBe( + false, + ); + expect(finalizeArtifactPublication(appRoot)).toBe(true); + expect(fs.existsSync(path.join(packageRoot, '.artifact-stamp'))).toBe(true); + expect( + fs.existsSync(path.join(packageRoot, '.artifact-stamp.pending')), + ).toBe(false); + }); + + it('is byte-identical when repeated with unchanged artifacts', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + prepare(); + finalizeArtifactPublication(appRoot); + const manifestPath = path.join( + appRoot, + 'build', + 'xcframeworks', + 'flavored-frameworks.json', + ); + const first = fs.readFileSync(manifestPath); + const reactLink = path.join( + appRoot, + 'build', + 'xcframeworks', + 'debug', + 'React.xcframework', + ); + const inode = fs.lstatSync(reactLink).ino; + prepare(); + expect(fs.readFileSync(manifestPath)).toEqual(first); + expect(fs.lstatSync(reactLink).ino).toBe(inode); + }); + + it('changes the artifact stamp when a selected binary changes in place', () => { + const debug = writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + prepare(); + finalizeArtifactPublication(appRoot); + const stampPath = path.join( + appRoot, + 'build', + 'xcframeworks', + '.artifact-stamp', + ); + const first = fs.readFileSync(stampPath, 'utf8'); + const binary = path.join( + debug.React.xcframeworkPath, + 'ios-arm64_x86_64-simulator', + 'React.framework', + 'React', + ); + fs.appendFileSync(binary, '-updated'); + prepare(); + finalizeArtifactPublication(appRoot); + expect(fs.readFileSync(stampPath, 'utf8')).not.toBe(first); + }); + + it('preserves the last complete publication when new artifacts fail validation', () => { + writeArtifactSlot(debugRoot); + const release = writeArtifactSlot(releaseRoot); + prepare(); + finalizeArtifactPublication(appRoot); + const manifestPath = path.join( + appRoot, + 'build', + 'xcframeworks', + 'flavored-frameworks.json', + ); + const first = fs.readFileSync(manifestPath, 'utf8'); + fs.writeFileSync( + path.join( + release.React.xcframeworkPath, + 'ios-arm64_x86_64-simulator', + 'React.framework', + 'Headers', + 'React.h', + ), + 'void BrokenReleaseHeaders(void);\n', + ); + expect(prepare).toThrow(/different Debug and Release headers/); + expect(fs.readFileSync(manifestPath, 'utf8')).toBe(first); + }); + + it('fails before publication when either flavor is incomplete', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot, {missing: 'hermes-engine'}); + expect(prepare).toThrow(/both flavors must contain 'hermes-engine'/); + expect(fs.existsSync(path.join(appRoot, 'build', 'xcframeworks'))).toBe( + false, + ); + }); + + it('rejects Debug/Release header differences', () => { + writeArtifactSlot(debugRoot, {reactHeader: 'void DebugOnly(void);\n'}); + writeArtifactSlot(releaseRoot, {reactHeader: 'void ReleaseOnly(void);\n'}); + expect(prepare).toThrow(/different Debug and Release headers/); + }); + + it('accepts flavor-specific Swift module metadata when public headers match', () => { + writeArtifactSlot(debugRoot, { + reactSwiftInterface: '// Debug -Onone interface metadata\n', + }); + writeArtifactSlot(releaseRoot, { + reactSwiftInterface: '// Release -O interface metadata\n', + }); + expect(prepare).not.toThrow(); + }); + + it('rejects incompatible Debug/Release slices', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot, { + reactIdentifier: 'ios-arm64-simulator', + reactArchitectures: ['arm64'], + }); + expect(prepare).toThrow(/incompatible Debug and Release XCFrameworks/); + }); + + it('normalizes plugin frameworks and rejects destination collisions', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + const pluginDebug = writeFrameworkXcframework( + path.join(root, 'plugin-debug'), + 'Plugin', + 'React', + ); + const pluginRelease = writeFrameworkXcframework( + path.join(root, 'plugin-release'), + 'Plugin', + 'React', + ); + const pluginManifest = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + fs.mkdirSync(path.dirname(pluginManifest), {recursive: true}); + fs.writeFileSync( + pluginManifest, + JSON.stringify([ + { + id: 'plugin', + frameworkName: 'React', + linkage: 'dynamic', + flavors: {debug: pluginDebug, release: pluginRelease}, + }, + ]), + ); + expect(prepare).toThrow(/multiple flavored frameworks embed as React/); + }); + + it('rejects plugin IDs that collide with built-in framework settings', () => { + writeArtifactSlot(debugRoot); + writeArtifactSlot(releaseRoot); + const pluginDebug = writeFrameworkXcframework( + path.join(root, 'plugin-debug'), + 'Plugin', + 'PluginFramework', + ); + const pluginRelease = writeFrameworkXcframework( + path.join(root, 'plugin-release'), + 'Plugin', + 'PluginFramework', + ); + const pluginManifest = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + fs.mkdirSync(path.dirname(pluginManifest), {recursive: true}); + fs.writeFileSync( + pluginManifest, + JSON.stringify([ + { + id: 'react', + frameworkName: 'PluginFramework', + linkage: 'dynamic', + flavors: {debug: pluginDebug, release: pluginRelease}, + }, + ]), + ); + expect(prepare).toThrow(/duplicate flavored framework id 'react'/); + }); +}); + +describe('XCFramework helpers', () => { + it.each([ + ['ios', null, 'iphoneos*'], + ['ios', 'simulator', 'iphonesimulator*'], + ['ios', 'maccatalyst', 'macosx*'], + ['tvos', 'simulator', 'appletvsimulator*'], + ['xros', null, 'xros*'], + ])('maps %s/%s to %s', (platform, variant, expected) => { + expect(sdkConditionForSlice(platform, variant)).toBe(expected); + }); + + it('converts the framework module map to a plain prefixed React module', () => { + expect( + transformReactModuleMap( + 'framework module React {\n umbrella header "React.h"\n textual header "RCTDefines.h"\n}\n', + ), + ).toBe( + 'module React {\n umbrella header "React/React.h"\n textual header "React/RCTDefines.h"\n}\n', + ); + }); +}); 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 new file mode 100644 index 000000000000..6e56cca8171f --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-config-test.js @@ -0,0 +1,259 @@ +/** + * 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'; + +/** + * Red tests for the SPM port of packages/react-native/scripts/cocoapods/autolinking.rb. + * + * The Ruby script (list_native_modules!) does the following: + * 1. Accepts a config_command (default-documented as ['npx', '@react-native-community/cli', 'config']) + * 2. Warns if config_command is empty / invalid (autolinking.rb:19-26) + * 3. Captures stdout + exit status of the command (autolinking.rb:28) + * 4. Warns if exit status is non-zero (autolinking.rb:30-37) + * 5. Parses the JSON output (autolinking.rb:38) + * 6. Derives output path = /build/generated/autolinking/autolinking.json (autolinking.rb:41-43) + * 7. Creates the output directory if missing (autolinking.rb:46) + * 8. Writes the RAW JSON string unchanged to that path (autolinking.rb:47) + * + * These tests assert the same surface for the JS port (generate-spm-autolinking-config.js), + * which does not yet exist — so they are red by construction. + */ + +const { + generateAutolinkingConfig, + resolveDefaultConfigCommand, +} = require('../generate-spm-autolinking-config'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +let tmpProjects = []; + +function makeTmpProject() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-autolink-config-')); + tmpProjects.push(tmp); + const projectRoot = path.join(tmp, 'project'); + const iosDir = path.join(projectRoot, 'ios'); + fs.mkdirSync(iosDir, {recursive: true}); + return {tmp, projectRoot, iosDir}; +} + +function fakeCliConfig(iosSourceDir) { + return { + root: path.dirname(iosSourceDir), + reactNativePath: '../react-native', + project: { + ios: {sourceDir: iosSourceDir}, + android: {sourceDir: path.join(path.dirname(iosSourceDir), 'android')}, + }, + dependencies: { + 'react-native-test-library-apple': { + name: 'react-native-test-library-apple', + root: '/somewhere/react-native-test-library/apple', + platforms: { + ios: { + podspecPath: + '/somewhere/react-native-test-library/apple/TestLibraryApple.podspec', + configurations: [], + scriptPhases: [], + version: '0.87.0-main', + }, + android: null, + }, + }, + }, + }; +} + +function autolinkingJsonPath(iosSourceDir) { + return path.join( + iosSourceDir, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ); +} + +// --------------------------------------------------------------------------- +// generateAutolinkingConfig +// --------------------------------------------------------------------------- + +describe('generateAutolinkingConfig', () => { + beforeEach(() => { + tmpProjects = []; + }); + + afterEach(() => { + for (const tmp of tmpProjects) { + fs.rmSync(tmp, {recursive: true, force: true}); + } + }); + + it('writes the CLI config JSON to /build/generated/autolinking/autolinking.json (autolinking.rb:41-47)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + const outPath = autolinkingJsonPath(iosDir); + expect(fs.existsSync(outPath)).toBe(true); + expect(fs.readFileSync(outPath, 'utf8')).toBe(raw); + }); + + it('writes the raw JSON unchanged — no filtering or reshaping of the upstream config (autolinking.rb:47)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const cfg = fakeCliConfig(iosDir); + const raw = JSON.stringify(cfg); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + const parsed = JSON.parse( + fs.readFileSync(autolinkingJsonPath(iosDir), 'utf8'), + ); + // iOS dep preserved + expect( + parsed.dependencies['react-native-test-library-apple'].platforms.ios + .podspecPath, + ).toBe( + cfg.dependencies['react-native-test-library-apple'].platforms.ios + .podspecPath, + ); + // Android section preserved (downstream consumer does its own iOS-only filtering) + expect(parsed.project.android).toBeDefined(); + expect( + parsed.dependencies['react-native-test-library-apple'].platforms.android, + ).toBeNull(); + }); + + it('creates the autolinking output directory if missing (autolinking.rb:46)', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + + expect(fs.existsSync(path.join(iosDir, 'build'))).toBe(false); + + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + expect( + fs.existsSync(path.join(iosDir, 'build', 'generated', 'autolinking')), + ).toBe(true); + }); + + it('uses a no-install npx fallback when the local CLI cannot be resolved', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCommand /*: ?Array */ = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: cmd => { + receivedCommand = cmd; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCommand).toEqual([ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', + ]); + }); + + it('prefers the locally resolved React Native CLI over npx', () => { + const {projectRoot} = makeTmpProject(); + const cliRoot = path.join( + projectRoot, + 'node_modules', + '@react-native-community', + 'cli', + ); + fs.mkdirSync(path.join(cliRoot, 'build'), {recursive: true}); + fs.writeFileSync( + path.join(cliRoot, 'package.json'), + JSON.stringify({bin: {'rnc-cli': 'build/bin.js'}}), + ); + fs.writeFileSync(path.join(cliRoot, 'build', 'bin.js'), ''); + + expect(resolveDefaultConfigCommand(projectRoot)).toEqual([ + process.execPath, + path.join(fs.realpathSync(cliRoot), 'build', 'bin.js'), + 'config', + ]); + }); + + it('passes projectRoot as the CWD to the cliRunner', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const raw = JSON.stringify(fakeCliConfig(iosDir)); + let receivedCwd /*: ?string */ = null; + + generateAutolinkingConfig({ + projectRoot, + cliRunner: (_cmd, opts) => { + receivedCwd = opts && opts.cwd; + return {stdout: raw, stderr: '', exitCode: 0}; + }, + }); + + expect(receivedCwd).toBe(projectRoot); + }); + + it('throws when configCommand is empty (autolinking.rb:19-26)', () => { + const {projectRoot} = makeTmpProject(); + expect(() => + generateAutolinkingConfig({ + projectRoot, + configCommand: [], + cliRunner: () => ({stdout: '{}', stderr: '', exitCode: 0}), + }), + ).toThrow(/config command/i); + }); + + it('throws when the CLI exits with a non-zero status (autolinking.rb:30-37)', () => { + const {projectRoot} = makeTmpProject(); + expect(() => + generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({ + stdout: '', + stderr: 'cli failed', + exitCode: 1, + }), + }), + ).toThrow(/exit|status|1/i); + }); + + it('returns the parsed config, raw JSON, and path it wrote to', () => { + const {projectRoot, iosDir} = makeTmpProject(); + const cfg = fakeCliConfig(iosDir); + const raw = JSON.stringify(cfg); + + const returned = generateAutolinkingConfig({ + projectRoot, + cliRunner: () => ({stdout: raw, stderr: '', exitCode: 0}), + }); + + expect(returned).toEqual({ + config: cfg, + outputPath: autolinkingJsonPath(iosDir), + rawJson: raw, + }); + }); +}); 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 new file mode 100644 index 000000000000..55b89936e52d --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -0,0 +1,1415 @@ +/** + * 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 { + AUTOGEN_MARKER, + MissingManifestError, + collectSpmSources, + expandSpmSourceGlobs, + findSelfManagedPackageDir, + generateAutolinkedPackageSwift, + generateSynthPackageSwift, + hasMixedLanguageSources, + hasPodspec, + linkHeaderTree, + main, + reactDescriptor, + reportMissingManifests, +} = require('../generate-spm-autolinking'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// reactDescriptor — the ReactDescriptor handed to autolinking plugins +// --------------------------------------------------------------------------- + +describe('reactDescriptor (plugin context.react)', () => { + it('builds a local package ref (absolute path + relPath) and the full product set when codegen exists', () => { + const d = reactDescriptor( + '/abs/app/build/xcframeworks', + '../xcframeworks', + true, + ); + expect(d.packageRef).toEqual({ + name: 'ReactNative', + path: '/abs/app/build/xcframeworks', + relPath: '../xcframeworks', + }); + // Full product set, incl. ReactAppHeaders in the SEPARATE React-GeneratedCode + // package (the entry a hand-rolled plugin would miss). + expect(d.products).toEqual([ + {name: 'ReactHeaders', package: 'ReactNative'}, + {name: 'ReactNativeHeaders', package: 'ReactNative'}, + {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, + {name: 'ReactAppHeaders', package: 'React-GeneratedCode'}, + ]); + }); + + it('omits React-GeneratedCode products when the codegen package is absent', () => { + const d = reactDescriptor( + '/abs/app/build/xcframeworks', + '../xcframeworks', + false, + ); + expect(d.products).toEqual([ + {name: 'ReactHeaders', package: 'ReactNative'}, + {name: 'ReactNativeHeaders', package: 'ReactNative'}, + {name: 'ReactNativeDependenciesHeaders', package: 'ReactNative'}, + ]); + // Invariant: no listed product references a package that isn't resolvable. + expect(d.products.some(p => p.package === 'React-GeneratedCode')).toBe( + false, + ); + }); + + it('returns null when there is no resolvable React dependency', () => { + expect(reactDescriptor(null, null, false)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// generateAutolinkedPackageSwift — top-level aggregator +// +// Post-refactor shape: autolinked/Package.swift is a thin meta-package that +// references each autolinked dep as its own sub-package via .package(path:), +// and re-exports them via a single AutolinkedAggregate target. Per-dep +// settings (cFlags, cxxFlags, header paths, link order) live in each synth +// sub-package — see generateSynthPackageSwift below. +// --------------------------------------------------------------------------- + +describe('generateAutolinkedPackageSwift (aggregator)', () => { + it('emits a valid swift-tools-version 6.0 package with an Autolinked product backed by AutolinkedAggregate', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).toContain('// swift-tools-version: 6.0'); + expect(result).toContain('import PackageDescription'); + expect(result).toContain( + '.library(name: "Autolinked", targets: ["AutolinkedAggregate"])', + ); + expect(result).toContain('name: "AutolinkedAggregate"'); + }); + + it('references each npm dep as .package(path: "packages/") and depends on its product', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [{swiftName: 'A'}, {swiftName: 'B'}], + }); + expect(result).toContain('.package(name: "A", path: "packages/A")'); + expect(result).toContain('.package(name: "B", path: "packages/B")'); + expect(result).toContain('.product(name: "A", package: "A")'); + expect(result).toContain('.product(name: "B", package: "B")'); + }); + + it('emits autolinking-plugin package + product contributions (path and remote)', () => { + const result = generateAutolinkedPackageSwift({ + pluginPackageDeps: [ + {name: 'ExpoModulesCore', path: '../../../../node_modules/expo'}, + {name: 'ReactNative', url: 'https://example/rn.git', version: '0.87.0'}, + ], + pluginProductDeps: [ + {name: 'ExpoModulesCore', package: 'ExpoModulesCore'}, + ], + }); + expect(result).toContain( + '.package(name: "ExpoModulesCore", path: "../../../../node_modules/expo")', + ); + expect(result).toContain( + '.package(url: "https://example/rn.git", exact: "0.87.0")', + ); + expect(result).toContain( + '.product(name: "ExpoModulesCore", package: "ExpoModulesCore")', + ); + }); + + it('emits an eval-time missing-manifest guard naming each lib by npm name', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [ + { + swiftName: 'ReactNativeSafeAreaContext', + packagePath: 'libs/ReactNativeSafeAreaContext', + npmName: 'react-native-safe-area-context', + }, + ], + }); + // The guard runs at resolution (manifest eval) — before the Xcode sync + // build phase — so a wiped library manifest surfaces an actionable message + // instead of SwiftPM's opaque "manifest cannot be accessed". + expect(result).toContain('let __rnAutolinkedLibs'); + expect(result).toContain( + '(path: "libs/ReactNativeSafeAreaContext", npm: "react-native-safe-area-context")', + ); + expect(result).toContain('FileManager.default.fileExists'); + expect(result).toContain('npx react-native spm scaffold'); + expect(result).toContain('npx patch-package'); + expect(result).toContain('fatalError('); + // The guard reads its own location to resolve lib paths. + expect(result).toContain('#filePath'); + }); + + it('omits the guard entirely when there are no npm deps', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).not.toContain('__rnAutolinkedLibs'); + }); + + it('emits inline .target() blocks for each inlineTarget alongside AutolinkedAggregate', () => { + const result = generateAutolinkedPackageSwift({ + inlineTargets: [ + { + name: 'ScreenshotManager', + path: 'sources/ScreenshotManager', + exclude: [], + publicHeadersPath: '.', + }, + ], + xcframeworksRelPath: '../build/xcframeworks', + hasReactDep: true, + hasXcfwHeaders: true, + hasDepsHeaders: true, + }); + // ReactNative dep is needed because inline targets reference it + expect(result).toContain( + '.package(name: "ReactNative", path: "../build/xcframeworks")', + ); + // Aggregator depends on inline targets via .target(name: ...) + expect(result).toContain('.target(name: "ScreenshotManager")'); + // Inline target's own declaration appears in the targets array + expect(result).toMatch( + /name: "ScreenshotManager",[\s\S]*?path: "sources\/ScreenshotManager"/, + ); + // Inline target depends on the invariant React compile product. + expect(result).toMatch( + /name: "ScreenshotManager",[\s\S]*?\.product\(name: "ReactHeaders", package: "ReactNative"\)/, + ); + // Inline targets resolve headers via product deps — no -I flags, no VFS. + expect(result).toContain( + '.product(name: "ReactNativeHeaders", package: "ReactNative")', + ); + expect(result).toContain( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + expect(result).not.toContain('rnCoreHeaders'); + expect(result).not.toContain('-ivfsoverlay'); + expect(result).toContain('.linkedFramework("CoreGraphics")'); + }); + + it('mixes npm sub-package deps and inline targets in a single aggregator', () => { + const result = generateAutolinkedPackageSwift({ + npmDeps: [{swiftName: 'NpmA'}], + inlineTargets: [ + { + name: 'LocalA', + path: 'sources/LocalA', + exclude: [], + publicHeadersPath: '.', + }, + ], + xcframeworksRelPath: '../build/xcframeworks', + hasReactDep: true, + hasXcfwHeaders: true, + }); + // Both forms of dep on AutolinkedAggregate + expect(result).toContain('.product(name: "NpmA", package: "NpmA")'); + expect(result).toContain('.target(name: "LocalA")'); + }); + + it('emits a stub aggregator when neither npmDeps nor inlineTargets are provided', () => { + const result = generateAutolinkedPackageSwift({}); + expect(result).toContain('name: "AutolinkedAggregate"'); + expect(result).not.toContain('.package(name:'); + }); +}); + +// --------------------------------------------------------------------------- +// generateSynthPackageSwift — per-dep synthesized Package.swift +// +// Each autolinked dep gets its own SPM package, written under +// autolinked/packages//Package.swift. Sources are mirrored into +// /Sources// so SPM's path-containment check passes. +// +// The synth Package.swift embeds the dep's settings (cFlags, cxxFlags, header +// paths) and declares cross-package dependencies for its transitive +// spmDependencies as sibling synth packages at path "../". +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift', () => { + function baseSpec(overrides /*: ?Object */) { + return { + swiftName: 'MyDep', + exclude: [], + publicHeadersPath: '.', + spmDependencies: [], + hasReactDep: true, + hasXcfwHeaders: true, + hasDepsHeaders: false, + codegenHeadersIncluded: false, + ...overrides, + }; + } + + it('emits a valid Package with name/product matching swiftName', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain('// swift-tools-version: 6.0'); + expect(result).toContain('name: "MyDep"'); + expect(result).toContain('.library('); + expect(result).toContain('name: "MyDep"'); + expect(result).toContain('targets: ["MyDep"]'); + // Target path points at the mirrored sources sub-dir + expect(result).toContain('path: "Sources/MyDep"'); + }); + + it('declares the library product as type: .dynamic so SPM framework-wraps it (enables includes)', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain( + '.library(name: "MyDep", type: .dynamic, targets: ["MyDep"])', + ); + }); + + it('emits DEBUG/NDEBUG config-gated cxxSettings so Fabric C++ matches the prebuilt React.framework ABI', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain('.define("DEBUG", .when(configuration: .debug))'); + expect(result).toContain( + '.define("NDEBUG", .when(configuration: .release))', + ); + }); + + it('depends on ReactNative via a fixed relative path (default synth depth)', () => { + const result = generateSynthPackageSwift(baseSpec({hasReactDep: true})); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + expect(result).toContain( + '.package(name: "React-GeneratedCode", path: "../../../ios")', + ); + expect(result).toContain( + '.product(name: "ReactHeaders", package: "ReactNative")', + ); + // Fully declarative — no runtime discovery, no Foundation import. + expect(result).not.toContain('import Foundation'); + expect(result).not.toContain('spm-paths.json'); + expect(result).not.toContain('#filePath'); + }); + + it('honors caller-supplied reactNativePackagePath / codegenPackagePath', () => { + const result = generateSynthPackageSwift( + baseSpec({ + hasReactDep: true, + reactNativePackagePath: '../../rel/xcframeworks', + codegenPackagePath: '../../rel/ios', + }), + ); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../rel/xcframeworks")', + ); + expect(result).toContain( + '.package(name: "React-GeneratedCode", path: "../../rel/ios")', + ); + }); + + it('declares sibling synth packages at path "../" for each spmDependencies entry', () => { + const result = generateSynthPackageSwift( + baseSpec({spmDependencies: [{swiftName: 'CommonDep'}]}), + ); + expect(result).toContain( + '.package(name: "CommonDep", path: "../CommonDep")', + ); + expect(result).toContain( + '.product(name: "CommonDep", package: "CommonDep")', + ); + }); + + it('serves React headers via product deps (binaryTargets + ReactAppHeaders)', () => { + const result = generateSynthPackageSwift(baseSpec({hasReactDep: true})); + expect(result).toContain( + '.product(name: "ReactNativeHeaders", package: "ReactNative")', + ); + expect(result).toContain( + '.product(name: "ReactAppHeaders", package: "React-GeneratedCode")', + ); + // No header-search-path vars, no flags, no legacy VFS machinery. + expect(result).not.toContain('rnCoreHeaders'); + expect(result).not.toContain('unsafeFlags(["-I"'); + expect(result).not.toContain('ReactHeadersAll'); + expect(result).not.toContain('-ivfsoverlay'); + expect(result).not.toContain('let xcfwHeaders'); + expect(result).not.toContain('let vfsOverlay'); + expect(result).not.toContain('let depsHeaders'); + }); + + it('emits exclude list when given', () => { + const result = generateSynthPackageSwift( + baseSpec({exclude: ['tests/', 'broken.m']}), + ); + expect(result).toContain('exclude: ["tests/", "broken.m"]'); + }); + + it('omits publicHeadersPath when null (not all targets expose headers)', () => { + const result = generateSynthPackageSwift( + baseSpec({publicHeadersPath: null}), + ); + expect(result).not.toContain('publicHeadersPath:'); + }); + + it('links UIKit and Foundation frameworks by default', () => { + const result = generateSynthPackageSwift(baseSpec()); + expect(result).toContain('.linkedFramework("UIKit")'); + expect(result).toContain('.linkedFramework("Foundation")'); + }); + + // ------------------------------------------------------------------------- + // In-place mode: synth Package.swift lives in the dep's real source dir + // (target.path = ".") with an absolute appRoot. Used by the production + // emitter so Xcode can save source files normally (atomic-save through a + // symlink in autolinked/ fails with NSFileNoSuchFileError). + // ------------------------------------------------------------------------- + + it('emits no runtime discovery — fully declarative manifest', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + publicHeadersPath: 'include', + hasReactDep: true, + targetPath: '.', + }); + expect(result).not.toContain('rnSpmPaths'); + expect(result).not.toContain('spm-paths.json'); + expect(result).not.toContain('import Foundation'); + expect(result).toContain('path: "."'); + }); + + it('in-place mode: ReactNative dep path uses the default fixed relative path', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + targetPath: '.', + }); + expect(result).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + }); + + it('in-place mode: sibling synth refs use absolute paths from siblingSynthAbsolutePaths', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + targetPath: '.', + spmDependencies: [{swiftName: 'CommonDep'}], + siblingSynthAbsolutePaths: {CommonDep: '/abs/path/to/common'}, + }); + expect(result).toContain( + '.package(name: "CommonDep", path: "/abs/path/to/common")', + ); + expect(result).toContain( + '.product(name: "CommonDep", package: "CommonDep")', + ); + }); + + it('falls back to a relative sibling path when no absolute synth path is provided', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + targetPath: '.', + spmDependencies: [{swiftName: 'Missing'}], + siblingSynthAbsolutePaths: {}, + }); + expect(result).toContain('.package(name: "Missing", path: "../Missing")'); + }); + + it('wrapper-dir mode: target.path = "root" (a dir symlink) so Xcode atomic-save works on real files', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app/root', + autogenHeadersAbsolute: + '/abs/app/root/build/generated/autolinking/headers', + }); + expect(result).toContain('path: "root"'); + }); + + it('wrapper-dir mode: routes all includes through the single merged tree (autolinking headers folded in)', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/build/generated/autolinking/headers', + }); + // The autolinking headers dir is folded into the per-app farm (served by + // the ReactAppHeaders product) — never a separate -I. + expect(result).not.toContain( + '"-I", "/abs/app/build/generated/autolinking/headers"', + ); + }); + + it('wrapper-dir mode: omits publicHeadersPath (headers route through -I instead)', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs', + autogenHeadersAbsolute: '/abs/headers', + // publicHeadersPath intentionally not set + }); + expect(result).not.toContain('publicHeadersPath:'); + }); +}); + +// --------------------------------------------------------------------------- +// linkHeaderTree +// +// Mirrors header files from srcDir into a separate destDir via relative +// symlinks. Used for the centralized cross-package headers tree at +// /headers//. +// --------------------------------------------------------------------------- + +describe('linkHeaderTree', () => { + let tmpDirs; + + beforeEach(() => { + tmpDirs = []; + }); + + afterEach(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + function makeTmpDirs() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-headers-')); + tmpDirs.push(root); + const src = path.join(root, 'src'); + const dest = path.join(root, 'headers', 'MyDep'); + fs.mkdirSync(src, {recursive: true}); + return {root, src, dest}; + } + + it('symlinks each header from srcDir into destDir with a relative target', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'Foo.h'), '// foo\n'); + fs.writeFileSync(path.join(src, 'Foo.mm'), '// not a header — skip\n'); + + linkHeaderTree(src, dest); + + const link = path.join(dest, 'Foo.h'); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + // dest sits under root/headers/MyDep, src under root/src — relative target + // walks up two levels then back down into src/. + expect(fs.readlinkSync(link)).toBe('../../src/Foo.h'); + expect(fs.existsSync(path.join(dest, 'Foo.mm'))).toBe(false); + }); + + it('preserves nested header subdirs so resolves (ReactCommonSamples case)', () => { + const {src, dest} = makeTmpDirs(); + fs.mkdirSync(path.join(src, 'ReactCommon')); + fs.writeFileSync(path.join(src, 'ReactCommon', 'Nested.h'), '// nested\n'); + + linkHeaderTree(src, dest); + + const link = path.join(dest, 'ReactCommon', 'Nested.h'); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(link)).toBe('../../../src/ReactCommon/Nested.h'); + }); + + it('is idempotent: re-running with the same headers preserves symlink inodes', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'Stable.h'), '// h\n'); + + linkHeaderTree(src, dest); + const link = path.join(dest, 'Stable.h'); + const inoBefore = fs.lstatSync(link).ino; + + linkHeaderTree(src, dest); + expect(fs.lstatSync(link).ino).toBe(inoBefore); + }); + + it('prunes symlinks for headers that no longer exist in srcDir', () => { + const {src, dest} = makeTmpDirs(); + fs.writeFileSync(path.join(src, 'A.h'), '// a\n'); + fs.writeFileSync(path.join(src, 'B.h'), '// b\n'); + linkHeaderTree(src, dest); + + expect(fs.existsSync(path.join(dest, 'B.h'))).toBe(true); + + // Remove B.h from src and re-run; the stale symlink should be gone. + fs.unlinkSync(path.join(src, 'B.h')); + linkHeaderTree(src, dest); + + expect(fs.existsSync(path.join(dest, 'A.h'))).toBe(true); + expect(fs.existsSync(path.join(dest, 'B.h'))).toBe(false); + }); + + it('removes destDir entirely when srcDir has no headers', () => { + const {src, dest} = makeTmpDirs(); + // No header files in src — just a non-header. + fs.writeFileSync(path.join(src, 'thing.mm'), '// impl\n'); + fs.mkdirSync(dest, {recursive: true}); + fs.writeFileSync(path.join(dest, 'Stale.h'), '// stale\n'); + + linkHeaderTree(src, dest); + + expect(fs.existsSync(dest)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// collectSpmSources — recursive auto-discovery used as the default `sources:` +// allowlist. Skip-dirs (tests/, __tests__/, android/, …) are pruned at every +// depth. Anything not matching ALL_SOURCE_EXTENSIONS is left out (no .js, +// .podspec, .md, package.json, CMakeLists.txt). +// --------------------------------------------------------------------------- + +describe('collectSpmSources', () => { + let tmpDirs; + + beforeEach(() => { + tmpDirs = []; + }); + + afterEach(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + function makeTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sources-')); + tmpDirs.push(dir); + return dir; + } + + it('returns every source file under sourcePath, sorted, forward-slash-separated', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.h'), ''); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + fs.mkdirSync(path.join(dir, 'Sub')); + fs.writeFileSync(path.join(dir, 'Sub', 'B.cpp'), ''); + fs.writeFileSync(path.join(dir, 'Sub', 'B.hpp'), ''); + + expect(collectSpmSources(dir)).toEqual([ + 'A.h', + 'A.mm', + 'Sub/B.cpp', + 'Sub/B.hpp', + ]); + }); + + it('ignores non-source files like .js, .ts, .podspec, .md, CMakeLists.txt, package.json', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'Module.mm'), ''); + fs.writeFileSync(path.join(dir, 'module.js'), ''); + fs.writeFileSync(path.join(dir, 'index.ts'), ''); + fs.writeFileSync(path.join(dir, 'My.podspec'), ''); + fs.writeFileSync(path.join(dir, 'README.md'), ''); + fs.writeFileSync(path.join(dir, 'CMakeLists.txt'), ''); + fs.writeFileSync(path.join(dir, 'package.json'), '{}'); + + expect(collectSpmSources(dir)).toEqual(['Module.mm']); + }); + + it('skips test/__tests__/__mocks__/jest/android/node_modules directories at the top level', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'Real.mm'), ''); + for (const skip of [ + 'tests', + '__tests__', + '__mocks__', + 'test', + 'jest', + 'android', + 'node_modules', + ]) { + fs.mkdirSync(path.join(dir, skip)); + fs.writeFileSync(path.join(dir, skip, 'Hidden.mm'), ''); + } + + expect(collectSpmSources(dir)).toEqual(['Real.mm']); + }); + + it('skips skip-dirs at any nesting depth (the regression that motivated the switch)', () => { + // NativeCxxModuleExample/tests/NativeCxxModuleExampleTests.cpp shape: + // the test dir lives under a nested subdir, not at the source root. + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'NativeCxxModuleExample', 'tests'), { + recursive: true, + }); + fs.writeFileSync( + path.join(dir, 'NativeCxxModuleExample', 'NativeCxxModuleExample.mm'), + '', + ); + fs.writeFileSync( + path.join( + dir, + 'NativeCxxModuleExample', + 'tests', + 'NativeCxxModuleExampleTests.cpp', + ), + '', + ); + + expect(collectSpmSources(dir)).toEqual([ + 'NativeCxxModuleExample/NativeCxxModuleExample.mm', + ]); + }); + + it('returns an empty list when sourcePath does not exist', () => { + expect(collectSpmSources('/no/such/dir/spm-test')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// expandSpmSourceGlobs — translates CocoaPods-style globs (e.g. +// 'ios/**/*.{h,m,mm}') into a sorted list of matching file paths. Skip-dir +// filtering still applies even when the pattern would otherwise match. +// --------------------------------------------------------------------------- + +describe('expandSpmSourceGlobs', () => { + let tmpDirs; + + beforeEach(() => { + tmpDirs = []; + }); + + afterEach(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + function makeTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-globs-')); + tmpDirs.push(dir); + return dir; + } + + it('** matches any depth, with brace alternation expanding extensions', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'ios', 'Sub'), {recursive: true}); + fs.writeFileSync(path.join(dir, 'ios', 'Root.h'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'Root.m'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'Sub', 'Deep.mm'), ''); + fs.writeFileSync(path.join(dir, 'ios', 'ignored.txt'), ''); + + expect(expandSpmSourceGlobs(dir, ['ios/**/*.{h,m,mm}'])).toEqual([ + 'ios/Root.h', + 'ios/Root.m', + 'ios/Sub/Deep.mm', + ]); + }); + + it('single * stays within one segment', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'a', 'b'), {recursive: true}); + fs.writeFileSync(path.join(dir, 'a', 'Foo.mm'), ''); + fs.writeFileSync(path.join(dir, 'a', 'b', 'Bar.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['a/*.mm'])).toEqual(['a/Foo.mm']); + }); + + it('still skips SKIP_DIRS_DEFAULT even when the glob would match inside them', () => { + const dir = makeTmp(); + fs.mkdirSync(path.join(dir, 'tests')); + fs.writeFileSync(path.join(dir, 'Real.mm'), ''); + fs.writeFileSync(path.join(dir, 'tests', 'Hidden.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['**/*.mm'])).toEqual(['Real.mm']); + }); + + it('multiple patterns are unioned and deduplicated', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.h'), ''); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['*.h', '*.mm', '*.{h,mm}'])).toEqual([ + 'A.h', + 'A.mm', + ]); + }); + + it('returns an empty list when no pattern matches', () => { + const dir = makeTmp(); + fs.writeFileSync(path.join(dir, 'A.mm'), ''); + + expect(expandSpmSourceGlobs(dir, ['nope/*.swift'])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// generateSynthPackageSwift — sources: line rendering +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift (sources: allowlist)', () => { + it('emits a multi-line sources: array when spec.sources is non-empty', () => { + const result = generateSynthPackageSwift({ + swiftName: 'MyDep', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: '.', + sources: ['root/A.mm', 'root/Sub/B.cpp'], + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + }); + expect(result).toContain('sources: ['); + expect(result).toContain('"root/A.mm"'); + expect(result).toContain('"root/Sub/B.cpp"'); + // Order matters for diff readability: sources: comes after path: and + // before publicHeadersPath:. + const sourcesIdx = result.indexOf('sources: ['); + const pathIdx = result.indexOf('path: "."'); + const publicHeadersIdx = result.indexOf('publicHeadersPath:'); + expect(pathIdx).toBeLessThan(sourcesIdx); + if (publicHeadersIdx !== -1) { + expect(sourcesIdx).toBeLessThan(publicHeadersIdx); + } + }); + + it('omits sources: line when spec.sources is null or empty (falls back to SPM auto-scan)', () => { + const a = generateSynthPackageSwift({ + swiftName: 'A', + targetPath: '.', + appRootAbsolute: '/abs', + }); + const b = generateSynthPackageSwift({ + swiftName: 'B', + targetPath: '.', + sources: [], + appRootAbsolute: '/abs', + }); + expect(a).not.toContain('sources: ['); + expect(b).not.toContain('sources: ['); + }); +}); + +// --------------------------------------------------------------------------- +// spm.name override — verifies that a non-default Swift name (set by a +// library author via react-native.config.js `spm.name`) flows verbatim into +// the synth Package.swift: target name, library name, product references, +// and sibling package paths all use the override. +// --------------------------------------------------------------------------- + +describe('generateSynthPackageSwift (spm.name override)', () => { + it('uses the override Swift name for the target, library, and product', () => { + const result = generateSynthPackageSwift({ + swiftName: 'worklets', // override from spm.name (default would be "ReactNativeWorklets") + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + }); + expect(result).toContain('name: "worklets"'); + expect(result).toContain('.library(name: "worklets"'); + expect(result).toContain('targets: ["worklets"]'); + // The auto-derived name must not appear anywhere. + expect(result).not.toContain('ReactNativeWorklets'); + }); + + it('emits the override name in sibling .package(...) and .product(...) refs when a transitive dep was overridden', () => { + // Simulates the case where reanimated declares spm.dependencies on + // react-native-worklets, and worklets has set spm.name: "worklets". + // The autolinker's swiftNameByNpm map resolves the transitive to + // "worklets" before passing it to generateSynthPackageSwift. + const result = generateSynthPackageSwift({ + swiftName: 'reanimated', + hasReactDep: true, + hasXcfwHeaders: true, + targetPath: 'root', + appRootAbsolute: '/abs/app', + autogenHeadersAbsolute: '/abs/app/headers', + spmDependencies: [{swiftName: 'worklets'}], + siblingSynthAbsolutePaths: {worklets: '/abs/app/packages/worklets'}, + }); + expect(result).toContain( + '.package(name: "worklets", path: "/abs/app/packages/worklets")', + ); + expect(result).toContain('.product(name: "worklets", package: "worklets")'); + expect(result).not.toContain('ReactNativeWorklets'); + }); +}); + +// --------------------------------------------------------------------------- +// findSelfManagedPackageDir — detects hand-authored Package.swift at either +// the dep root or under ios/. The nested layout lets community libraries +// keep their npm-package root free of SPM artifacts (.build/, .swiftpm/). +// --------------------------------------------------------------------------- + +describe('findSelfManagedPackageDir', () => { + let depRoot; + + beforeEach(() => { + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-selfmgd-')); + }); + + afterEach(() => { + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + it('returns null when no Package.swift exists at any candidate location', () => { + expect(findSelfManagedPackageDir(depRoot)).toBe(null); + }); + + it('returns the dep root when /Package.swift exists without the AUTOGEN marker', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// swift-tools-version: 6.0\n// Hand-authored.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot); + }); + + it('returns null when /Package.swift carries the AUTOGEN marker', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + AUTOGEN_MARKER + '\n// synth wrapper content\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(null); + }); + + it('returns /ios when only the nested manifest exists and lacks the AUTOGEN marker', () => { + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// swift-tools-version: 6.0\n// Hand-authored nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); + }); + + it('prefers the root manifest when both root and nested manifests exist', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// Root manifest.\n', + ); + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// Nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(depRoot); + }); + + it('falls back to the nested manifest when the root one is autolinker-generated', () => { + // Models the transition state: dep was previously autolinker-wrapped and + // recently shipped its own ios/Package.swift. The root file (a leftover + // synth manifest from a prior run) shouldn't shadow the hand-authored one. + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + AUTOGEN_MARKER + '\n// stale synth\n', + ); + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync( + path.join(depRoot, 'ios', 'Package.swift'), + '// Hand-authored nested manifest.\n', + ); + expect(findSelfManagedPackageDir(depRoot)).toBe(path.join(depRoot, 'ios')); + }); +}); + +// --------------------------------------------------------------------------- +// hasPodspec — does the dep ship a podspec (auto-scaffoldable)? +// --------------------------------------------------------------------------- +describe('hasPodspec', () => { + let depRoot; + + beforeEach(() => { + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podspec-')); + }); + + afterEach(() => { + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + it('returns false when no .podspec exists at the root or under ios/', () => { + expect(hasPodspec(depRoot)).toBe(false); + }); + + it('returns true for a .podspec at the dep root', () => { + fs.writeFileSync(path.join(depRoot, 'Foo.podspec'), '# podspec'); + expect(hasPodspec(depRoot)).toBe(true); + }); + + it('returns true for a .podspec under ios/', () => { + fs.mkdirSync(path.join(depRoot, 'ios')); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.podspec'), '# podspec'); + expect(hasPodspec(depRoot)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Missing-manifest error — the gate that replaced the silent synth wrapper +// for community npm deps. The `error:` line prefix is the load-bearing +// contract: Xcode parses it to render a build error. +// --------------------------------------------------------------------------- +describe('MissingManifestError + reportMissingManifests', () => { + let errSpy; + + beforeEach(() => { + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errSpy.mockRestore(); + }); + + it('carries the dep list and a scaffold instruction on the error', () => { + const deps = [{name: 'Foo', npmName: 'react-native-foo', hasPodspec: true}]; + const err = new MissingManifestError(deps); + expect(err).toBeInstanceOf(Error); + expect(err.missingManifests).toEqual(deps); + expect(err.message).toContain('react-native spm scaffold'); + }); + + it('emits one `error:`-prefixed line per dep naming the npm package + fix', () => { + const err = reportMissingManifests([ + {name: 'Foo', npmName: 'react-native-foo', hasPodspec: true}, + {name: 'Bar', npmName: 'react-native-bar', hasPodspec: true}, + ]); + expect(err).toBeInstanceOf(MissingManifestError); + expect(errSpy).toHaveBeenCalledTimes(2); + const lines = errSpy.mock.calls.map(c => c[0]); + // Xcode only renders the `error: ` headline; each dep is one such message. + expect(lines.every(l => l.startsWith('error: '))).toBe(true); + expect(lines[0]).toContain('react-native-foo'); + expect(lines[0]).toContain('npx react-native spm scaffold'); + // The pressure mechanics: persist via patch-package, and the error returns + // on a fresh node_modules if you don't (no auto-scaffold/auto-restore). + expect(lines[0]).toContain('patch-package'); + expect(lines[0]).toContain('node_modules is reset'); + }); + + it('tells the user a podspec-less dep cannot be auto-scaffolded', () => { + reportMissingManifests([ + {name: 'Baz', npmName: 'react-native-baz', hasPodspec: false}, + ]); + const line = errSpy.mock.calls[0][0]; + expect(line.startsWith('error: ')).toBe(true); + expect(line).toContain('no podspec'); + expect(line).toContain('react-native-baz'); + }); + + it('gives a mixed-language dep a DISTINCT error (not "run scaffold") with an opt-out + binary path', () => { + reportMissingManifests([ + { + name: 'Screens', + npmName: 'react-native-screens', + hasPodspec: true, + mixed: true, + }, + ]); + const line = errSpy.mock.calls[0][0]; + expect(line.startsWith('error: ')).toBe(true); + expect(line).toContain('mixed Swift'); + // Must NOT tell them to scaffold — scaffolding can't fix mixed-language. + expect(line).not.toContain('react-native spm scaffold'); + // The two real escape hatches: + expect(line).toContain('react-native.config.js'); // opt out of autolinking + expect(line).toContain('platforms: { ios: null }'); + expect(line).toContain('xcframework'); // or consume as a prebuilt binary + }); +}); + +describe('hasMixedLanguageSources', () => { + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-mixed-')); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + it('is true when both .swift and .mm exist under the source dir (screens shape)', () => { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.swift'), ''); + fs.writeFileSync(path.join(root, 'ios', 'RNSScreen.mm'), ''); + expect(hasMixedLanguageSources(root)).toBe(true); + }); + + it('is false for a pure-ObjC++ lib (svg/skia shape)', () => { + fs.mkdirSync(path.join(root, 'apple'), {recursive: true}); + fs.writeFileSync(path.join(root, 'apple', 'A.mm'), ''); + fs.writeFileSync(path.join(root, 'apple', 'B.h'), ''); + expect(hasMixedLanguageSources(root)).toBe(false); + }); + + it('ignores .swift that lives only under example/ or __tests__ (not real sources)', () => { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'ios', 'A.mm'), ''); + fs.mkdirSync(path.join(root, 'example', 'ios'), {recursive: true}); + fs.writeFileSync(path.join(root, 'example', 'ios', 'App.swift'), ''); + expect(hasMixedLanguageSources(root)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// main() — autolinking plugin host exemption +// +// A dep that declares an autolinking plugin OWNS its native contribution (the +// plugin returns its package/product deps). RN must NOT also try to +// source-build that dep through the community-lib path: a plugin host like +// Expo typically ships no Package.swift and is mixed Swift/ObjC, so the +// community-lib path would raise MissingManifestError before the plugin ever +// runs. The regression pair below pins that exemption down — the negative +// control proves it is load-bearing (remove the plugin and the SAME dep throws). +// --------------------------------------------------------------------------- + +describe('main() — autolinking plugin host exemption', () => { + let created = []; + let spies = []; + + beforeEach(() => { + // Silence the [generate-spm-autolinking] logger (console.log/warn/error). + 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 = []; + }); + + // 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}) { + 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 + // through as context.reactNativeRoot). + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + // package.json so findProjectRoot() resolves to appRoot. + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + // The plugin-host dep: native sources present, but NO Package.swift. + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(expoDir, 'ios', 'Expo.mm'), + '// native source\n', + ); + if (withPlugin) { + 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 () {\n' + + ' return {\n' + + " packageDependencies: [{name: 'ExpoModulesCore', path: '../../../../node_modules/expo/ios'}],\n" + + " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],\n" + + ' };\n' + + '};\n', + ); + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + }), + ); + return {appRoot, rnRoot}; + } + + it('exempts a plugin-host dep from source-building (no MissingManifestError; plugin contribution merged)', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: true}); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).not.toThrow(); + + const pkg = fs.readFileSync( + path.join(appRoot, 'build/generated/autolinking/Package.swift'), + 'utf8', + ); + // The plugin's contribution is present … + expect(pkg).toContain('.package(name: "ExpoModulesCore"'); + expect(pkg).toContain( + '.product(name: "ExpoModulesCore", package: "ExpoModulesCore")', + ); + // … and expo is NOT source-built as a community lib: no wrapper package + // reference and no eval-time missing-manifest guard. + expect(pkg).not.toContain('path: "packages/'); + expect(pkg).not.toContain('__rnAutolinkedLibs'); + }); + + it('negative control: without the plugin declaration the SAME dep throws MissingManifestError (exemption is load-bearing)', () => { + const {appRoot, rnRoot} = buildFixture({withPlugin: false}); + expect(() => + main(['--app-root', appRoot, '--react-native-root', rnRoot]), + ).toThrow(MissingManifestError); + }); +}); + +// --------------------------------------------------------------------------- +// main() — plugin flavoredFrameworks sidecar +// +// Both plugin sidecars (.spm-plugin-flavored-frameworks.json, consumed by +// artifact preparation, and .spm-plugin-generated-sources.json, consumed by +// the injector) are ALWAYS rewritten — `[]` when no plugin +// declares any — so removing a plugin clears stale entries. +// --------------------------------------------------------------------------- + +describe('main() — flavoredFrameworks 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-flavored-frameworks.json', + ); + + function scaffold(autolinkingDeps) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-flavart-')); + 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}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies: autolinkingDeps}), + ); + return {appRoot, rnRoot}; + } + + it('writes [] to the generated-sources sidecar when no plugin declares any (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold({}); + const genSourcesPath = path.join( + path.dirname(sidecarPath(appRoot)), + '.spm-plugin-generated-sources.json', + ); + // Pre-seed a stale manifest to prove it is overwritten, not left alone — + // the injector's reconciliation trusts this file, so a full plugin + // removal must reset it to []. + fs.writeFileSync(genSourcesPath, JSON.stringify([{path: '/stale.swift'}])); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(genSourcesPath, 'utf8'))).toEqual([]); + }); + + it('writes [] when no plugin declares flavored artifacts (clears stale entries)', () => { + const {appRoot, rnRoot} = scaffold({}); + // Pre-seed a stale sidecar to prove it is overwritten, not left alone. + fs.writeFileSync(sidecarPath(appRoot), JSON.stringify([{id: 'stale'}])); + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual( + [], + ); + }); + + it('records a plugin-declared flavored framework to the sidecar', () => { + const {appRoot, rnRoot} = scaffold({ + expo: { + root: path.join('__EXPO__'), + platforms: {ios: {}}, + }, + }); + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); + // Point autolinking.json's expo root at the real dir. + fs.writeFileSync( + path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ), + JSON.stringify({ + dependencies: {expo: {root: expoDir, platforms: {ios: {}}}}, + }), + ); + 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 () {\n' + + ' return {\n' + + " packageDependencies: [{name: 'ExpoModulesCore', path: '../../../../node_modules/expo/ios'}],\n" + + " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],\n" + + ' flavoredFrameworks: [{\n' + + " id: 'expo-modules-core',\n" + + " frameworkName: 'ExpoModulesCore',\n" + + " linkage: 'dynamic',\n" + + " flavors: {debug: '/abs/debug/ExpoModulesCore.xcframework', release: '/abs/release/ExpoModulesCore.xcframework'},\n" + + ' }],\n' + + ' };\n' + + '};\n', + ); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + expect(JSON.parse(fs.readFileSync(sidecarPath(appRoot), 'utf8'))).toEqual([ + { + id: 'expo-modules-core', + frameworkName: 'ExpoModulesCore', + linkage: 'dynamic', + flavors: { + debug: '/abs/debug/ExpoModulesCore.xcframework', + release: '/abs/release/ExpoModulesCore.xcframework', + }, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// main() — .spm-sync-watch-paths emission (mixed dirs + files) +// +// The watch file drives the Xcode auto-sync stale check. It must carry, mixed +// and deduped/sorted: (1) each module's source DIR; (2) each npm dep's +// checked-in root Package.swift (FILE) and .react-native/ (DIR) — threaded from +// the autolinking model's dep root, not derived by walking up; (3) plugin +// watchPaths. Nonexistent paths are filtered at emission time. +// --------------------------------------------------------------------------- + +describe('main() — .spm-sync-watch-paths emission', () => { + 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 = []; + }); + + function readWatchLines(appRoot) { + const contents = fs.readFileSync( + path.join(appRoot, 'build/generated/autolinking/.spm-sync-watch-paths'), + 'utf8', + ); + return contents.split('\n').filter(l => l.length > 0); + } + + it('emits source dirs + dep manifests + .react-native dirs + plugin paths, deduped and sorted', () => { + // realpath so paths derived here match a plugin's realpath'd __dirname + // (macOS /var → /private/var symlink). + const appRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'spm-watch-emit-')), + ); + 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'}), + ); + + // (B) A self-managed community dep: root Package.swift (no AUTOGEN marker) + // makes it self-managed; it also ships a .react-native/ metadata dir. + const fooDir = path.join(appRoot, 'node_modules', 'react-native-foo'); + fs.mkdirSync(fooDir, {recursive: true}); + fs.writeFileSync( + path.join(fooDir, 'Package.swift'), + '// swift-tools-version:5.9\n// hand-authored\n', + ); + fs.mkdirSync(path.join(fooDir, '.react-native')); + fs.writeFileSync(path.join(fooDir, '.react-native', 'meta.json'), '{}\n'); + fs.writeFileSync(path.join(fooDir, 'Foo.swift'), '// src\n'); + + // (C) A plugin-host dep contributing watchPaths: one existing absolute path + // (kept), one absent absolute path (filtered at emission), one relative + // path (dropped by invokePlugins). + const expoDir = path.join(appRoot, 'node_modules', 'expo'); + fs.mkdirSync(path.join(expoDir, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(expoDir, 'ios', 'Expo.mm'), '// native\n'); + fs.writeFileSync(path.join(expoDir, 'Package.swift'), '// expo manifest\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'), + [ + "const path = require('path');", + 'module.exports = function () {', + ' return {', + " packageDependencies: [{name: 'ExpoModulesCore', path: '../../../../node_modules/expo/ios'}],", + " productDependencies: [{name: 'ExpoModulesCore', package: 'ExpoModulesCore'}],", + ' watchPaths: [', + " path.join(__dirname, 'Package.swift'),", // exists → kept + " path.join(__dirname, 'MISSING.swift'),", // absent → filtered at emission + " 'rel/manifest',", // relative → dropped by invokePlugins + ' ],', + ' };', + '};', + ].join('\n') + '\n', + ); + + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-foo': {root: fooDir, platforms: {ios: {}}}, + expo: {root: expoDir, platforms: {ios: {}}}, + }, + }), + ); + + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + const lines = readWatchLines(appRoot); + + // (A/B) foo's source dir, root manifest FILE, and .react-native DIR. + expect(lines).toContain(fooDir); + expect(lines).toContain(path.join(fooDir, 'Package.swift')); + expect(lines).toContain(path.join(fooDir, '.react-native')); + + // (C) plugin's existing absolute watchPath is kept. + expect(lines).toContain(path.join(expoDir, 'Package.swift')); + + // Filtered / dropped entries never reach the file. + expect(lines.some(l => l.includes('MISSING.swift'))).toBe(false); + expect(lines.some(l => l.includes('rel/manifest'))).toBe(false); + + // Deduped and sorted (stable, deterministic output). + expect(new Set(lines).size).toBe(lines.length); + expect([...lines].sort()).toEqual(lines); + }); +}); 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 new file mode 100644 index 000000000000..0d96db1a5a7f --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-package-test.js @@ -0,0 +1,282 @@ +/** + * 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 { + findSourcePath, + generateXCFrameworksPackageSwift, + main, +} = require('../generate-spm-package'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// generateXCFrameworksPackageSwift +// --------------------------------------------------------------------------- + +describe('generateXCFrameworksPackageSwift', () => { + it('exposes only invariant compile-time products', () => { + const result = generateXCFrameworksPackageSwift(); + expect(result).toContain( + '.library(name: "ReactHeaders", targets: ["ReactHeaders"])', + ); + expect(result).toContain( + '.library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"])', + ); + expect(result).toContain( + '.library(name: "ReactNativeDependenciesHeaders", targets: ["ReactNativeDependenciesHeaders"])', + ); + expect(result).not.toContain('.library(name: "ReactNative"'); + expect(result).not.toContain('.library(name: "hermes-engine"'); + }); + + it('uses a Clang ReactHeaders target and header-only binary targets', () => { + const result = generateXCFrameworksPackageSwift(); + expect(result).toContain( + 'name: "ReactHeaders",\n dependencies: ["ReactNativeHeaders"]', + ); + expect(result).toContain('path: "ReactHeadersTarget"'); + expect(result).toContain('publicHeadersPath: "include"'); + expect(result).toContain( + 'name: "ReactNativeHeaders",\n path: "ReactNativeHeaders.xcframework"', + ); + expect(result).not.toContain('.binaryTarget(\n name: "React",'); + }); + + it('includes auto-generated header comment', () => { + const result = generateXCFrameworksPackageSwift(); + expect(result).toContain('AUTO-GENERATED'); + expect(result).toContain('swift-tools-version: 6.0'); + expect(result).toContain('name: "ReactNative"'); + }); +}); + +// --------------------------------------------------------------------------- +// findSourcePath +// --------------------------------------------------------------------------- + +describe('findSourcePath', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-find-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds directory matching derived name', () => { + fs.mkdirSync(path.join(tempDir, 'MyApp')); + expect(findSourcePath(tempDir, 'my-app')).toBe('MyApp'); + }); + + it('falls back to ios directory', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect(findSourcePath(tempDir, 'unknown-pkg')).toBe('ios'); + }); + + it('scans for directory with native sources', () => { + fs.mkdirSync(path.join(tempDir, 'CustomDir')); + fs.writeFileSync(path.join(tempDir, 'CustomDir', 'main.m'), ''); + expect(findSourcePath(tempDir, 'unrelated-name')).toBe('CustomDir'); + }); + + it('returns derived name when nothing found', () => { + expect(findSourcePath(tempDir, 'my-app')).toBe('MyApp'); + }); +}); + +// --------------------------------------------------------------------------- +// main — end-to-end generation of build/xcframeworks/{Package.swift,symlinks} +// from a local artifacts.json. The headers composer is injected so the +// happy paths stay inside a tempdir with no cross-package side effects. +// --------------------------------------------------------------------------- + +describe('main', () => { + let appRoot; + let rnRoot; + let origExitCode; + let logSpy; + let errSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-app-')); + rnRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-rn-')); + origExitCode = process.exitCode; + process.exitCode = undefined; + // main() is chatty via makeLogger/console.error — silence to keep output + // readable; assertions target the filesystem, not the logs. + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + process.exitCode = origExitCode; + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(rnRoot, {recursive: true, force: true}); + }); + + // Writes the app package.json so findProjectRoot/readPackageJson resolve. + function writeAppPkg(name /*: string */ = 'my-app') { + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name, version: '1.0.0'}), + 'utf8', + ); + } + + function writeArtifacts(entries /*: Array */) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pkg-art-')); + const json = {}; + for (const name of entries) { + const xcfwPath = path.join(dir, `${name}.xcframework`); + fs.mkdirSync(xcfwPath, {recursive: true}); + json[name] = {xcframeworkPath: xcfwPath, url: 'https://example'}; + } + fs.writeFileSync( + path.join(dir, 'artifacts.json'), + JSON.stringify(json), + 'utf8', + ); + return dir; + } + + function run( + debugArtifactsDir /*:: ?: ?string */, + releaseArtifactsDir /*:: ?: ?string */, + prepare /*:: ?: ?Function */, + ) { + const argv = [ + '--app-root', + appRoot, + '--react-native-root', + rnRoot, + '--version', + '0.85.0', + ]; + if (debugArtifactsDir != null) { + argv.push('--debug-artifacts-dir', debugArtifactsDir); + } + if (releaseArtifactsDir != null) { + argv.push('--release-artifacts-dir', releaseArtifactsDir); + } + main( + argv, + prepare == null ? undefined : {prepareFlavoredFrameworks: prepare}, + ); + } + + it('prepares both flavors before generating the invariant Package.swift', () => { + writeAppPkg(); + const debugArtifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + const releaseArtifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', + ]); + const prepare = jest.fn(({appRoot: root}) => { + fs.mkdirSync(path.join(root, 'build', 'xcframeworks'), {recursive: true}); + }); + try { + run(debugArtifactsDir, releaseArtifactsDir, prepare); + + expect(process.exitCode).toBeUndefined(); + expect(prepare).toHaveBeenCalledWith({ + appRoot, + debugArtifactsDir, + releaseArtifactsDir, + }); + + const pkgSwift = path.join( + appRoot, + 'build', + 'xcframeworks', + 'Package.swift', + ); + expect(fs.existsSync(pkgSwift)).toBe(true); + const contents = fs.readFileSync(pkgSwift, 'utf8'); + expect(contents).toContain('.library(name: "ReactHeaders"'); + expect(contents).not.toContain( + '.binaryTarget(\n name: "React",', + ); + } finally { + fs.rmSync(debugArtifactsDir, {recursive: true, force: true}); + fs.rmSync(releaseArtifactsDir, {recursive: true, force: true}); + } + }); + + it('requires Debug and Release together', () => { + writeAppPkg(); + const artifactsDir = writeArtifacts([ + 'React', + 'ReactNativeDependencies', + 'hermes-engine', + ]); + try { + expect(() => run(artifactsDir, null, jest.fn())).toThrow( + /both --debug-artifacts-dir and --release-artifacts-dir are required/, + ); + } finally { + fs.rmSync(artifactsDir, {recursive: true, force: true}); + } + }); + + it('throws when no package.json is found', () => { + // No app package.json written. + expect(() => run(null, null)).toThrow(/No package\.json/); + }); + + it('does not publish Package.swift if artifact preparation fails', () => { + writeAppPkg(); + const debugDir = writeArtifacts([]); + const releaseDir = writeArtifacts([]); + try { + expect(() => + run(debugDir, releaseDir, () => { + throw new Error('invalid release artifacts'); + }), + ).toThrow(/invalid release artifacts/); + expect( + fs.existsSync( + path.join(appRoot, 'build', 'xcframeworks', 'Package.swift'), + ), + ).toBe(false); + } finally { + fs.rmSync(debugDir, {recursive: true, force: true}); + fs.rmSync(releaseDir, {recursive: true, force: true}); + } + }); + + it('auto-detects an existing build/xcframeworks without --artifacts-dir', () => { + writeAppPkg(); + const xcfwDir = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(xcfwDir, {recursive: true}); + fs.writeFileSync(path.join(xcfwDir, 'Package.swift'), '// existing'); + run(null, null); + // No artifacts-dir: it should leave the existing manifest untouched. + expect(process.exitCode).toBeUndefined(); + expect(fs.readFileSync(path.join(xcfwDir, 'Package.swift'), 'utf8')).toBe( + '// existing', + ); + }); +}); 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 new file mode 100644 index 000000000000..459804f421f5 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js @@ -0,0 +1,228 @@ +/** + * 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 { + addPreActionToScheme, + buildEmbedFrameworksScript, + buildSchemePreActionScript, + buildSyncAutolinkingScript, + flavorForBuildConfiguration, + frameworkConditionalSettings, + generateXcscheme, +} = require('../generate-spm-xcodeproj'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const FRAMEWORK = { + id: 'react', + frameworkName: 'React', + executableName: 'React', + artifactRelativePath: 'React.xcframework', + slices: [ + { + sdk: 'iphoneos*', + platform: 'ios', + variant: null, + architectures: ['arm64'], + libraryIdentifier: 'ios-arm64', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + { + sdk: 'iphonesimulator*', + platform: 'ios', + variant: 'simulator', + architectures: ['arm64', 'x86_64'], + libraryIdentifier: 'ios-arm64_x86_64-simulator', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + { + sdk: 'macosx*', + platform: 'ios', + variant: 'maccatalyst', + architectures: ['arm64', 'x86_64'], + libraryIdentifier: 'ios-arm64_x86_64-maccatalyst', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + ], +}; + +describe('scheme pre-action', () => { + it('contains the sync script and target-scoped build environment', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'SYNC_SENTINEL', + ); + expect(result).toContain(''); + expect(result).toContain('SYNC_SENTINEL'); + expect(result).toContain(''); + expect(result).toContain('BlueprintIdentifier = "TARGET_UUID"'); + }); + + it('XML-escapes the shell script', () => { + const result = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'echo "x" > /tmp/x 2>&1 < /tmp/in', + ); + expect(result).toContain('"'); + expect(result).toContain('>'); + expect(result).toContain('&'); + expect(result).toContain('<'); + }); + + it('refreshes stale script text and is idempotent', () => { + const first = generateXcscheme( + 'MyApp', + 'TARGET_UUID', + 'MyApp', + 'OLD_SCRIPT', + ); + const updated = addPreActionToScheme(first, 'TARGET_UUID', 'NEW_SCRIPT'); + expect(updated).toContain('NEW_SCRIPT'); + expect(updated).not.toContain('OLD_SCRIPT'); + expect(addPreActionToScheme(updated, 'TARGET_UUID', 'NEW_SCRIPT')).toBe( + updated, + ); + }); +}); + +describe('sync scripts', () => { + const baked = '../node_modules/react-native'; + const script = buildSyncAutolinkingScript(baked); + + it('resolves Node and React Native at build time and dispatches sync', () => { + expect(script).toContain('NODE_BINARY="${NODE_BINARY:-}"'); + expect(script).toContain('. "$SRCROOT/.xcode.env"'); + expect(script).toContain( + "require('path').dirname(require.resolve('react-native/package.json'))", + ); + expect(script).toContain( + '"$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync', + ); + expect(script).toContain('npx react-native spm sync'); + }); + + it('does not select or mutate runtime framework flavors', () => { + expect(script).not.toContain('swap-flavor'); + expect(script).not.toContain('RN_SPM_FLAVOR'); + expect(script).not.toContain('React.xcframework'); + expect(script).not.toContain('BUILT_PRODUCTS_DIR'); + }); + + it('watches mixed directories/files and treats vanished paths as stale', () => { + expect(script).toContain('if [ -d "$P" ]; then'); + expect(script).toContain('elif [ -f "$P" ]; then'); + expect(script).toContain( + 'WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths"', + ); + }); + + it('is deterministic, shared with the pre-action, and valid POSIX shell', () => { + expect(buildSyncAutolinkingScript(baked)).toBe(script); + expect(buildSchemePreActionScript(baked)).toBe(script); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-script-')); + const file = path.join(dir, 'sync.sh'); + fs.writeFileSync(file, script); + try { + expect(() => execFileSync('/bin/sh', ['-n', file])).not.toThrow(); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); + +describe('configuration-driven framework settings', () => { + it.each([ + ['Debug', 'debug'], + ['MyDEBUGBuild', 'debug'], + ['Development', 'debug'], + ['MyDevelopmentConfig', 'debug'], + ['Release', 'release'], + ['Staging', 'release'], + ['Production', 'release'], + ])('maps %s to %s', (configuration, expected) => { + expect(flavorForBuildConfiguration(configuration)).toBe(expected); + }); + + it('emits SDK-qualified exact framework, binary, and search paths', () => { + const settings = frameworkConditionalSettings([FRAMEWORK]); + const serialized = JSON.stringify(settings); + expect(serialized).toContain('RN_SPM_REACT_FRAMEWORK[sdk=iphoneos*]'); + expect(serialized).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]'); + expect(serialized).toContain( + '$(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/React.xcframework/ios-arm64', + ); + expect(serialized).toContain('RN_SPM_REACT_SEARCH_PATH[sdk=macosx*]'); + }); + + it('uses architecture conditions when one SDK has disjoint slices', () => { + const split = { + ...FRAMEWORK, + slices: [ + {...FRAMEWORK.slices[1], architectures: ['arm64']}, + { + ...FRAMEWORK.slices[1], + architectures: ['x86_64'], + libraryIdentifier: 'ios-x86_64-simulator', + }, + ], + }; + const serialized = JSON.stringify(frameworkConditionalSettings([split])); + expect(serialized).toContain( + 'RN_SPM_REACT_FRAMEWORK[sdk=iphonesimulator*][arch=arm64]', + ); + expect(serialized).toContain( + 'RN_SPM_REACT_FRAMEWORK[sdk=iphonesimulator*][arch=x86_64]', + ); + }); +}); + +describe('embed framework phase script', () => { + const script = buildEmbedFrameworksScript([FRAMEWORK]); + + it('validates, rsyncs, and signs the exact selected framework', () => { + expect(script).toContain( + 'validate_framework "${RN_SPM_REACT_FRAMEWORK:-}" "React.framework"', + ); + expect(script).toContain( + 'copy_and_sign "${RN_SPM_REACT_FRAMEWORK:-}" "React.framework"', + ); + expect(script.indexOf('validate_framework "${RN_SPM_REACT')).toBeLessThan( + script.indexOf('copy_and_sign "${RN_SPM_REACT'), + ); + expect(script).toContain('/usr/bin/rsync -a --delete'); + expect(script).toContain('EXPANDED_CODE_SIGN_IDENTITY'); + expect(script).toContain('/usr/bin/codesign --force --sign -'); + expect(script).toContain('CODE_SIGNING_ALLOWED:-YES'); + }); + + it('reports configuration, framework, SDK, and selected source on failure', () => { + expect(script).toContain("framework '$name' is unavailable"); + expect(script).toContain("configuration '$CONFIGURATION'"); + expect(script).toContain("SDK '$SDK_NAME': $source"); + }); + + it('uses no Node, network, symlink mutation, or SwiftPM cache path', () => { + expect(script).not.toContain('node'); + expect(script).not.toContain('curl'); + expect(script).not.toContain('ln -s'); + expect(script).not.toContain('SourcePackages'); + }); +}); 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 new file mode 100644 index 000000000000..445b4c6ef31a --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -0,0 +1,444 @@ +/** + * 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 { + injectSpmIntoPbxproj, + planInjection, +} = require('../generate-spm-xcodeproj'); +const fs = require('fs'); +const path = require('path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// Derive a CocoaPods-integrated variant by layering a Pods xcconfig onto the +// app target's Debug config (what makes in-place injection refuse). +const PODS = PLAIN.replace( + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {', + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {', +); + +const RN_PATH = '../node_modules/react-native'; + +// Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through +// a symlinked react-native would resolve to the wrong dir at build time). +const TEST_HERMES_CLI_PATH = + '/abs/node_modules/hermes-compiler/hermesc/osx-bin/hermesc'; +const TEST_FRAMEWORKS = [ + { + id: 'react', + frameworkName: 'React', + executableName: 'React', + artifactRelativePath: 'React.xcframework', + slices: [ + { + sdk: 'iphoneos*', + platform: 'ios', + variant: null, + architectures: ['arm64'], + libraryIdentifier: 'ios-arm64', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + { + sdk: 'iphonesimulator*', + platform: 'ios', + variant: 'simulator', + architectures: ['arm64', 'x86_64'], + libraryIdentifier: 'ios-arm64_x86_64-simulator', + libraryPath: 'React.framework', + binaryPath: 'React.framework/React', + }, + ], + }, +]; + +function inject( + text, + remote = null, + hermesCliPath = TEST_HERMES_CLI_PATH, + generatedSources = [], +) { + const plan = planInjection(text, {}); + expect(plan.ok).toBe(true); + return injectSpmIntoPbxproj( + text, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + RN_PATH, + remote, + hermesCliPath, + generatedSources, + TEST_FRAMEWORKS, + ); +} + +// 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`. +const PROVIDER_SOURCE = { + path: 'build/generated/autolinking/expo/ExpoModulesProvider.swift', + name: 'ExpoModulesProvider.swift', + sourceTree: 'SOURCE_ROOT', + 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, {}); + expect(plan.ok).toBe(true); + expect(plan.target.name).toBe('MyApp'); + expect(plan.configUuids).toHaveLength(2); // Debug + Release + expect(plan.frameworksPhaseUuid).toMatch(/^[0-9A-Fa-f]{24}$/); + // Also resolves the Sources phase (generated sources compile into it). + expect(plan.sourcesPhaseUuid).toMatch(/^[0-9A-Fa-f]{24}$/); + }); + + it('refuses a CocoaPods-integrated target (fail-closed for fallback)', () => { + const plan = planInjection(PODS, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toMatch(/CocoaPods/); + }); + + it('refuses when there is no application target', () => { + const noApp = PLAIN.replace( + '"com.apple.product-type.application"', + '"com.apple.product-type.framework"', + ); + const plan = planInjection(noApp, {}); + expect(plan.ok).toBe(false); + expect(plan.reason).toMatch(/no application target/); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => { + it('adds the local package references and product dependencies', () => { + const {text} = inject(PLAIN); + expect(text).toContain('/* Begin XCLocalSwiftPackageReference section */'); + expect(text).toContain('relativePath = build/xcframeworks'); + expect(text).toContain('relativePath = build/generated/autolinking'); + expect(text).toContain('relativePath = build/generated/ios'); + // One XCSwiftPackageProductDependency per product (6). + expect(text.match(/isa = XCSwiftPackageProductDependency;/g)).toHaveLength( + 6, + ); + expect(text).toContain('productName = ReactHeaders'); + expect(text).not.toContain('productName = ReactNative;'); + expect(text).toContain('productName = Autolinked'); + expect(text).toContain('productName = ReactCodegen'); + }); + + it('wires packageReferences onto the project and product deps onto the target', () => { + const {text} = inject(PLAIN); + expect(text).toMatch(/packageReferences = \(/); + expect(text).toMatch(/packageProductDependencies = \(/); + // Product build files land in the Frameworks phase. + expect(text).toContain('ReactHeaders in Frameworks'); + }); + + it('uses remote package references in remote mode', () => { + const remote = { + url: 'https://github.com/facebook/react-native', + version: '0.87.0', + identity: 'react-native', + }; + const {text} = inject(PLAIN, remote); + expect(text).toContain('/* Begin XCRemoteSwiftPackageReference section */'); + expect(text).toContain( + 'repositoryURL = "https://github.com/facebook/react-native"', + ); + // build/xcframeworks is NOT referenced locally in remote mode. + expect(text).not.toContain('relativePath = build/xcframeworks'); + // The app's generated-code packages stay local. + expect(text).toContain('relativePath = build/generated/ios'); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { + it('merges React build settings into BOTH build configurations', () => { + const {text} = inject(PLAIN); + expect(text.match(/-ObjC/g)).toHaveLength(2); + expect(text.match(/REACT_NATIVE_PATH = /g)).toHaveLength(2); + expect(text).not.toContain('fmodule-map-file='); + expect(text).toContain('build/generated/autolinking/headers'); + expect(text.match(/CLANG_CXX_LANGUAGE_STANDARD = "c\+\+20"/g)).toHaveLength( + 2, + ); + // HERMES_CLI_PATH points react-native-xcode.sh at the hermes-compiler npm + // package (no hermes-engine pod under SPM), injected into both configs. + expect(text.match(/HERMES_CLI_PATH = /g)).toHaveLength(2); + expect(text).toContain(TEST_HERMES_CLI_PATH); + expect(text).toContain('RN_SPM_FLAVOR = debug'); + expect(text).toContain('RN_SPM_FLAVOR = release'); + expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphoneos*]'); + expect(text).toContain('RN_SPM_REACT_BINARY[sdk=iphonesimulator*]'); + expect(text).toContain('$(RN_SPM_REACT_BINARY)'); + }); + + it('omits HERMES_CLI_PATH when hermesc could not be resolved', () => { + const {text} = inject(PLAIN, null, null); + expect(text).not.toContain('HERMES_CLI_PATH'); + }); + + it('prepends the Sync SPM Autolinking build phase', () => { + const {text} = inject(PLAIN); + expect(text).toContain('Sync SPM Autolinking'); + expect(text).toContain('npx react-native spm sync'); + // It runs before Sources. + const syncIdx = text.indexOf('Sync SPM Autolinking */,'); + const sourcesIdx = text.indexOf('Sources */,'); + expect(syncIdx).toBeGreaterThan(-1); + expect(syncIdx).toBeLessThan(sourcesIdx); + }); + + it('adds one generated embed phase immediately after Frameworks', () => { + const {text} = inject(PLAIN); + expect(text).not.toContain('Fix SPM Embedded Flavor'); + const bp = text.slice(text.indexOf('buildPhases = (')); + const arr = bp.slice(0, bp.indexOf(');')); + const comments = [...arr.matchAll(/\/\* ([^*]+) \*\//g)].map(m => m[1]); + expect(comments[0]).toBe('Sync SPM Autolinking'); // prepended, first + expect(comments.indexOf('Embed React Native Flavored Frameworks')).toBe( + comments.indexOf('Frameworks') + 1, + ); + expect(text).toContain('$(SRCROOT)/build/xcframeworks/.artifact-stamp'); + expect(text).toContain('$(RN_SPM_REACT_FRAMEWORK)'); + expect(text).toContain( + '$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/React.framework', + ); + }); +}); + +describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => { + it('wires a manifest entry into the app target (ref + build file + Sources + group)', () => { + const {text, generatedSourceUuids} = inject(PLAIN, null, null, [ + PROVIDER_SOURCE, + ]); + const [fileRefUuid, buildFileUuid] = + generatedSourceUuids[PROVIDER_SOURCE.path]; + expect(fileRefUuid).toMatch(/^[0-9A-F]{24}$/); + expect(buildFileUuid).toMatch(/^[0-9A-F]{24}$/); + + // PBXFileReference with the SRCROOT-relative path + SOURCE_ROOT tree. + expect(text).toContain(`${fileRefUuid} /* ExpoModulesProvider.swift */`); + expect(text).toContain('lastKnownFileType = sourcecode.swift'); + expect(text).toContain(`path = ${PROVIDER_SOURCE.path};`); + expect(text).toContain('sourceTree = SOURCE_ROOT;'); + + // PBXBuildFile → the file ref, and a Sources-phase membership. + expect(text).toContain( + `${buildFileUuid} /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ${fileRefUuid} /* ExpoModulesProvider.swift */;};`, + ); + // The build file is a member of the Sources phase (compiled into the app). + const sourcesPhase = text.slice( + text.indexOf('/* Begin PBXSourcesBuildPhase section */'), + ); + expect(sourcesPhase.slice(0, sourcesPhase.indexOf('/* End'))).toContain( + `${buildFileUuid} /* ExpoModulesProvider.swift in Sources */,`, + ); + + // The single "SPM Generated Sources" group, parented and holding the ref. + expect(text).toContain('/* SPM Generated Sources */ = {'); + expect(text).toContain('isa = PBXGroup;'); + const groupBlock = text.slice( + text.indexOf('/* SPM Generated Sources */ = {'), + ); + expect(groupBlock.slice(0, groupBlock.indexOf('};'))).toContain( + `${fileRefUuid} /* ExpoModulesProvider.swift */,`, + ); + // File ref + build file UUIDs are tracked for deinit. + const {injectedUuids} = inject(PLAIN, null, null, [PROVIDER_SOURCE]); + expect(injectedUuids).toEqual( + expect.arrayContaining([fileRefUuid, buildFileUuid]), + ); + expect(isBalanced(text)).toBe(true); + }); + + it('is idempotent with generated sources — a second run is byte-for-byte identical', () => { + const first = inject(PLAIN, null, null, [PROVIDER_SOURCE]).text; + const plan = planInjection(first, {}); + const second = injectSpmIntoPbxproj( + first, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + RN_PATH, + null, + null, + [PROVIDER_SOURCE], + TEST_FRAMEWORKS, + ).text; + expect(second).toBe(first); + }); + + it('stores an out-of-tree source as an absolute reference', () => { + const abs = { + path: '/opt/generated/OtherProvider.swift', + name: 'OtherProvider.swift', + sourceTree: '""', + fileType: 'sourcecode.swift', + }; + const {text} = inject(PLAIN, null, null, [abs]); + expect(text).toContain('path = /opt/generated/OtherProvider.swift;'); + expect(text).toContain('sourceTree = "";'); + }); + + it('logs loudly and skips wiring when the target has no Sources phase', () => { + const noSources = PLAIN.replace( + /\/\* Begin PBXSourcesBuildPhase section \*\/[\s\S]*?\/\* End PBXSourcesBuildPhase section \*\/\n\n/, + '', + ); + const plan = planInjection(noSources, {}); + expect(plan.ok).toBe(true); + expect(plan.sourcesPhaseUuid).toBeNull(); + + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const {text, generatedSourceUuids} = injectSpmIntoPbxproj( + noSources, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + RN_PATH, + null, + null, + [PROVIDER_SOURCE], + TEST_FRAMEWORKS, + ); + const logged = spy.mock.calls.map(c => c[0]).join('\n'); + spy.mockRestore(); + + expect(logged).toMatch(/no Sources build phase/); + // No generated source wired, but the SPM graph injection still happened. + expect(generatedSourceUuids).toEqual({}); + expect(text).not.toContain('SPM Generated Sources'); + expect(text).toContain('productName = ReactHeaders'); + }); +}); + +describe('injectSpmIntoPbxproj — invariants', () => { + it('produces a balanced (well-formed) pbxproj', () => { + const {text} = inject(PLAIN); + expect(isBalanced(PLAIN)).toBe(true); + expect(isBalanced(text)).toBe(true); + }); + + it('is idempotent — a second injection is a byte-for-byte no-op', () => { + const first = inject(PLAIN).text; + const plan = planInjection(first, {}); + const second = injectSpmIntoPbxproj( + first, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + }, + RN_PATH, + null, + null, + [], + TEST_FRAMEWORKS, + ).text; + expect(second).toBe(first); + }); + + it('keeps the diff small — only adds lines, never removes original ones', () => { + const {text} = inject(PLAIN); + // Every original line is preserved verbatim (purely additive splice). + for (const line of PLAIN.split('\n')) { + if (line.trim() === '') continue; + expect(text).toContain(line); + } + const added = text.split('\n').length - PLAIN.split('\n').length; + // Sanity bound: the complete SPM graph + conditional settings + phases is + // still a compact additive transform. + expect(added).toBeGreaterThan(0); + expect(added).toBeLessThan(220); + }); + + it('refreshes a stale shellScript on re-injection', () => { + const first = inject(PLAIN).text; + // Simulate an earlier run whose generated script has since changed (e.g. + // fixed dispatch logic) by corrupting a substring of the baked-in script. + const stale = first.replace( + 'npx react-native spm sync', + 'STALE_OLD_SYNC_COMMAND', + ); + expect(stale).not.toBe(first); + const plan = planInjection(stale, {}); + const second = injectSpmIntoPbxproj( + stale, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + }, + RN_PATH, + null, + null, + [], + TEST_FRAMEWORKS, + ).text; + // The stale marker is gone and the current script is restored. + expect(second).not.toContain('STALE_OLD_SYNC_COMMAND'); + expect(second).toContain('npx react-native spm sync'); + expect(second).toBe(first); + }); + + it('namespaces injected UUIDs by the host project root (collision-safe, stable)', () => { + const {injectedUuids} = inject(PLAIN); + // All injected UUIDs are valid 24-hex and none collide with the originals. + const originalUuids = new Set(PLAIN.match(/[0-9A-Fa-f]{24}/g)); + for (const u of injectedUuids) { + expect(u).toMatch(/^[0-9A-F]{24}$/); + expect(originalUuids.has(u)).toBe(false); + } + // Deterministic across runs. + expect(inject(PLAIN).injectedUuids).toEqual(injectedUuids); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/read-podspec-test.js b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js new file mode 100644 index 000000000000..135667fa729e --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/read-podspec-test.js @@ -0,0 +1,487 @@ +/** + * 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 {flattenSubspecs, readPodspec, regexPodspec} = require('../read-podspec'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Real-world podspec fixtures inlined as strings. The regex parser is tested +// against these directly; flattenSubspecs is tested against pod-ipc-style +// JSON objects that match what `pod ipc spec` actually emits. + +const SAFE_AREA_PODSPEC = ` +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "react-native-safe-area-context" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + s.platforms = { :ios => "12.4", :tvos => "12.4", :osx => "10.15" } + s.source = { :git => package["repository"]["url"], :tag => "#{s.version}" } + s.source_files = "ios/**/*.{h,m,mm}" + s.dependency "React-Core" +end +`; + +const SIMPLE_LIB_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.2.3" + s.source_files = "ios/**/*.{h,m,mm}" + s.public_header_files = "ios/**/*.h" + s.framework = "UIKit" + s.frameworks = ["Foundation", "CoreGraphics"] + s.dependency "React-Core" + s.dependency "React-jsi" +end +`; + +const REANIMATED_LIKE_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "RNReanimated" + s.version = "1.0.0" + s.dependency "RNWorklets" + install_modules_dependencies(s) + s.subspec "common" do |ss| + ss.source_files = "Common/cpp/reanimated/**/*.{cpp,h}" + ss.header_mappings_dir = "Common/cpp/reanimated" + ss.header_dir = "reanimated" + end + s.subspec "apple" do |ss| + ss.source_files = "apple/reanimated/**/*.{mm,h,m}" + ss.header_mappings_dir = "apple/reanimated" + end +end +`; + +const HEADER_SEARCH_PATHS_PODSPEC = ` +Pod::Spec.new do |s| + s.name = "react-native-thing" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm}" + s.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\\"$(PODS_TARGET_SRCROOT)/common/cpp\\"" + } +end +`; + +// Helper: write a fixture to a temp file and return its path. +function writeFixture(name, content) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podspec-')); + const file = path.join(dir, name); + fs.writeFileSync(file, content); + return {file, dir}; +} + +// --------------------------------------------------------------------------- +// regexPodspec — best-effort fallback when CocoaPods isn't available. +// Should handle simple RN libs cleanly and degrade gracefully on subspecs / +// install_modules_dependencies() (warns + partial = true). +// --------------------------------------------------------------------------- + +describe('regexPodspec', () => { + it('extracts name, version, source_files, dependency from a real-world simple podspec', () => { + const {file, dir} = writeFixture( + 'react-native-safe-area-context.podspec', + SAFE_AREA_PODSPEC, + ); + try { + const raw = regexPodspec(file); + expect(raw.name).toBe('react-native-safe-area-context'); + expect(raw.source_files).toEqual(['ios/**/*.{h,m,mm}']); + expect(raw.dependencies).toEqual(['React-Core']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('handles s.framework (singular method call) and s.frameworks (array assignment) together', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.frameworks.sort()).toEqual([ + 'CoreGraphics', + 'Foundation', + 'UIKit', + ]); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('collects multiple s.dependency lines in declaration order', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.dependencies).toEqual(['React-Core', 'React-jsi']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('extracts pod_target_xcconfig HEADER_SEARCH_PATHS (string form), preserving the $(PODS_TARGET_SRCROOT) token', () => { + const {file, dir} = writeFixture( + 'hsp.podspec', + HEADER_SEARCH_PATHS_PODSPEC, + ); + try { + const raw = regexPodspec(file); + const hsp = raw.pod_target_xcconfig.HEADER_SEARCH_PATHS; + expect(hsp).toContain('$(PODS_TARGET_SRCROOT)/common/cpp'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('warns on subspec blocks and install_modules_dependencies() so callers know coverage is partial', () => { + const {file, dir} = writeFixture( + 'subspec.podspec', + REANIMATED_LIKE_PODSPEC, + ); + try { + const raw = regexPodspec(file); + expect(raw.__warnings__.some(w => /Subspecs detected/.test(w))).toBe( + true, + ); + expect( + raw.__warnings__.some(w => /install_modules_dependencies/.test(w)), + ).toBe(true); + expect(raw.__regex_partial__).toBe(true); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('marks output as partial so flattenSubspecs can propagate to the PodspecModel', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + const raw = regexPodspec(file); + expect(raw.__regex_partial__).toBe(true); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); + +// --------------------------------------------------------------------------- +// flattenSubspecs — merges default_subspecs (or all subspecs) into a single +// logical PodspecModel. Tested with pod-ipc-shaped objects directly, since +// that's the shape that exercises the merging logic (regex doesn't extract +// subspec bodies). +// --------------------------------------------------------------------------- + +describe('flattenSubspecs', () => { + it('returns a model from a top-level-only spec without touching subspecs', () => { + const raw = { + name: 'react-native-foo', + version: '1.0', + source_files: 'ios/**/*.{h,m,mm}', + dependencies: {'React-Core': []}, + }; + const model = flattenSubspecs(raw); + expect(model.name).toBe('react-native-foo'); + expect(model.version).toBe('1.0'); + expect(model.sourceFiles).toEqual(['ios/**/*.{h,m,mm}']); + expect(model.dependencies).toEqual(['React-Core']); + expect(model.partial).toBe(false); + }); + + it('lifts preprocessor defines from OTHER_CFLAGS + GCC_PREPROCESSOR_DEFINITIONS (worklets shape)', () => { + const raw = { + name: 'RNWorklets', + version: '0.9.2', + pod_target_xcconfig: { + OTHER_CFLAGS: + '$(inherited) -DWORKLETS_FEATURE_FLAGS="[A:false][B:true]" -DWORKLETS_VERSION=0.9.2 ', + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Debug*]': + '$(inherited) HERMES_ENABLE_DEBUGGER=1', + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Release*]': '$(inherited)', + }, + }; + const model = flattenSubspecs(raw); + const byName = Object.fromEntries( + model.preprocessorDefines.map(d => [d.name, d]), + ); + // Quoted string-literal value kept intact (incl. its quotes). + expect(byName.WORKLETS_FEATURE_FLAGS).toEqual({ + name: 'WORKLETS_FEATURE_FLAGS', + value: '"[A:false][B:true]"', + config: null, + }); + expect(byName.WORKLETS_VERSION).toEqual({ + name: 'WORKLETS_VERSION', + value: '0.9.2', + config: null, + }); + // Per-config define scoped to debug; $(inherited) dropped. + expect(byName.HERMES_ENABLE_DEBUGGER).toEqual({ + name: 'HERMES_ENABLE_DEBUGGER', + value: '1', + config: 'debug', + }); + expect(model.preprocessorDefines).toHaveLength(3); + }); + + it('parses a multi-path HEADER_SEARCH_PATHS string with embedded quotes + recursive /** (skia shape)', () => { + const raw = { + name: 'react-native-skia', + version: '1.0', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: + '"$(PODS_TARGET_SRCROOT)/cpp/"/** "$(PODS_TARGET_SRCROOT)/cpp" "$(PODS_TARGET_SRCROOT)/cpp/skia" "$(PODS_TARGET_SRCROOT)/cpp/dawn/include"', + }, + }; + const model = flattenSubspecs(raw); + // Each space-separated, individually-quoted path becomes its own entry + // (quotes stripped); the `/**` recursive marker is preserved for translate. + expect(model.headerSearchPaths).toEqual([ + '$(PODS_TARGET_SRCROOT)/cpp//**', + '$(PODS_TARGET_SRCROOT)/cpp', + '$(PODS_TARGET_SRCROOT)/cpp/skia', + '$(PODS_TARGET_SRCROOT)/cpp/dawn/include', + ]); + }); + + it('lifts defines from s.xcconfig too, not just pod_target_xcconfig (reanimated shape)', () => { + const raw = { + name: 'RNReanimated', + version: '4.4.1', + // reanimated declares its version define in `s.xcconfig`, not + // pod_target_xcconfig (where worklets puts it). + xcconfig: { + OTHER_CFLAGS: '$(inherited) -DREANIMATED_VERSION=4.4.1', + }, + pod_target_xcconfig: { + 'GCC_PREPROCESSOR_DEFINITIONS[config=*Debug*]': + '$(inherited) HERMES_ENABLE_DEBUGGER=1', + }, + }; + const model = flattenSubspecs(raw); + const byName = Object.fromEntries( + model.preprocessorDefines.map(d => [d.name, d]), + ); + expect(byName.REANIMATED_VERSION).toEqual({ + name: 'REANIMATED_VERSION', + value: '4.4.1', + config: null, + }); + expect(byName.HERMES_ENABLE_DEBUGGER.config).toBe('debug'); + }); + + it('drops non-define flags and unresolved tokens from OTHER_CFLAGS', () => { + const raw = { + name: 'foo', + version: '1', + pod_target_xcconfig: { + OTHER_CFLAGS: + '-Wno-comma -gen-cdb-fragment-path build/cdb -DGOOD=1 -D$(BAD_TOKEN)=x -DALSO_GOOD', + }, + }; + const model = flattenSubspecs(raw); + const names = model.preprocessorDefines.map(d => d.name).sort(); + // Only the two valid -D defines survive; -W / -gen-cdb-fragment-path and + // the unresolved $(...) token are dropped. + expect(names).toEqual(['ALSO_GOOD', 'GOOD']); + expect( + model.preprocessorDefines.find(d => d.name === 'ALSO_GOOD').value, + ).toBe(null); + }); + + it('unions source_files across selected subspecs', () => { + const raw = { + name: 'foo', + version: '1', + source_files: 'top/**/*.h', + subspecs: [ + {name: 'common', source_files: 'Common/cpp/**/*.cpp'}, + {name: 'apple', source_files: 'apple/**/*.mm'}, + ], + default_subspecs: ['common', 'apple'], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles.sort()).toEqual([ + 'Common/cpp/**/*.cpp', + 'apple/**/*.mm', + 'top/**/*.h', + ]); + }); + + it('selects ALL subspecs when default_subspecs is unset (matches CocoaPods behavior)', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + {name: 'a', source_files: 'a/**/*.h'}, + {name: 'b', source_files: 'b/**/*.h'}, + ], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles.sort()).toEqual(['a/**/*.h', 'b/**/*.h']); + }); + + it('honors default_subspecs by name — non-default subspecs are excluded', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + {name: 'core', source_files: 'core/**/*.h'}, + {name: 'optional', source_files: 'optional/**/*.h'}, + ], + default_subspecs: ['core'], + }; + const model = flattenSubspecs(raw); + expect(model.sourceFiles).toEqual(['core/**/*.h']); + expect(model.sourceFiles).not.toContain('optional/**/*.h'); + }); + + it('merges pod_target_xcconfig HEADER_SEARCH_PATHS across subspecs and dedupes', () => { + const raw = { + name: 'foo', + version: '1', + subspecs: [ + { + name: 'a', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: + '"$(PODS_TARGET_SRCROOT)/a/cpp" "$(PODS_TARGET_SRCROOT)/shared"', + }, + }, + { + name: 'b', + pod_target_xcconfig: { + HEADER_SEARCH_PATHS: ['"$(PODS_TARGET_SRCROOT)/shared"'], + }, + }, + ], + }; + const model = flattenSubspecs(raw); + expect(model.headerSearchPaths).toEqual( + expect.arrayContaining([ + '$(PODS_TARGET_SRCROOT)/a/cpp', + '$(PODS_TARGET_SRCROOT)/shared', + ]), + ); + // dedup + const sharedCount = model.headerSearchPaths.filter( + p => p === '$(PODS_TARGET_SRCROOT)/shared', + ).length; + expect(sharedCount).toBe(1); + }); + + it('takes the first non-null header_mappings_dir (subspec layer-walk order)', () => { + const raw = { + name: 'foo', + version: '1', + // top-level has no mappings_dir + subspecs: [ + {name: 'common', header_mappings_dir: 'Common/cpp/foo'}, + {name: 'apple', header_mappings_dir: 'apple/foo'}, + ], + }; + const model = flattenSubspecs(raw); + expect(model.headerMappingsDir).toBe('Common/cpp/foo'); + }); + + it('accepts dependencies as a pod-ipc hash {name: [version]} OR as an array (regex fallback shape)', () => { + const fromIpc = flattenSubspecs({ + name: 'foo', + version: '1', + dependencies: {'React-Core': [], 'React-jsi': ['1.0']}, + }); + expect(fromIpc.dependencies.sort()).toEqual(['React-Core', 'React-jsi']); + + const fromRegex = flattenSubspecs({ + name: 'foo', + version: '1', + dependencies: ['React-Core', 'React-jsi'], + }); + expect(fromRegex.dependencies.sort()).toEqual(['React-Core', 'React-jsi']); + }); + + it('tokenizes compiler_flags from either string ("a b c") or array form', () => { + const a = flattenSubspecs({ + name: 'foo', + version: '1', + compiler_flags: '-Wno-documentation -fno-rtti', + }); + expect(a.compilerFlags).toEqual(['-Wno-documentation', '-fno-rtti']); + + const b = flattenSubspecs({ + name: 'foo', + version: '1', + compiler_flags: ['-Wno-documentation', '-fno-rtti'], + }); + expect(b.compilerFlags).toEqual(['-Wno-documentation', '-fno-rtti']); + }); + + it('propagates __regex_partial__ + __warnings__ from the regex fallback into the PodspecModel', () => { + const raw = { + name: 'foo', + version: '1', + __regex_partial__: true, + __warnings__: [ + 'Subspecs detected', + 'install_modules_dependencies detected', + ], + }; + const model = flattenSubspecs(raw); + expect(model.partial).toBe(true); + expect(model.warnings.length).toBe(2); + }); + + it('defaults requires_arc to true; explicit false is honored', () => { + expect(flattenSubspecs({name: 'a', version: '1'}).requiresArc).toBe(true); + expect( + flattenSubspecs({name: 'a', version: '1', requires_arc: false}) + .requiresArc, + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// readPodspec — orchestrator. We can't easily test the pod-ipc branch (would +// require either CocoaPods on the test runner or invasive child-process +// mocking), so we exercise the fallback path: when `pod` isn't on PATH, the +// regex parser kicks in transparently. +// --------------------------------------------------------------------------- + +describe('readPodspec', () => { + it('throws a clear error when the file does not exist', () => { + expect(() => readPodspec('/no/such/file.podspec')).toThrow( + /does not exist/, + ); + }); + + it('returns a flattened PodspecModel for a simple podspec (regex fallback path)', () => { + const {file, dir} = writeFixture('simple.podspec', SIMPLE_LIB_PODSPEC); + try { + // We can't force pod-ipc to fail without mocking, but the regex + // parser produces a complete enough model that the test assertions + // hold regardless of which branch ran. + const model = readPodspec(file); + expect(model.name).toBe('react-native-foo'); + expect(model.version).toBe('1.2.3'); + expect(model.sourceFiles).toContain('ios/**/*.{h,m,mm}'); + expect(model.dependencies).toEqual( + expect.arrayContaining(['React-Core', 'React-jsi']), + ); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); +}); 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 new file mode 100644 index 000000000000..0282e4bf57fc --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -0,0 +1,395 @@ +/** + * 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 { + SPM_INJECTED_MARKER, + injectSpmIntoExistingXcodeproj, + readArtifactsVersionOverride, + removeSpmInjection, +} = require('../generate-spm-xcodeproj'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const PLAIN = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +let scaffoldedAppRoots = []; + +afterEach(() => { + for (const appRoot of scaffoldedAppRoots) { + fs.rmSync(appRoot, {recursive: true, force: true}); + } + scaffoldedAppRoots = []; +}); + +// 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() { + 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'); + const rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + fs.mkdirSync(rnRoot, {recursive: true}); + const artifactRoot = path.join(appRoot, 'build', 'xcframeworks'); + fs.mkdirSync(artifactRoot, {recursive: true}); + fs.writeFileSync( + path.join(artifactRoot, 'flavored-frameworks.json'), + JSON.stringify({version: 1, frameworks: []}), + ); + fs.writeFileSync(path.join(artifactRoot, '.artifact-stamp'), 'test\n'); + return {appRoot, xcodeprojPath, rnRoot}; +} + +function pbxprojOf(xcodeprojPath) { + return fs.readFileSync(path.join(xcodeprojPath, 'project.pbxproj'), 'utf8'); +} + +// Absolute source paths under the app root — mirrors what Expo emits +// (/expo/ExpoModulesProvider.swift). The injector normalizes these +// to SRCROOT-relative. +const PROVIDER_REL = + 'build/generated/autolinking/expo/ExpoModulesProvider.swift'; +const OTHER_REL = 'build/generated/autolinking/other/OtherProvider.swift'; + +const GENERATED_SOURCES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-generated-sources.json', +); + +function writeManifest(appRoot, relPaths) { + const manifestPath = path.join(appRoot, GENERATED_SOURCES_MANIFEST); + fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); + fs.writeFileSync( + manifestPath, + JSON.stringify( + relPaths.map(rel => ({path: path.join(appRoot, rel)})), + null, + 2, + ), + 'utf8', + ); +} + +function readMarker(xcodeprojPath) { + return JSON.parse( + fs.readFileSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER), 'utf8'), + ); +} + +describe('removeSpmInjection — the surgical inverse of add', () => { + it('round-trips: add then deinit restores the pbxproj byte-for-byte', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + + const injected = injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(injected.status).toBe('injected'); + // It actually changed something + wrote the marker. + expect(pbxprojOf(xcodeprojPath)).not.toBe(before); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + true, + ); + + const removed = removeSpmInjection({appRoot, xcodeprojPath}); + expect(removed.status).toBe('removed'); + // Byte-identical to the pre-add pbxproj. + expect(pbxprojOf(xcodeprojPath)).toBe(before); + // Marker is gone. + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + }); + + it('preserves an unrelated edit made to the pbxproj after add', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + // Simulate a user edit AFTER injection: flip the deployment target. + const edited = pbxprojOf(xcodeprojPath).replace( + /IPHONEOS_DEPLOYMENT_TARGET = [0-9.]+;/g, + 'IPHONEOS_DEPLOYMENT_TARGET = 18.0;', + ); + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + edited, + 'utf8', + ); + + removeSpmInjection({appRoot, xcodeprojPath}); + + const after = pbxprojOf(xcodeprojPath); + // The user's edit survives… + expect(after).toContain('IPHONEOS_DEPLOYMENT_TARGET = 18.0;'); + // …and all SPM injection is gone. + expect(after).not.toContain('Sync SPM Autolinking'); + expect(after).not.toContain('build/generated/autolinking/headers'); + expect(after).not.toContain('REACT_NATIVE_PATH'); + expect(after).not.toMatch(/relativePath = build\/xcframeworks/); + }); + + it('is a no-op (status: absent) when the project was never injected', () => { + const {appRoot, xcodeprojPath} = scaffoldApp(); + const before = pbxprojOf(xcodeprojPath); + const result = removeSpmInjection({appRoot, xcodeprojPath}); + expect(result.status).toBe('absent'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + + it('round-trips WITH a generated-sources manifest (add then deinit is byte-identical)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeManifest(appRoot, [PROVIDER_REL]); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + // The generated source was actually wired in. + expect(after).toContain('ExpoModulesProvider.swift'); + expect(after).toContain('SPM Generated Sources'); + // Stored SRCROOT-relative (under the app root). + expect(after).toContain(`path = ${PROVIDER_REL};`); + expect(after).toContain('sourceTree = SOURCE_ROOT;'); + + // Marker round-trip: the generatedSources section maps the normalized path. + const marker = readMarker(xcodeprojPath); + expect(Object.keys(marker.generatedSources)).toEqual([PROVIDER_REL]); + expect(marker.generatedSources[PROVIDER_REL]).toHaveLength(2); + + const removed = removeSpmInjection({appRoot, xcodeprojPath}); + expect(removed.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(); + // First run: two generated sources. + writeManifest(appRoot, [PROVIDER_REL, OTHER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const marker1 = readMarker(xcodeprojPath); + const droppedUuids = marker1.generatedSources[OTHER_REL]; + const keptUuids = marker1.generatedSources[PROVIDER_REL]; + expect(droppedUuids).toHaveLength(2); + + // Second run (simulating `spm update`): OTHER dropped from the manifest. + writeManifest(appRoot, [PROVIDER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + + // Exactly the dropped entry's objects are gone… + for (const u of droppedUuids) { + expect(after).not.toContain(u); + } + expect(after).not.toContain('OtherProvider.swift'); + // …the kept entry + the group survive. + for (const u of keptUuids) { + expect(after).toContain(u); + } + expect(after).toContain('ExpoModulesProvider.swift'); + expect(after).toContain('SPM Generated Sources'); + + // Marker no longer lists the dropped entry. + const marker2 = readMarker(xcodeprojPath); + expect(Object.keys(marker2.generatedSources)).toEqual([PROVIDER_REL]); + }); + + it('re-injecting an unchanged manifest is byte-for-byte identical', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeManifest(appRoot, [PROVIDER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const first = pbxprojOf(xcodeprojPath); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toBe(first); + }); + + it('retires the group when the last generated source leaves the manifest', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + writeManifest(appRoot, [PROVIDER_REL]); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + // Manifest becomes empty on the next update. + writeManifest(appRoot, []); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('ExpoModulesProvider.swift'); + expect(after).not.toContain('SPM Generated Sources'); + expect(readMarker(xcodeprojPath).generatedSources).toEqual({}); + }); + + it('injects nothing generated-source-related when no manifest exists', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const after = pbxprojOf(xcodeprojPath); + expect(after).not.toContain('SPM Generated Sources'); + expect(readMarker(xcodeprojPath).generatedSources).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// 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 +// previously-recorded value when the caller omits one; deinit drops it along +// with the rest of the marker. +// --------------------------------------------------------------------------- +describe('artifactsVersionOverride marker field', () => { + it('records an explicit override into the marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBe('0.80.0'); + expect(readArtifactsVersionOverride(appRoot)).toBe('0.80.0'); + }); + + it('defaults to null when no --version override has ever been given', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBeNull(); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); + + it('preserves a previously-recorded override on a later run without --version', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + // A later `update` (no --version) must not erase the pin. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBe('0.80.0'); + expect(readArtifactsVersionOverride(appRoot)).toBe('0.80.0'); + }); + + it('a later explicit --version overwrites the previous pin', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.81.0', + }); + expect(readMarker(xcodeprojPath).artifactsVersionOverride).toBe('0.81.0'); + expect(readArtifactsVersionOverride(appRoot)).toBe('0.81.0'); + }); + + it('deinit drops the override along with the whole marker (no clear verb yet)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + removeSpmInjection({appRoot, xcodeprojPath}); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- +describe('readArtifactsVersionOverride', () => { + it('returns null when no xcodeproj has been injected yet', () => { + const {appRoot} = scaffoldApp(); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); + + it('returns null (never throws) on a malformed marker', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + artifactsVersionOverride: '0.80.0', + }); + fs.writeFileSync( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + '{ not valid json', + 'utf8', + ); + expect(() => readArtifactsVersionOverride(appRoot)).not.toThrow(); + expect(readArtifactsVersionOverride(appRoot)).toBeNull(); + }); +}); 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 new file mode 100644 index 000000000000..d42bb1253b28 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/scaffold-package-swift-test.js @@ -0,0 +1,1138 @@ +/** + * 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 { + SCAFFOLDER_MARKER, + SCAFFOLDER_VERSION, + emitScaffoldedPackageSwift, + scaffoldAll, + scaffoldPackageSwiftForDep, + translatePodspecToSpmTarget, +} = require('../scaffold-package-swift'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Minimal PodspecModel fixture builder so each test stays focused on the +// field it exercises. +function podspec(overrides /*: Object */ = {}) { + return { + name: 'react-native-foo', + version: '1.0', + sourceFiles: [], + publicHeaderFiles: [], + privateHeaderFiles: [], + excludeFiles: [], + headerMappingsDir: null, + headerMappingsDirs: [], + headerDir: null, + frameworks: [], + weakFrameworks: [], + libraries: [], + dependencies: [], + compilerFlags: [], + headerSearchPaths: [], + preprocessorDefines: [], + resources: [], + requiresArc: true, + warnings: [], + partial: false, + usesInstallModulesDependencies: false, + ...overrides, + }; +} + +function autolinkedDep(overrides = {}) { + return { + name: 'react-native-foo', + root: '/fake/node_modules/react-native-foo', + platforms: { + ios: { + podspecPath: + '/fake/node_modules/react-native-foo/react-native-foo.podspec', + }, + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// translatePodspecToSpmTarget — pure: PodspecModel + AutolinkedDep → +// SpmScaffoldSpec. Buckets deps, substitutes Xcode tokens, validates names. +// --------------------------------------------------------------------------- + +describe('translatePodspecToSpmTarget', () => { + it('always uses toSwiftName(npm-name) as the SPM target name — header_dir does NOT change the target name', () => { + // The autolinker registers every autolinked dep under toSwiftName(npmName) + // in its aggregator. The scaffolded Package.swift's product MUST match + // that or SPM resolution fails on the .product(name:, package:) lookup. + // header_dir flows through headerSearchPaths instead. + const model = podspec({ + headerDir: 'react/renderer/components/safeareacontext', + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-safe-area-context'}), + ); + expect(spec.swiftName).toBe('ReactNativeSafeAreaContext'); + }); + + it('adds dirname(header_mappings_dir) as a header search path so namespaced includes resolve (reanimated/worklets pattern)', () => { + // reanimated/worklets ship headers at `apple/reanimated/...` and + // `Common/cpp/reanimated/...` with per-subspec header_mappings_dir, and + // include them as ``. SPM has no header_mappings_dir copy + // step, so the parent of each mappings dir must be on the search path. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'apple', 'reanimated'), {recursive: true}); + fs.mkdirSync(path.join(root, 'Common', 'cpp', 'reanimated'), { + recursive: true, + }); + const model = podspec({ + headerMappingsDirs: ['Common/cpp/reanimated', 'apple/reanimated'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated', root}), + ); + expect(spec.headerSearchPaths).toContain('apple'); + expect(spec.headerSearchPaths).toContain('Common/cpp'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('skips a header_mappings_dir whose parent dir does not exist on disk', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + const model = podspec({headerMappingsDirs: ['nope/reanimated']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.headerSearchPaths).not.toContain('nope'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('wires a pod-style dependency (RNWorklets) to its npm sibling via the podToNpm index', () => { + const model = podspec({dependencies: ['RNWorklets', 'React-jsi']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + new Map([ + ['RNWorklets', 'react-native-worklets'], + ['RNReanimated', 'react-native-reanimated'], + ]), + ); + // RNWorklets → sibling; React-jsi → collapses into ReactNative core. + expect(spec.siblingNames).toContain('react-native-worklets'); + expect(spec.coreReactNative).toBe(true); + }); + + it('does not self-wire when a pod dependency maps back to the dep itself', () => { + const model = podspec({dependencies: ['RNReanimated']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + new Map([['RNReanimated', 'react-native-reanimated']]), + ); + expect(spec.siblingNames).not.toContain('react-native-reanimated'); + }); + + it('derives publicHeadersPath from header_mappings_dir, preferring the cross-platform (Common) namespace root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wk-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'Common', 'cpp', 'worklets'), { + recursive: true, + }); + fs.mkdirSync(path.join(root, 'apple', 'worklets'), {recursive: true}); + const model = podspec({ + headerMappingsDirs: ['Common/cpp/worklets', 'apple/worklets'], + publicHeaderFiles: ['Common/cpp/worklets/**/*.h'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-worklets', root}), + ); + // Common/cpp (parent of Common/cpp/worklets) is what dependents need to + // resolve ; the apple/ root is not preferred. + expect(spec.publicHeadersPath).toBe('Common/cpp'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('header-map emulation: adds every header-containing subdir to the search path (flat-include libs like svg)', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hmap-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'apple', 'Elements'), {recursive: true}); + fs.mkdirSync(path.join(root, 'apple', 'Text'), {recursive: true}); + fs.writeFileSync(path.join(root, 'apple', 'Elements', 'A.h'), ''); + fs.writeFileSync(path.join(root, 'apple', 'Text', 'B.h'), ''); + fs.writeFileSync(path.join(root, 'apple', 'C.mm'), ''); + const model = podspec({ + sourceFiles: ['apple/Elements/A.h', 'apple/Text/B.h', 'apple/C.mm'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-svg', root}), + ); + expect(spec.headerSearchPaths).toContain('apple/Elements'); + expect(spec.headerSearchPaths).toContain('apple/Text'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('expands a recursive `/**` HEADER_SEARCH_PATH into the base dir + all subdirs (skia shape)', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rec-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'cpp', 'skia', 'include', 'core'), { + recursive: true, + }); + const model = podspec({ + headerSearchPaths: ['$(PODS_TARGET_SRCROOT)/cpp//**'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-skia', root}), + ); + expect(spec.headerSearchPaths).toContain('cpp'); // base + expect(spec.headerSearchPaths).toContain('cpp/skia'); // makes resolve + expect(spec.headerSearchPaths).toContain('cpp/skia/include/core'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('flags needsObjCPrefix (and adds "." to the search path) when the target has ObjC(++) sources', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'objc-scaffold-')); + try { + fs.writeFileSync(path.join(root, 'A.mm'), ''); + const model = podspec({sourceFiles: ['A.mm', 'B.cpp']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.needsObjCPrefix).toBe(true); + expect(spec.headerSearchPaths).toContain('.'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does not flag needsObjCPrefix for a C++-only target', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cpp-scaffold-')); + try { + fs.writeFileSync(path.join(root, 'A.cpp'), ''); + const model = podspec({sourceFiles: ['A.cpp']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.needsObjCPrefix).toBe(false); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does not add "." for a single-segment header_mappings_dir', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rea-scaffold-')); + try { + fs.mkdirSync(path.join(root, 'ios'), {recursive: true}); + const model = podspec({headerMappingsDirs: ['ios']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.headerSearchPaths).not.toContain('.'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('still uses toSwiftName(npm-name) even when header_dir is a plain identifier (matches autolinker registration)', () => { + const model = podspec({headerDir: 'reanimated'}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-reanimated'}), + ); + expect(spec.swiftName).toBe('ReactNativeReanimated'); + }); + + it('falls back cleanly when header_dir is absent', () => { + const model = podspec({headerDir: null}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo-bar'}), + ); + expect(spec.swiftName).toBe('ReactNativeFooBar'); + }); + + it('substitutes $(PODS_TARGET_SRCROOT) in HEADER_SEARCH_PATHS with the target-relative form', () => { + const model = podspec({ + headerSearchPaths: ['$(PODS_TARGET_SRCROOT)/common/cpp'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.headerSearchPaths).toEqual(['common/cpp']); + }); + + it('drops HEADER_SEARCH_PATHS entries with unresolved Xcode tokens and warns', () => { + const model = podspec({ + headerSearchPaths: [ + '$(PODS_TARGET_SRCROOT)/ok', + '$(SOMETHING_UNKNOWN)/foo', + ], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.headerSearchPaths).toEqual(['ok']); + expect(spec.warnings.some(w => /SOMETHING_UNKNOWN/.test(w))).toBe(true); + }); + + it('buckets React-Core / React-jsi / RCT-Folly / glog into the single ReactNative product', () => { + const model = podspec({ + dependencies: ['React-Core', 'React-jsi', 'RCT-Folly', 'glog'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(true); + expect(spec.siblingNames).toEqual([]); + }); + + it('routes sibling RN deps (react-native-*) into siblingNames', () => { + const model = podspec({ + dependencies: ['React-Core', 'react-native-worklets'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(true); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + }); + + it('treats a package.json codegenConfig as an implicit React-core dep (New-Arch libs strip install_modules_dependencies — svg shape)', () => { + // svg declares its React-core dep only via install_modules_dependencies(s), + // which we strip — so model.dependencies has NO React-Core. The codegenConfig + // marker is what tells us it still needs the React-GeneratedCode package. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegen-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'react-native-svg', + codegenConfig: {name: 'rnsvg'}, + }), + ); + const model = podspec({dependencies: []}); // nothing explicit + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-svg', root}), + ); + expect(spec.coreReactNative).toBe(true); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('treats install_modules_dependencies (no codegenConfig) as an implicit React-core dep (rn-tester TestLibrary shape)', () => { + // A plain ObjC module (rn-tester's TestLibraryApple/Common) wires React + // core ONLY via install_modules_dependencies(s) and has NO codegenConfig. + // The stripped helper leaves model.dependencies without React-Core, so the + // usesInstallModulesDependencies marker is what keeps coreReactNative true. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'imd-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({name: 'TestLibraryApple'}), // no codegenConfig + ); + const model = podspec({ + dependencies: [], + usesInstallModulesDependencies: true, + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'TestLibraryApple', root}), + ); + expect(spec.coreReactNative).toBe(true); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('does NOT force coreReactNative for a non-codegen dep with no React deps', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'no-codegen-dep-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({name: 'react-native-foo'}), // no codegenConfig + ); + const model = podspec({dependencies: []}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-foo', root}), + ); + expect(spec.coreReactNative).toBe(false); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + it('warns + drops unknown non-RN dependencies (MMKV, AFNetworking)', () => { + const model = podspec({dependencies: ['MMKV', 'AFNetworking']}); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.coreReactNative).toBe(false); + expect(spec.siblingNames).toEqual([]); + expect(spec.warnings.some(w => /MMKV/.test(w))).toBe(true); + expect(spec.warnings.some(w => /AFNetworking/.test(w))).toBe(true); + }); + + it('silently drops cross-subspec refs like "react-native-foo/common" from the same podspec', () => { + // CocoaPods uses this for one subspec depending on another from the + // SAME spec — after flattenSubspecs merges everything into one SPM + // target the ref is meaningless. Must not be treated as a sibling. + const model = podspec({ + name: 'react-native-safe-area-context', + dependencies: ['React-Core', 'react-native-safe-area-context/common'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({name: 'react-native-safe-area-context'}), + ); + expect(spec.siblingNames).toEqual([]); + expect(spec.coreReactNative).toBe(true); + }); + + it('strips subspec suffix from sibling RN deps ("react-native-worklets/foo" → "react-native-worklets")', () => { + const model = podspec({ + dependencies: ['react-native-worklets/foo', 'react-native-worklets/bar'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.siblingNames).toEqual(['react-native-worklets']); + }); + + it('passes through frameworks, weak frameworks, compiler flags, resources', () => { + const model = podspec({ + frameworks: ['UIKit', 'CoreMotion'], + weakFrameworks: ['SafariServices'], + compilerFlags: ['-Wno-documentation'], + resources: ['Foo.png'], + }); + const spec = translatePodspecToSpmTarget(model, autolinkedDep()); + expect(spec.extraFrameworks).toEqual(['UIKit', 'CoreMotion']); + expect(spec.weakFrameworks).toEqual(['SafariServices']); + expect(spec.compilerFlags).toEqual(['-Wno-documentation']); + expect(spec.resources).toEqual(['Foo.png']); + }); + + it('expands podspec source globs into explicit file paths against the dep root, and infers publicHeadersPath', () => { + // Fake a dep on disk so glob expansion can find real files. + const depDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-translate-')); + try { + fs.mkdirSync(path.join(depDir, 'ios', 'Sub'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'Foo.h'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Foo.mm'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Sub', 'Bar.h'), ''); + const model = podspec({sourceFiles: ['ios/**/*.{h,m,mm}']}); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({root: depDir}), + ); + // SPM rejects globs — these must be explicit relative paths now. + expect(spec.sources).toEqual( + expect.arrayContaining(['ios/Foo.h', 'ios/Foo.mm', 'ios/Sub/Bar.h']), + ); + // publicHeadersPath is inferred from the first existing prefix dir + // (so SPM's "publicHeadersPath defaults to non-existent include/" + // error doesn't fire). + expect(spec.publicHeadersPath).toBe('ios'); + } finally { + fs.rmSync(depDir, {recursive: true, force: true}); + } + }); + + it('filters out files matching exclude_files globs after expansion', () => { + const depDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-translate-')); + try { + fs.mkdirSync(path.join(depDir, 'ios', 'Fabric'), {recursive: true}); + fs.writeFileSync(path.join(depDir, 'ios', 'KeepMe.h'), ''); + fs.writeFileSync(path.join(depDir, 'ios', 'Fabric', 'SkipMe.h'), ''); + const model = podspec({ + sourceFiles: ['ios/**/*.h'], + excludeFiles: ['ios/Fabric/**'], + }); + const spec = translatePodspecToSpmTarget( + model, + autolinkedDep({root: depDir}), + ); + expect(spec.sources).toContain('ios/KeepMe.h'); + expect(spec.sources).not.toContain('ios/Fabric/SkipMe.h'); + } finally { + fs.rmSync(depDir, {recursive: true, force: true}); + } + }); +}); + +// --------------------------------------------------------------------------- +// emitScaffoldedPackageSwift — pure: SpmScaffoldSpec → Swift string. +// Snapshot-style "contains" assertions on the key emitted lines. +// --------------------------------------------------------------------------- + +describe('emitScaffoldedPackageSwift', () => { + function baseSpec(overrides = {}) { + return { + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + preprocessorDefines: [], + needsObjCPrefix: false, + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + ...overrides, + }; + } + + it('contains the SCAFFOLDER marker (after the line-1 swift-tools-version directive) and NOT the autolinker AUTOGEN marker', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + // Line 1 is reserved for the swift-tools-version directive — SPM ignores + // it elsewhere. The scaffolder marker lives on a subsequent line. + expect(out.split('\n', 1)[0]).toMatch(/^\/\/ swift-tools-version: /); + expect(out).toContain(SCAFFOLDER_MARKER); + // The autolinker's marker — must be absent so isSelfManagedPackage + // treats this file as self-managed. + expect(out).not.toContain( + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js', + ); + }); + + it('includes a cache-slot label comment when provided (bumps SPM manifest hash on slot change)', () => { + const out = emitScaffoldedPackageSwift(baseSpec(), { + cacheSlotLabel: '0.87.0-nightly-20260513-abc/debug', + }); + expect(out).toContain('// Cache slot: 0.87.0-nightly-20260513-abc/debug'); + }); + + it('emits DEBUG/NDEBUG config-gated cxxSettings so Fabric C++ matches the prebuilt React.framework ABI', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + expect(out).toContain('.define("DEBUG", .when(configuration: .debug))'); + expect(out).toContain('.define("NDEBUG", .when(configuration: .release))'); + }); + + it('is fully declarative — no runtime discovery code, no Foundation import', () => { + const out = emitScaffoldedPackageSwift(baseSpec()); + expect(out).not.toContain('import Foundation'); + expect(out).not.toContain('#filePath'); + expect(out).not.toContain('FileManager'); + }); + + it('emits a header-search-path directive per podspec entry (.headerSearchPath("common/cpp"))', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({headerSearchPaths: ['common/cpp']}), + ); + expect(out).toContain('.headerSearchPath("common/cpp")'); + }); + + it('declares the ReactNative package + product via scaffold-time relative paths when coreReactNative is true', () => { + const out = emitScaffoldedPackageSwift(baseSpec({coreReactNative: true}), { + cacheSlotLabel: null, + remote: null, + codegenPackageDir: '../../ios/build/generated/ios', + localXcfwPackageDir: '../../ios/build/xcframeworks', + }); + expect(out).toContain( + '.package(name: "ReactNative", path: "../../ios/build/xcframeworks")', + ); + expect(out).toContain( + '.package(name: "React-GeneratedCode", path: "../../ios/build/generated/ios")', + ); + expect(out).toContain( + '.product(name: "ReactHeaders", package: "ReactNative")', + ); + }); + + it('throws when coreReactNative is set but no codegenPackageDir was provided', () => { + expect(() => + emitScaffoldedPackageSwift(baseSpec({coreReactNative: true})), + ).toThrow(/codegenPackageDir is required/); + }); + + it('remote mode: declares .package(url:exact:) and needs no local xcframeworks path', () => { + const out = emitScaffoldedPackageSwift(baseSpec({coreReactNative: true}), { + cacheSlotLabel: null, + remote: { + url: 'https://github.com/facebook/react-native-apple', + version: '0.87.0', + identity: 'react-native-apple', + }, + codegenPackageDir: '../../ios/build/generated/ios', + localXcfwPackageDir: null, + }); + expect(out).toContain( + '.package(url: "https://github.com/facebook/react-native-apple", exact: "0.87.0")', + ); + expect(out).toContain( + '.product(name: "ReactHeaders", package: "react-native-apple")', + ); + expect(out).not.toContain('build/xcframeworks'); + }); + + it('emits sibling .package(path: "../") + .product entries for sibling RN deps', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({siblingNames: ['react-native-worklets']}), + ); + // Path uses the libs/ symlink name (where the autolinker places + // the sibling), NOT the npm name — `../react-native-worklets` would be + // `libs/react-native-worklets`, which does not exist. + expect(out).toContain( + '.package(name: "ReactNativeWorklets", path: "../ReactNativeWorklets")', + ); + expect(out).toContain( + '.product(name: "ReactNativeWorklets", package: "ReactNativeWorklets")', + ); + }); + + it('-includes the ObjC prefix header in c/cxx settings when needsObjCPrefix is set', () => { + const withPrefix = emitScaffoldedPackageSwift( + baseSpec({needsObjCPrefix: true}), + ); + expect( + ( + withPrefix.match( + /\.unsafeFlags\(\["-include", "react-native-spm-prefix\.h"\]\)/g, + ) ?? [] + ).length, + ).toBe(2); // cSettings + cxxSettings + // Not emitted for a C/C++-only target. + const noPrefix = emitScaffoldedPackageSwift( + baseSpec({needsObjCPrefix: false}), + ); + expect(noPrefix).not.toContain('-include'); + }); + + it('emits preprocessor defines as .define(...) in c/cxx settings, escaping quoted values and honoring config', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({ + preprocessorDefines: [ + {name: 'WORKLETS_VERSION', value: '0.9.2', config: null}, + { + name: 'WORKLETS_FEATURE_FLAGS', + value: '"[A:false][B:true]"', + config: null, + }, + {name: 'HERMES_ENABLE_DEBUGGER', value: '1', config: 'debug'}, + {name: 'NDEBUG', value: null, config: 'release'}, + ], + }), + ); + expect(out).toContain('.define("WORKLETS_VERSION", to: "0.9.2")'); + // Embedded quotes escaped for the Swift string literal. + expect(out).toContain( + '.define("WORKLETS_FEATURE_FLAGS", to: "\\"[A:false][B:true]\\"")', + ); + expect(out).toContain( + '.define("HERMES_ENABLE_DEBUGGER", to: "1", .when(configuration: .debug))', + ); + // Valueless define + release config. + expect(out).toContain('.define("NDEBUG", .when(configuration: .release))'); + }); + + it('emits sources: array when podspec declared globs', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({sources: ['ios/**/*.{h,m,mm}', 'common/cpp/**/*.{cpp,h}']}), + ); + expect(out).toContain('sources: ['); + expect(out).toContain('"ios/**/*.{h,m,mm}"'); + expect(out).toContain('"common/cpp/**/*.{cpp,h}"'); + }); + + it('omits sources: line when no globs (SPM auto-scans target dir)', () => { + const out = emitScaffoldedPackageSwift(baseSpec({sources: []})); + expect(out).not.toContain('sources: ['); + }); + + it('emits publicHeadersPath when header_mappings_dir set', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({publicHeadersPath: 'common/cpp/foo'}), + ); + expect(out).toContain('publicHeadersPath: "common/cpp/foo"'); + }); + + it('dedups linker frameworks (default + extras = no UIKit twice)', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({extraFrameworks: ['UIKit', 'CoreMotion']}), + ); + const uikitCount = (out.match(/\.linkedFramework\("UIKit"\)/g) || []) + .length; + expect(uikitCount).toBe(1); + expect(out).toContain('.linkedFramework("CoreMotion")'); + }); + + it('embeds podspec compiler_flags into cxxSettings unsafeFlags', () => { + const out = emitScaffoldedPackageSwift( + baseSpec({compilerFlags: ['-Wno-documentation']}), + ); + expect(out).toContain('"-Wno-documentation"'); + }); +}); + +// --------------------------------------------------------------------------- +// scaffoldPackageSwiftForDep — orchestrator with I/O. Tested via temp dirs; +// covers each skip rule + the happy path. +// --------------------------------------------------------------------------- + +describe('scaffoldPackageSwiftForDep', () => { + let appRoot; + let depRoot; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-app-')); + depRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-dep-')); + }); + + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(depRoot, {recursive: true, force: true}); + }); + + function makePodspec() { + // Minimal valid podspec — exercises the regex-parser fallback path. + const podspecPath = path.join(depRoot, 'react-native-foo.podspec'); + fs.writeFileSync( + podspecPath, + ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm}" + s.dependency "React-Core" +end +`, + ); + return podspecPath; + } + + function makeCtx(overrides = {}) { + return { + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + force: false, + dryRun: false, + cacheSlotLabel: null, + ...overrides, + }; + } + + function makeDep(overrides = {}) { + return { + name: 'react-native-foo', + root: depRoot, + platforms: {ios: {}}, + ...overrides, + }; + } + + it('writes Package.swift into the dep root on the happy path', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(true); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + // Line 1 is the swift-tools-version directive; the scaffolder marker + // appears immediately after (still detectable by `isScaffolded` checks + // that scan the whole file). + expect(content.split('\n', 1)[0]).toMatch(/^\/\/ swift-tools-version: /); + expect(content).toContain(SCAFFOLDER_MARKER); + }); + + it('skips (does not write) a mixed-language dep — Swift + ObjC(++) cannot share one SPM target', () => { + // react-native-screens shape: a single source glob mixing .swift and .mm. + const podspecPath = path.join(depRoot, 'react-native-foo.podspec'); + fs.writeFileSync( + podspecPath, + ` +Pod::Spec.new do |s| + s.name = "react-native-foo" + s.version = "1.0" + s.source_files = "ios/**/*.{h,m,mm,swift}" + s.dependency "React-Core" +end +`, + ); + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.swift'), ''); + fs.writeFileSync(path.join(depRoot, 'ios', 'Foo.mm'), ''); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-mixed-language'); + // Fail-closed: no half-baked manifest left behind. + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + }); + + it('computes app paths relative to the libs/ symlink, not dep.root (fresh-resolve correctness)', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const content = fs.readFileSync( + path.join(depRoot, 'Package.swift'), + 'utf8', + ); + // swiftName = ReactNativeFoo; the autolinker references the dep via + // build/generated/autolinking/libs/ReactNativeFoo. SwiftPM resolves the + // manifest's relative paths against THAT location, so: + // build/generated/ios -> ../../../ios + // build/xcframeworks -> ../../../../xcframeworks + expect(content).toContain( + '.package(name: "React-GeneratedCode", path: "../../../ios")', + ); + expect(content).toContain( + '.package(name: "ReactNative", path: "../../../../xcframeworks")', + ); + // The old dep.root-relative form (doubled to …/autolinking/ios/build/... + // through the symlink) must NOT be emitted. + expect(content).not.toContain('../../ios/build/generated/ios'); + }); + + it('reports previouslyExisted=false for first-time scaffolds (so the CLI can prompt)', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + expect(result.previouslyExisted).toBe(false); + }); + + it('reports previouslyExisted=true when regenerating an existing scaffolder-marker file (slot change)', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// Cache slot: OLD\n`, + ); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'NEW'}), + ); + expect(result.status).toBe('written'); + expect(result.previouslyExisted).toBe(true); + }); + + it('skips with skipped-no-ios when autolinking.json has no ios platform', () => { + const result = scaffoldPackageSwiftForDep( + makeDep({platforms: {ios: null}}), + makeCtx(), + ); + expect(result.status).toBe('skipped-no-ios'); + }); + + it('skips with skipped-no-podspec when no .podspec exists in dep root', () => { + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-no-podspec'); + }); + + it('refuses to touch a Package.swift that lacks the scaffolder marker (user/upstream-managed)', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// Hand-written. Do not touch.', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-self-managed'); + // File unchanged + expect(fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8')).toBe( + '// Hand-written. Do not touch.', + ); + }); + + it('refuses to scaffold when a nested ios/Package.swift exists without markers', () => { + makePodspec(); + // Library ships its manifest under ios/ to keep the npm-package root + // free of SPM artifacts. The scaffolder should NOT write a stray root + // Package.swift — that would shadow the nested one (the autolinker + // checks the root first). + fs.mkdirSync(path.join(depRoot, 'ios'), {recursive: true}); + const nestedContent = + '// swift-tools-version: 6.0\n// Hand-written nested manifest.\n'; + fs.writeFileSync(path.join(depRoot, 'ios', 'Package.swift'), nestedContent); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-self-managed'); + // Root stayed clean + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + // Nested file untouched + expect( + fs.readFileSync(path.join(depRoot, 'ios', 'Package.swift'), 'utf8'), + ).toBe(nestedContent); + }); + + it('refuses to overwrite a Package.swift carrying the autolinker AUTOGEN_MARKER', () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit.\n', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-autogen'); + }); + + it('skips re-scaffolding when the existing file carries the scaffolder marker AND the same cache slot', () => { + makePodspec(); + // Pre-existing scaffold from same slot AND current generator version + // — otherwise the version-bump skip-bypass kicks in. + const prior = + SCAFFOLDER_MARKER + + `\n// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}` + + '\n// Cache slot: 0.87.0-X/debug\n// rest unchanged'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: '0.87.0-X/debug'}), + ); + expect(result.status).toBe('skipped-scaffolder-marker'); + expect(fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8')).toBe( + prior, + ); + }); + + it('REGENERATES when the existing scaffolder file is from a different cache slot (manifest hash bump)', () => { + makePodspec(); + const prior = + SCAFFOLDER_MARKER + '\n// Cache slot: OLD-slot/debug\n// rest'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'NEW-slot/debug'}), + ); + expect(result.status).toBe('written'); + expect( + fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'), + ).toContain('// Cache slot: NEW-slot/debug'); + }); + + it('--force re-overwrites a scaffolder-marker file even when the slot is unchanged', () => { + makePodspec(); + const prior = + SCAFFOLDER_MARKER + + '\n// Cache slot: SLOT-A/debug\n// hand edits here will be lost'; + fs.writeFileSync(path.join(depRoot, 'Package.swift'), prior); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({cacheSlotLabel: 'SLOT-A/debug', force: true}), + ); + expect(result.status).toBe('written'); + expect( + fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'), + ).not.toContain('hand edits here will be lost'); + }); + + it('--dry-run produces a ScaffoldResult but writes nothing', () => { + makePodspec(); + const result = scaffoldPackageSwiftForDep( + makeDep(), + makeCtx({dryRun: true}), + ); + expect(result.status).toBe('written'); + expect(fs.existsSync(path.join(depRoot, 'Package.swift'))).toBe(false); + }); + + it("honors a dep's spm: { scaffold: false } opt-out in its react-native.config.js", () => { + makePodspec(); + fs.writeFileSync( + path.join(depRoot, 'react-native.config.js'), + 'module.exports = { spm: { scaffold: false } };', + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-opt-out'); + }); + + it('returns skipped-is-react-native for `react-native` itself (handled by the xcframework path)', () => { + const result = scaffoldPackageSwiftForDep( + makeDep({name: 'react-native'}), + makeCtx(), + ); + expect(result.status).toBe('skipped-is-react-native'); + }); +}); + +// --------------------------------------------------------------------------- +// scaffoldAll — minimal smoke test. The orchestrator delegates everything +// to scaffoldPackageSwiftForDep (already covered above); here we just +// verify it reads autolinking.json and produces one result per dep. +// --------------------------------------------------------------------------- + +describe('scaffoldAll', () => { + let appRoot; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-all-')); + }); + + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + it('returns [] and logs when autolinking.json is absent', () => { + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results).toEqual([]); + }); + + it('walks dependencies in autolinking.json and produces one result per entry', () => { + const autolinkingDir = path.join(appRoot, 'build/generated/autolinking'); + fs.mkdirSync(autolinkingDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkingDir, 'autolinking.json'), + JSON.stringify({ + dependencies: { + 'react-native-a': {root: '/no/such/a', platforms: {ios: {}}}, + 'react-native-b': {root: '/no/such/b', platforms: {ios: null}}, + }, + }), + ); + const results = scaffoldAll({ + appRoot, + projectRoot: appRoot, + reactNativeRoot: appRoot, + }); + expect(results.length).toBe(2); + expect(results.find(r => r.depName === 'react-native-b').status).toBe( + 'skipped-no-ios', + ); + // react-native-a's root doesn't exist → skipped-no-podspec + expect(results.find(r => r.depName === 'react-native-a').status).toBe( + 'skipped-no-podspec', + ); + }); +}); + +// --------------------------------------------------------------------------- +// SCAFFOLDER_VERSION — auto-regen when the emitter's output format changes +// +// Without versioning, a Package.swift scaffolded by an older generator stays +// on disk indefinitely (skip-on-marker), even when our template has since +// been fixed. Bumping SCAFFOLDER_VERSION triggers a one-time regeneration +// on next scaffold. Edits are persisted via patch-package per the marker +// comment, so destructive regen here aligns with the documented workflow. +// --------------------------------------------------------------------------- + +describe('SCAFFOLDER_VERSION', () => { + it('is a positive integer', () => { + expect(Number.isInteger(SCAFFOLDER_VERSION)).toBe(true); + expect(SCAFFOLDER_VERSION).toBeGreaterThanOrEqual(1); + }); + + it('emitter writes the current version to the file', () => { + const out = emitScaffoldedPackageSwift({ + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + preprocessorDefines: [], + needsObjCPrefix: false, + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + }); + expect(out).toMatch( + new RegExp(`^// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}$`, 'm'), + ); + }); +}); + +describe('scaffoldPackageSwiftForDep — version-based regen', () => { + let tempDir; + let depRoot; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-scaffold-version-')); + depRoot = path.join(tempDir, 'node_modules', 'react-native-foo'); + fs.mkdirSync(depRoot, {recursive: true}); + fs.writeFileSync( + path.join(depRoot, 'package.json'), + JSON.stringify({name: 'react-native-foo', version: '1.0.0'}), + ); + 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}'\nend\n", + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function makeDep() { + return { + name: 'react-native-foo', + root: depRoot, + platforms: {ios: {}}, + }; + } + + function makeCtx(overrides = {}) { + return { + appRoot: tempDir, + reactNativeRoot: depRoot, + force: false, + dryRun: false, + cacheSlotLabel: 'SLOT-A/debug', + skipDeps: new Set(), + ...overrides, + }; + } + + it('regenerates a file scaffolded under an older version, even without --force', () => { + const olderVersion = Math.max(1, SCAFFOLDER_VERSION - 1); + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// AUTO-SCAFFOLDED-VERSION: ${olderVersion}\n// Cache slot: SLOT-A/debug\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const after = fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'); + expect(after).toContain( + `// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}`, + ); + }); + + it('regenerates a marker-tagged file with NO version line (treats as v1)', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// Cache slot: SLOT-A/debug\n// pre-versioning scaffold\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('written'); + const after = fs.readFileSync(path.join(depRoot, 'Package.swift'), 'utf8'); + expect(after).not.toContain('pre-versioning scaffold'); + expect(after).toContain( + `// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}`, + ); + }); + + it('skips when the existing file is already at the current version and slot', () => { + fs.writeFileSync( + path.join(depRoot, 'Package.swift'), + `${SCAFFOLDER_MARKER}\n// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}\n// Cache slot: SLOT-A/debug\n`, + ); + const result = scaffoldPackageSwiftForDep(makeDep(), makeCtx()); + expect(result.status).toBe('skipped-scaffolder-marker'); + }); +}); 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 new file mode 100644 index 000000000000..abdaaf823364 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -0,0 +1,292 @@ +/** + * 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 { + detectStandardRnLayoutRedirect, + ensureBothArtifactFlavors, + findInjectedXcodeproj, + resolveAction, + shouldAutoDeintegrate, +} = require('../../setup-apple-spm'); +const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); +const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const os = require('os'); +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) { + 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: []}), + ); + return dir; +} + +// Create a (CocoaPods or plain) xcodeproj fixture with a minimal pbxproj. +function mkXcodeproj(appRoot, name, {cocoapods = false} = {}) { + const dir = path.join(appRoot, name); + fs.mkdirSync(dir, {recursive: true}); + const baseConfig = cocoapods + ? 'baseConfigurationReference = ABC /* Pods-MyApp.debug.xcconfig */;\n' + : ''; + fs.writeFileSync( + path.join(dir, 'project.pbxproj'), + `// !$*UTF8*$!\n{\n\tobjects = {\n${baseConfig}\t};\n}\n`, + ); + return dir; +} + +function gitInitAndCommit(dir) { + const opts = {cwd: dir, stdio: 'ignore'}; + execFileSync('git', ['init'], opts); + execFileSync('git', ['config', 'user.email', 'test@example.com'], opts); + execFileSync('git', ['config', 'user.name', 'Test'], opts); + execFileSync('git', ['add', '-A'], opts); + execFileSync('git', ['commit', '-m', 'init'], opts); +} + +// --------------------------------------------------------------------------- +// resolveAction — zero-arg default. Explicit action wins; otherwise `update` +// when an injection marker exists, else `add` (first run). +// --------------------------------------------------------------------------- + +describe('resolveAction', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-resolve-action-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the requested action verbatim when one is given', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction('add', tempDir)).toBe('add'); + expect(resolveAction('update', tempDir)).toBe('update'); + expect(resolveAction('deinit', tempDir)).toBe('deinit'); + expect(resolveAction('scaffold', tempDir)).toBe('scaffold'); + }); + + it('defaults to `add` on first run (no injection marker)', () => { + expect(resolveAction(null, tempDir)).toBe('add'); + }); + + it('defaults to `add` even when a (non-injected) xcodeproj exists', () => { + mkXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction(null, tempDir)).toBe('add'); + }); + + it('defaults to `update` once an injection marker is present', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(resolveAction(null, tempDir)).toBe('update'); + }); +}); + +// --------------------------------------------------------------------------- +// findInjectedXcodeproj — locates the `.xcodeproj` carrying the injection marker +// --------------------------------------------------------------------------- + +describe('findInjectedXcodeproj', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-find-injected-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the injected project path when a marker is present', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(findInjectedXcodeproj(tempDir)).toBe( + path.join(tempDir, 'MyApp.xcodeproj'), + ); + }); + + it('returns null when no injected project exists', () => { + mkXcodeproj(tempDir, 'MyApp.xcodeproj'); + expect(findInjectedXcodeproj(tempDir)).toBeNull(); + }); +}); + +describe('dual-flavor artifact input', () => { + let root; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-dual-artifacts-')); + }); + + afterEach(() => fs.rmSync(root, {recursive: true, force: true})); + + function writeCompleteSlot(flavor) { + const slot = path.join(root, flavor); + fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { + recursive: true, + }); + const artifacts = {}; + for (const name of REQUIRED_ARTIFACTS) { + const xcframeworkPath = path.join(slot, `${name}.xcframework`); + fs.mkdirSync(xcframeworkPath, {recursive: true}); + artifacts[name] = {xcframeworkPath}; + } + fs.writeFileSync( + path.join(slot, 'artifacts.json'), + JSON.stringify(artifacts), + ); + return slot; + } + + function args() { + return { + version: null, + artifacts: root, + downloadPolicy: 'skip', + }; + } + + it('requires and returns complete Debug and Release slots', async () => { + const debug = writeCompleteSlot('debug'); + const release = writeCompleteSlot('release'); + await expect(ensureBothArtifactFlavors(args(), '0.85.0')).resolves.toEqual({ + debug, + release, + }); + }); + + it('fails if either flavor is incomplete', async () => { + writeCompleteSlot('debug'); + await expect(ensureBothArtifactFlavors(args(), '0.85.0')).rejects.toThrow( + /complete release slot/, + ); + }); + + it('rejects a single XCFramework as local artifact input', async () => { + const single = path.join(root, 'React.xcframework'); + fs.mkdirSync(single); + await expect( + ensureBothArtifactFlavors({...args(), artifacts: single}, '0.85.0'), + ).rejects.toThrow(/single XCFramework cannot satisfy automatic switching/); + }); +}); + +// --------------------------------------------------------------------------- +// detectStandardRnLayoutRedirect — auto-redirect into ios/ when run from the JS +// root of a standard RN app. +// --------------------------------------------------------------------------- + +describe('detectStandardRnLayoutRedirect', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-redirect-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns the ios/ subdir when cwd === projectRoot AND ios/ exists', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBe( + path.join(tempDir, 'ios'), + ); + }); + + it('returns null when running from a subdirectory (already cd-ed)', () => { + fs.mkdirSync(path.join(tempDir, 'ios')); + expect( + detectStandardRnLayoutRedirect(path.join(tempDir, 'ios'), tempDir), + ).toBeNull(); + }); + + it('returns null for flat layouts (no ios/ subdir, e.g. rn-tester)', () => { + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBeNull(); + }); + + it('returns null when `ios` is a file, not a directory', () => { + fs.writeFileSync(path.join(tempDir, 'ios'), ''); + expect(detectStandardRnLayoutRedirect(tempDir, tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// shouldAutoDeintegrate — the zero-arg safe-gate. Auto-convert ONLY a fresh +// CocoaPods RN project: CocoaPods pbxproj + stock Podfile (no third-party pods) +// + clean git tree. Anything else → false (strict `add`, fail-loud). +// --------------------------------------------------------------------------- + +describe('shouldAutoDeintegrate', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-safegate-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('false when the project is not CocoaPods-integrated', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: false, + }); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + it('false when there is no target project at all', () => { + expect(shouldAutoDeintegrate(tempDir, null)).toBe(false); + }); + + it('false for a CocoaPods project whose Podfile has third-party pods', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n use_react_native!\n pod 'MBProgressHUD'\nend\n", + ); + gitInitAndCommit(tempDir); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + it('false when the pbxproj has uncommitted edits (not revertible)', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n use_react_native!\nend\n", + ); + gitInitAndCommit(tempDir); + // Dirty the pbxproj itself after the commit → conversion not revertible. + fs.appendFileSync( + path.join(xcodeproj, 'project.pbxproj'), + '\n// local edit\n', + ); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + it('true despite an unrelated dirty file when pbxproj + Podfile are clean', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n config = use_native_modules!\n use_react_native!(:path => config[:reactNativePath])\nend\n", + ); + gitInitAndCommit(tempDir); + // A dirty lockfile / untracked file elsewhere must NOT block — the + // conversion only touches the pbxproj + Podfile, which stay clean. + fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}'); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js new file mode 100644 index 000000000000..ee5ee1e1c881 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-pbxproj-test.js @@ -0,0 +1,516 @@ +/** + * 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 { + addArrayMembers, + addArrayStringValues, + ensureScalarField, + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, + generateUUID, + insertObjectsIntoSection, + namespacedUUID, + quoteIfNeeded, + removeArrayMembersByUuid, + removeArrayStringValues, + removeDanglingJavaScriptCoreRef, + removeEmptyPodsGroup, + removeField, + removeObjectByUuid, + scanToClose, + serializeEntry, + setScalarField, + uuidsInArray, +} = require('../spm-pbxproj'); +const fs = require('fs'); +const path = require('path'); + +const PLAIN_PBXPROJ = fs.readFileSync( + path.join(__dirname, '__fixtures__', 'plain-app.pbxproj'), + 'utf8', +); + +// --------------------------------------------------------------------------- +// generateUUID +// --------------------------------------------------------------------------- + +describe('generateUUID', () => { + it('produces a 24-character uppercase hex string', () => { + const result = generateUUID('test-seed'); + expect(result).toMatch(/^[0-9A-F]{24}$/); + }); + + it('is deterministic', () => { + expect(generateUUID('same')).toBe(generateUUID('same')); + }); + + it('produces different results for different seeds', () => { + expect(generateUUID('seed-a')).not.toBe(generateUUID('seed-b')); + }); +}); + +// --------------------------------------------------------------------------- +// quoteIfNeeded +// --------------------------------------------------------------------------- + +describe('quoteIfNeeded', () => { + it.each([ + ['foo.bar/baz', 'foo.bar/baz'], + ['foo bar', '"foo bar"'], + ['a\\b', '"a\\\\b"'], + ['a"b', '"a\\"b"'], + ['', '""'], + ])('quoteIfNeeded(%j) => %j', (input, expected) => { + expect(quoteIfNeeded(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Surgical-edit toolkit (in-place injection primitives) +// --------------------------------------------------------------------------- + +describe('namespacedUUID', () => { + it('is deterministic and 24-hex', () => { + const a = namespacedUUID('ROOT', 'sec', 'id'); + expect(a).toMatch(/^[0-9A-F]{24}$/); + expect(namespacedUUID('ROOT', 'sec', 'id')).toBe(a); + }); + + it('differs by root, section, id, and salt', () => { + const base = namespacedUUID('ROOT', 'sec', 'id'); + expect(namespacedUUID('OTHER', 'sec', 'id')).not.toBe(base); + expect(namespacedUUID('ROOT', 'other', 'id')).not.toBe(base); + expect(namespacedUUID('ROOT', 'sec', 'other')).not.toBe(base); + expect(namespacedUUID('ROOT', 'sec', 'id', '2')).not.toBe(base); + }); +}); + +describe('scanToClose', () => { + it('matches braces and parens, skipping quoted delimiters', () => { + const t = 'x = { a = ("a)b"); };'; + const open = t.indexOf('{'); + expect(t[scanToClose(t, open)]).toBe('}'); + const paren = t.indexOf('('); + // The ")" inside the quoted string must not close the paren early. + expect(scanToClose(t, paren)).toBe(t.indexOf(');') + 0); + }); +}); + +describe('findObjectByUuid / findField', () => { + it('locates an object body and reads scalar + array fields', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + expect(target.name).toBe('MyApp'); + const obj = findObjectByUuid(PLAIN_PBXPROJ, target.uuid); + expect(obj).not.toBeNull(); + const productType = findField(PLAIN_PBXPROJ, obj, 'productType'); + expect(productType.value).toContain('application'); + const buildPhases = findField(PLAIN_PBXPROJ, obj, 'buildPhases'); + expect(uuidsInArray(buildPhases.value).size).toBe(3); + }); + + it('returns null for an absent field', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + expect(findField(PLAIN_PBXPROJ, project, 'packageReferences')).toBeNull(); + }); +}); + +describe('addArrayMembers', () => { + it('creates an absent array field after the body open', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = addArrayMembers(PLAIN_PBXPROJ, project, 'packageReferences', [ + {uuid: 'CAFE0000000000000000CAFE', comment: 'ref'}, + ]); + expect(out).toMatch(/packageReferences = \(/); + expect(out).toContain('CAFE0000000000000000CAFE /* ref */'); + }); + + it('appends to and dedupes an existing array', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + const member = [{uuid: 'AA0000000000000000000301'}]; // already in buildPhases + const out = addArrayMembers(PLAIN_PBXPROJ, target, 'buildPhases', member); + // Dedup: no second occurrence added. + expect(out.match(/AA0000000000000000000301/g)).toHaveLength( + PLAIN_PBXPROJ.match(/AA0000000000000000000301/g).length, + ); + }); + + it('prepends when requested', () => { + const target = findApplicationTargets(PLAIN_PBXPROJ)[0]; + const out = addArrayMembers( + PLAIN_PBXPROJ, + target, + 'buildPhases', + [{uuid: 'BEEF0000000000000000BEEF', comment: 'First'}], + {prepend: true}, + ); + const firstIdx = out.indexOf('BEEF0000000000000000BEEF'); + const sourcesIdx = out.indexOf('AA0000000000000000000301 /* Sources */'); + expect(firstIdx).toBeLessThan(sourcesIdx); + }); +}); + +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, + targetDebugDict(PLAIN_PBXPROJ), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect(out).toMatch(/OTHER_LDFLAGS = \(/); + expect(out).toContain('"$(inherited)"'); + expect(out).toContain('"-ObjC"'); + }); + + it('promotes an existing scalar to an array, preserving the old value', () => { + const scalar = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = "-lz"; PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + scalar, + targetDebugDict(scalar), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect(out).toMatch(/OTHER_LDFLAGS = \(/); + expect(out).toContain('"-lz"'); + expect(out).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)";', + 'OTHER_LDFLAGS = ("-ObjCFoo", ); PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + withArray, + targetDebugDict(withArray), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + // A substring check would have seen "-ObjC" inside "-ObjCFoo" and skipped it. + expect(out).toContain('"-ObjC"'); + expect(out).toContain('"-ObjCFoo"'); + }); + + it('does not re-add an exact existing member', () => { + const withArray = PLAIN_PBXPROJ.replace( + 'PRODUCT_NAME = "$(TARGET_NAME)";', + 'OTHER_LDFLAGS = ("-ObjC", ); PRODUCT_NAME = "$(TARGET_NAME)";', + ); + const out = addArrayStringValues( + withArray, + targetDebugDict(withArray), + 'OTHER_LDFLAGS', + ['"-ObjC"'], + ); + expect((out.match(/"-ObjC"/g) || []).length).toBe(1); + }); +}); + +describe('ensureScalarField', () => { + it('adds a scalar only when absent', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + expect(out).toContain('ORGANIZATIONNAME = Acme;'); + // Re-running is a no-op. + const project2 = findProjectObject(out); + expect(ensureScalarField(out, project2, 'ORGANIZATIONNAME', 'Other')).toBe( + out, + ); + }); +}); + +describe('setScalarField', () => { + it('replaces an existing value in place, preserving field order', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const withOrg = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + const before = withOrg.indexOf('ORGANIZATIONNAME'); + const project2 = findProjectObject(withOrg); + const out = setScalarField(withOrg, project2, 'ORGANIZATIONNAME', 'Other'); + expect(out).toContain('ORGANIZATIONNAME = Other;'); + expect(out).not.toContain('ORGANIZATIONNAME = Acme;'); + // Field stayed at the same position — no remove+re-append shuffling. + expect(out.indexOf('ORGANIZATIONNAME')).toBe(before); + }); + + it('is byte-identical when the value is unchanged', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const withOrg = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + const project2 = findProjectObject(withOrg); + const out = setScalarField(withOrg, project2, 'ORGANIZATIONNAME', 'Acme'); + expect(out).toBe(withOrg); + }); + + it('appends the field when absent, like ensureScalarField', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const out = setScalarField( + PLAIN_PBXPROJ, + project, + 'ORGANIZATIONNAME', + 'Acme', + ); + expect(out).toContain('ORGANIZATIONNAME = Acme;'); + expect( + ensureScalarField(PLAIN_PBXPROJ, project, 'ORGANIZATIONNAME', 'Acme'), + ).toBe(out); + }); + + it('preserves a quoted value fully, including its quotes', () => { + const project = findProjectObject(PLAIN_PBXPROJ); + const withScript = ensureScalarField( + PLAIN_PBXPROJ, + project, + 'SOME_SCRIPT', + '"echo hi"', + ); + const project2 = findProjectObject(withScript); + const out = setScalarField( + withScript, + project2, + 'SOME_SCRIPT', + '"echo bye"', + ); + expect(out).toContain('SOME_SCRIPT = "echo bye";'); + expect(out).not.toContain('echo hi'); + }); +}); + +describe('insertObjectsIntoSection', () => { + it('creates a new section before the objects dict closes', () => { + const entry = serializeEntry({ + uuid: 'DEAD0000000000000000DEAD', + comment: 'XCLocalSwiftPackageReference "x"', + fields: {isa: 'XCLocalSwiftPackageReference', relativePath: 'x'}, + }); + const out = insertObjectsIntoSection( + PLAIN_PBXPROJ, + 'XCLocalSwiftPackageReference', + entry, + ); + expect(out).toContain('/* Begin XCLocalSwiftPackageReference section */'); + expect(out).toContain('DEAD0000000000000000DEAD'); + // Still inside the objects dict (before rootObject). + expect(out.indexOf('DEAD0000000000000000DEAD')).toBeLessThan( + out.indexOf('rootObject ='), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Surgical removal — inverses used by `deinit` +// --------------------------------------------------------------------------- + +describe('surgical removal (deinit inverse)', () => { + const FAKE = 'DEADBEEF0000000000001234'; + + it('removeObjectByUuid exactly inverts insertObjectsIntoSection', () => { + const inserted = insertObjectsIntoSection( + PLAIN_PBXPROJ, + 'PBXBuildFile', + serializeEntry({ + uuid: FAKE, + comment: 'Fake', + fields: {isa: 'PBXBuildFile'}, + }), + ); + expect(inserted).not.toBe(PLAIN_PBXPROJ); + expect(removeObjectByUuid(inserted, FAKE)).toBe(PLAIN_PBXPROJ); + }); + + it('removeObjectByUuid is a no-op when the uuid is absent', () => { + expect(removeObjectByUuid(PLAIN_PBXPROJ, FAKE)).toBe(PLAIN_PBXPROJ); + }); + + it('removeArrayMembersByUuid inverts addArrayMembers on an existing array', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const added = addArrayMembers(PLAIN_PBXPROJ, target, 'buildPhases', [ + {uuid: FAKE, comment: 'Fake'}, + ]); + expect(added).not.toBe(PLAIN_PBXPROJ); + expect(removeArrayMembersByUuid(added, [FAKE])).toBe(PLAIN_PBXPROJ); + }); + + it('removeField inverts ensureScalarField', () => { + const [target] = findApplicationTargets(PLAIN_PBXPROJ); + const added = ensureScalarField( + PLAIN_PBXPROJ, + target, + 'SPM_TEST_FLAG', + '"yes"', + ); + expect(added).not.toBe(PLAIN_PBXPROJ); + const [target2] = findApplicationTargets(added); + expect(removeField(added, target2, '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', [ + '"-A"', + ]); + const [t2] = findApplicationTargets(seeded); + const appended = addArrayStringValues(seeded, t2, 'SPM_TEST_ARR', ['"-B"']); + const [t3] = findApplicationTargets(appended); + // Removing the appended "-B" returns to the seeded (single-value) state. + expect( + removeArrayStringValues(appended, t3, 'SPM_TEST_ARR', ['"-B"']), + ).toBe(seeded); + }); +}); + +// --------------------------------------------------------------------------- +// removeEmptyPodsGroup — clean up the leftover empty `Pods` group after +// `pod deintegrate` (which `add --deintegrate` runs). +// --------------------------------------------------------------------------- + +describe('removeEmptyPodsGroup', () => { + // A main group referencing an empty `Pods` group (what pod deintegrate leaves). + const WITH_EMPTY_PODS = [ + '// !$*UTF8*$!', + '{', + '\tobjects = {', + '/* Begin PBXGroup section */', + '\t\tAA0000000000000000000001 = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t\t13B07FAE1A68108700A75B9A /* App */,', + '\t\t\t\tBBD78D7AC51CEA395F1C20DB /* Pods */,', + '\t\t\t);', + '\t\t\tsourceTree = "";', + '\t\t};', + '\t\tBBD78D7AC51CEA395F1C20DB /* Pods */ = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t);', + '\t\t\tpath = Pods;', + '\t\t\tsourceTree = "";', + '\t\t};', + '/* End PBXGroup section */', + '\t};', + '}', + '', + ].join('\n'); + + it('removes the empty Pods group object and its parent reference', () => { + const out = removeEmptyPodsGroup(WITH_EMPTY_PODS); + expect(out).not.toContain('/* Pods */'); + expect(out).not.toContain('path = Pods;'); + // The unrelated child survives. + expect(out).toContain('13B07FAE1A68108700A75B9A /* App */,'); + }); + + it('leaves a NON-empty Pods group untouched (still integrated)', () => { + const nonEmpty = WITH_EMPTY_PODS.replace( + 'children = (\n\t\t\t);\n\t\t\tpath = Pods;', + 'children = (\n\t\t\t\tDEADBEEF0000000000000001 /* libPods.a */,\n\t\t\t);\n\t\t\tpath = Pods;', + ); + expect(removeEmptyPodsGroup(nonEmpty)).toBe(nonEmpty); + }); + + it('is a no-op when there is no Pods group', () => { + const noPods = WITH_EMPTY_PODS.split('\n') + .filter(l => !l.includes('Pods')) + .join('\n'); + expect(removeEmptyPodsGroup(noPods)).toBe(noPods); + }); +}); + +// --------------------------------------------------------------------------- +// removeDanglingJavaScriptCoreRef — strip the community template's leftover +// `JavaScriptCore.framework` PBXFileReference (navigator-only since RN 0.60, +// meaningless under Hermes) unless it's actually linked. +// --------------------------------------------------------------------------- + +describe('removeDanglingJavaScriptCoreRef', () => { + const JSC_FILE_REF = + '\t\tED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };'; + + // A Frameworks group referencing the dangling JavaScriptCore.framework ref. + const WITH_DANGLING_JSC = [ + '// !$*UTF8*$!', + '{', + '\tobjects = {', + '/* Begin PBXFileReference section */', + JSC_FILE_REF, + '/* End PBXFileReference section */', + '/* Begin PBXGroup section */', + '\t\t13B07FAF1A68108700A75B9A /* Frameworks */ = {', + '\t\t\tisa = PBXGroup;', + '\t\t\tchildren = (', + '\t\t\t\tED297162215061F000B7C4FE /* JavaScriptCore.framework */,', + '\t\t\t);', + '\t\t\tname = Frameworks;', + '\t\t\tsourceTree = "";', + '\t\t};', + '/* End PBXGroup section */', + '\t};', + '}', + '', + ].join('\n'); + + it('removes both the file reference object and its group membership when dangling', () => { + const out = removeDanglingJavaScriptCoreRef(WITH_DANGLING_JSC); + expect(out).not.toContain('JavaScriptCore.framework'); + expect(out).not.toContain('ED297162215061F000B7C4FE'); + // The parent group survives, now childless. + expect(out).toContain('13B07FAF1A68108700A75B9A /* Frameworks */ = {'); + }); + + it('leaves a still-linked reference untouched (a PBXBuildFile references it)', () => { + const linked = WITH_DANGLING_JSC.replace( + '/* Begin PBXFileReference section */', + '/* Begin PBXBuildFile section */\n' + + '\t\tED2971642150620600B7C4FE /* JavaScriptCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED297162215061F000B7C4FE /* JavaScriptCore.framework */; };\n' + + '/* End PBXBuildFile section */\n' + + '/* Begin PBXFileReference section */', + ); + expect(removeDanglingJavaScriptCoreRef(linked)).toBe(linked); + }); + + it('is a no-op when there is no JavaScriptCore reference', () => { + const noJsc = WITH_DANGLING_JSC.split('\n') + .filter(l => !l.includes('JavaScriptCore')) + .join('\n'); + expect(removeDanglingJavaScriptCoreRef(noJsc)).toBe(noJsc); + }); + + it('is idempotent', () => { + const once = removeDanglingJavaScriptCoreRef(WITH_DANGLING_JSC); + const twice = removeDanglingJavaScriptCoreRef(once); + expect(twice).toBe(once); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js new file mode 100644 index 000000000000..87e116d32d51 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -0,0 +1,621 @@ +/** + * 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 { + RemoteVersionError, + buildPerAppHeaderTree, + defaultCacheDir, + displayPath, + isPublishableVersion, + makeLogger, + readPackageJson, + remotePackageConfig, + resolveInstalledRnVersion, + resolveReactNativeRoot, + runCodegenAndInstallTemplate, + sharedCacheDir, + toSwiftName, +} = require('../spm-utils'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// toSwiftName +// --------------------------------------------------------------------------- + +describe('toSwiftName', () => { + it.each([ + ['@react-native/tester', 'Tester'], + ['my-app', 'MyApp'], + ['@scope/foo-bar', 'FooBar'], + ['simple', 'Simple'], + ['a--b', 'AB'], + ['my_great_app', 'MyGreatApp'], + ])('toSwiftName(%j) => %j', (input, expected) => { + expect(toSwiftName(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// defaultCacheDir +// --------------------------------------------------------------------------- + +describe('sharedCacheDir', () => { + it('matches CocoaPods shared_cache_dir (~/Library/Caches/ReactNative)', () => { + expect(sharedCacheDir()).toBe( + path.join(os.homedir(), 'Library', 'Caches', 'ReactNative'), + ); + }); +}); + +describe('defaultCacheDir', () => { + it('nests SPM artifacts under the canonical ReactNative cache root', () => { + const result = defaultCacheDir('0.80.0', 'debug'); + expect(result).toBe( + path.join( + os.homedir(), + 'Library', + 'Caches', + 'ReactNative', + 'spm-artifacts', + '0.80.0', + 'debug', + ), + ); + // No bundle-id-named dir that other tools might also use. + expect(result).not.toContain('com.facebook.ReactNative'); + }); + + it('varies by flavor', () => { + const debug = defaultCacheDir('1.0.0', 'debug'); + const release = defaultCacheDir('1.0.0', 'release'); + expect(debug).not.toBe(release); + expect(debug).toContain('debug'); + expect(release).toContain('release'); + }); +}); + +// --------------------------------------------------------------------------- +// displayPath +// --------------------------------------------------------------------------- + +describe('displayPath', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('replaces homedir with ~', () => { + const home = os.homedir(); + expect(displayPath(path.join(home, 'projects', 'app'))).toBe( + '~/projects/app', + ); + }); + + it('returns ~ for exact homedir', () => { + expect(displayPath(os.homedir())).toBe('~'); + }); + + it('returns relative path when close to cwd and not under $HOME', () => { + const cwd = path.join(os.tmpdir(), 'spm-display-cwd'); + jest.spyOn(process, 'cwd').mockReturnValue(cwd); + jest.spyOn(os, 'homedir').mockReturnValue('/home/spm-display-user'); + expect(displayPath(path.join(cwd, 'sub', 'dir'))).toBe( + path.join('sub', 'dir'), + ); + }); + + it('returns absolute path for deep relative', () => { + // Paths more than 2 levels above cwd should stay absolute + // (unless they fall under $HOME) + const home = os.homedir(); + const p = path.join(home, 'deep', 'nested', 'path'); + // This is under $HOME, so it should use ~/ + expect(displayPath(p)).toBe('~/deep/nested/path'); + }); +}); + +// --------------------------------------------------------------------------- +// makeLogger +// --------------------------------------------------------------------------- + +describe('makeLogger', () => { + let spies; + + afterEach(() => { + if (spies) { + spies.forEach(s => s.mockRestore()); + spies = null; + } + }); + + function mockConsole(...methods) { + spies = methods.map(m => + jest.spyOn(console, m).mockImplementation(() => {}), + ); + return spies; + } + + it('log writes to stdout with green prefix', () => { + const [spy] = mockConsole('log'); + const {log} = makeLogger('test'); + log('hello'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[test]')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello')); + }); + + it('warn writes to stderr with yellow prefix', () => { + const [spy] = mockConsole('warn'); + const {warn} = makeLogger('test'); + warn('caution'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[test]')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('caution')); + }); + + it('die throws, sets exitCode, writes to stderr', () => { + const [spy] = mockConsole('error'); + const origExitCode = process.exitCode; + try { + const {die} = makeLogger('test'); + expect(() => die('fatal')).toThrow('fatal'); + expect(process.exitCode).toBe(1); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('fatal')); + } finally { + process.exitCode = origExitCode; + } + }); +}); + +// --------------------------------------------------------------------------- +// readPackageJson +// --------------------------------------------------------------------------- + +describe('readPackageJson', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('returns parsed JSON for valid file', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({name: 'test-pkg', version: '1.0.0'}), + ); + const result = readPackageJson(tempDir); + expect(result).toEqual({name: 'test-pkg', version: '1.0.0'}); + }); + + it('returns null for missing file', () => { + expect(readPackageJson(tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveReactNativeRoot +// --------------------------------------------------------------------------- + +describe('resolveReactNativeRoot', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('finds react-native hoisted above the app package root', () => { + const workspaceRoot = path.join(tempDir, 'workspace'); + const appRoot = path.join(workspaceRoot, 'packages', 'app', 'ios'); + const rnRoot = path.join(workspaceRoot, 'node_modules', 'react-native'); + fs.mkdirSync(appRoot, {recursive: true}); + fs.mkdirSync(rnRoot, {recursive: true}); + + expect( + resolveReactNativeRoot( + appRoot, + path.join(workspaceRoot, 'packages', 'app'), + ), + ).toBe(rnRoot); + }); +}); + +// --------------------------------------------------------------------------- +// isPublishableVersion +// --------------------------------------------------------------------------- + +describe('isPublishableVersion', () => { + it.each([ + ['0.86.3', true], + ['0.87.0-nightly-20260608-2ff3b81dc', true], + ['1.2.3', true], + ['1000.0.0', false], + ['0.0.0', false], + ['0.0.0-canary', false], + [null, false], + ['', false], + ])('isPublishableVersion(%j) => %j', (input, expected) => { + expect(isPublishableVersion(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// resolveInstalledRnVersion +// --------------------------------------------------------------------------- + +describe('resolveInstalledRnVersion', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function writeRn(dir /*: string */, version /*: string */) { + const rnDir = path.join(dir, 'node_modules', 'react-native'); + fs.mkdirSync(rnDir, {recursive: true}); + fs.writeFileSync( + path.join(rnDir, 'package.json'), + JSON.stringify({name: 'react-native', version}), + ); + } + + it('reads the version from appRoot/node_modules/react-native', () => { + writeRn(tempDir, '0.86.3'); + expect(resolveInstalledRnVersion(tempDir)).toBe('0.86.3'); + }); + + it('walks up to find a hoisted react-native', () => { + const appRoot = path.join(tempDir, 'packages', 'app', 'ios'); + fs.mkdirSync(appRoot, {recursive: true}); + writeRn(tempDir, '0.87.0'); + expect(resolveInstalledRnVersion(appRoot)).toBe('0.87.0'); + }); + + it('returns null when react-native is not installed', () => { + expect(resolveInstalledRnVersion(tempDir)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// remotePackageConfig +// --------------------------------------------------------------------------- + +describe('remotePackageConfig', () => { + const REMOTE_CONFIG_REL = 'build/generated/autolinking/spm-remote.json'; + let tempDir; + let savedEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-utils-test-')); + savedEnv = { + url: process.env.RN_SPM_REMOTE_URL, + version: process.env.RN_SPM_REMOTE_VERSION, + }; + delete process.env.RN_SPM_REMOTE_URL; + delete process.env.RN_SPM_REMOTE_VERSION; + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + if (savedEnv.url == null) { + delete process.env.RN_SPM_REMOTE_URL; + } else { + process.env.RN_SPM_REMOTE_URL = savedEnv.url; + } + if (savedEnv.version == null) { + delete process.env.RN_SPM_REMOTE_VERSION; + } else { + process.env.RN_SPM_REMOTE_VERSION = savedEnv.version; + } + }); + + function writeRn(version /*: string */) { + const rnDir = path.join(tempDir, 'node_modules', 'react-native'); + fs.mkdirSync(rnDir, {recursive: true}); + fs.writeFileSync( + path.join(rnDir, 'package.json'), + JSON.stringify({name: 'react-native', version}), + ); + } + + function writePersisted(obj /*: Object */) { + const cfgPath = path.join(tempDir, REMOTE_CONFIG_REL); + fs.mkdirSync(path.dirname(cfgPath), {recursive: true}); + fs.writeFileSync(cfgPath, JSON.stringify(obj)); + } + + function readPersisted() /*: Object */ { + return JSON.parse( + fs.readFileSync(path.join(tempDir, REMOTE_CONFIG_REL), 'utf8'), + ); + } + + it('returns null in local mode (no URL anywhere)', () => { + expect(remotePackageConfig(tempDir)).toBeNull(); + }); + + it('env override: activates remote mode and persists versionOverride', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + process.env.RN_SPM_REMOTE_VERSION = '0.86.0'; + writeRn('0.86.3'); // present but ignored — override wins + + const result = remotePackageConfig(tempDir); + expect(result).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.0', + identity: 'react-native-apple', + }); + expect(readPersisted()).toEqual({ + url: 'file:///tmp/react-native-apple', + versionOverride: '0.86.0', + }); + }); + + it('env URL only: derives version from npm and persists NO version', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + writeRn('0.86.3'); + + const result = remotePackageConfig(tempDir); + expect(result).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.3', + identity: 'react-native-apple', + }); + // Derived version is never frozen. + expect(readPersisted()).toEqual({url: 'file:///tmp/react-native-apple'}); + }); + + it('throws RemoteVersionError for a non-publishable derived version', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + writeRn('1000.0.0'); + expect(() => remotePackageConfig(tempDir)).toThrow(RemoteVersionError); + }); + + it('throws RemoteVersionError when react-native is not installed', () => { + process.env.RN_SPM_REMOTE_URL = 'file:///tmp/react-native-apple'; + expect(() => remotePackageConfig(tempDir)).toThrow(RemoteVersionError); + }); + + it('honors a persisted versionOverride with no env', () => { + writePersisted({ + url: 'file:///tmp/react-native-apple', + versionOverride: '0.86.0', + }); + writeRn('1000.0.0'); // dev placeholder — override still wins, no throw + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.0', + identity: 'react-native-apple', + }); + }); + + it('reads a legacy persisted {url, version} as an override', () => { + writePersisted({ + url: 'file:///tmp/react-native-apple', + version: '0.85.1', + }); + writeRn('1000.0.0'); + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.85.1', + identity: 'react-native-apple', + }); + }); + + it('persisted URL only: derives from npm without an env (the sync lever)', () => { + writePersisted({url: 'file:///tmp/react-native-apple'}); + writeRn('0.86.3'); + + expect(remotePackageConfig(tempDir)).toEqual({ + url: 'file:///tmp/react-native-apple', + version: '0.86.3', + identity: 'react-native-apple', + }); + // No env → no re-write of the persisted file. + expect(readPersisted()).toEqual({url: 'file:///tmp/react-native-apple'}); + }); +}); + +// --------------------------------------------------------------------------- +// runCodegenAndInstallTemplate +// --------------------------------------------------------------------------- + +describe('runCodegenAndInstallTemplate', () => { + let tempDir; + let reactNativeRoot; + let appRoot; + let codegenPkgSwift; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-codegen-test-')); + reactNativeRoot = path.join(tempDir, 'react-native'); + appRoot = path.join(tempDir, 'app'); + + // Minimal fake codegen script (no-op) so the execSync call exits cleanly. + fs.mkdirSync(path.join(reactNativeRoot, 'scripts'), {recursive: true}); + fs.writeFileSync( + path.join(reactNativeRoot, 'scripts', 'generate-codegen-artifacts.js'), + '// no-op codegen for tests\n', + ); + // Codegen template that installSpmCodegenTemplate renders + writes. + fs.mkdirSync( + path.join(reactNativeRoot, 'scripts', 'codegen', 'templates'), + { + recursive: true, + }, + ); + fs.writeFileSync( + path.join( + reactNativeRoot, + 'scripts', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ), + '// template\n', + ); + // build/generated/ios must exist for the template to be installed. + fs.mkdirSync(path.join(appRoot, 'build', 'generated', 'ios'), { + recursive: true, + }); + codegenPkgSwift = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'Package.swift', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('installs the codegen template by default', () => { + runCodegenAndInstallTemplate(appRoot, appRoot, reactNativeRoot); + expect(fs.existsSync(codegenPkgSwift)).toBe(true); + }); + + it('skips the template install when installTemplate is false', () => { + runCodegenAndInstallTemplate(appRoot, appRoot, reactNativeRoot, undefined, { + installTemplate: false, + }); + // The SPM sync re-points the xcframework symlinks and installs the template + // itself afterwards, so this in-codegen install must be suppressed. + expect(fs.existsSync(codegenPkgSwift)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// buildMergedHeaderTree +// --------------------------------------------------------------------------- +describe('per-app header farm (ReactAppHeaders SPM target)', () => { + let tempDir; + let appRoot; + let perAppDir; + + function writeFile(p, contents) { + fs.mkdirSync(path.dirname(p), {recursive: true}); + fs.writeFileSync(p, contents); + } + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-farm-test-')); + appRoot = path.join(tempDir, 'app'); + perAppDir = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'ReactAppHeaders', + ); + // Autolinking header farm — a SYMLINK farm (leaf headers are symlinks to + // the dep's real source). foldDir must follow symlinks, not skip them. + const realProviderHeader = path.join(tempDir, 'src', 'Provider.h'); + writeFile(realProviderHeader, '#pragma once\n// provider\n'); + const farmHeader = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + 'headers', + 'MyLib', + 'Provider.h', + ); + fs.mkdirSync(path.dirname(farmHeader), {recursive: true}); + fs.symlinkSync(realProviderHeader, farmHeader); + // Codegen output (folded both at generated/ios root and ReactCodegen/). + writeFile( + path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'ReactCodegen', + 'react', + 'renderer', + 'EventEmitters.h', + ), + '#pragma once\n// codegen\n', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + it('folds codegen + autolinking headers and lives inside the codegen package', () => { + const result = buildPerAppHeaderTree(appRoot); + expect(result.path).toBe(perAppDir); + expect(fs.existsSync(path.join(perAppDir, 'MyLib', 'Provider.h'))).toBe( + true, + ); + expect( + fs.existsSync( + path.join(perAppDir, 'react', 'renderer', 'EventEmitters.h'), + ), + ).toBe(true); + // include form resolves via the farm root. + expect( + fs.existsSync( + path.join( + perAppDir, + 'ReactCodegen', + 'react', + 'renderer', + 'EventEmitters.h', + ), + ), + ).toBe(true); + }); + + it('carries the SPM stub source so the farm is a valid target', () => { + buildPerAppHeaderTree(appRoot); + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeadersStub.c'))).toBe( + true, + ); + }); + + it('rebuilds cleanly on re-run without folding its previous self', () => { + buildPerAppHeaderTree(appRoot); + const second = buildPerAppHeaderTree(appRoot); + // No nested ReactAppHeaders/ReactAppHeaders self-fold artifacts. + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeaders'))).toBe(false); + // ...and no ReactAppHeaders.tmp/ either: the temp build dir must NOT live + // under a folded root (build/generated/ios), else foldDir walks the + // half-built farm and creates a spurious `ReactAppHeaders.tmp/` namespace + // duplicating every codegen header. + expect(fs.existsSync(path.join(perAppDir, 'ReactAppHeaders.tmp'))).toBe( + false, + ); + expect( + [...second.virtualPaths].some(p => p.includes('ReactAppHeaders.tmp')), + ).toBe(false); + // The out-of-tree temp is consumed (renamed into place), not left behind. + expect( + fs.existsSync(path.join(appRoot, 'build', '.react-app-headers.tmp')), + ).toBe(false); + expect(second.virtualPaths.has('MyLib/Provider.h')).toBe(true); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js b/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js new file mode 100644 index 000000000000..ed31a5115298 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/swift-tools-version-test.js @@ -0,0 +1,97 @@ +/** + * 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'; + +// --------------------------------------------------------------------------- +// SPM requires `// swift-tools-version: X.Y` on the FIRST LINE of Package.swift. +// When the directive isn't on line 1, SPM silently treats the manifest as +// tools-version 3.1.0 and recent Xcode rejects it outright: +// +// error: package 'package.swift' is using Swift tools version 3.1.0 which +// is no longer supported; consider using '// swift-tools-version: 6.3' +// +// This regression test pins every Package.swift our generators emit to that +// rule, and asserts the same for the static codegen template. +// --------------------------------------------------------------------------- + +const { + generateAutolinkedPackageSwift, + generateSynthPackageSwift, +} = require('../generate-spm-autolinking'); +const {generateXCFrameworksPackageSwift} = require('../generate-spm-package'); +const {emitScaffoldedPackageSwift} = require('../scaffold-package-swift'); +const fs = require('fs'); +const path = require('path'); + +const TOOLS_VERSION_RE = /^\/\/ swift-tools-version: \d+\.\d+/; + +function firstLineOf(s) { + return s.split('\n', 1)[0]; +} + +describe('swift-tools-version directive must be on line 1', () => { + it('generateXCFrameworksPackageSwift (xcframeworks sub-package)', () => { + const out = generateXCFrameworksPackageSwift( + ['React', 'ReactNativeDependencies', 'hermes-engine'], + '/tmp/cache/0.85.3/debug', + ); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('generateAutolinkedPackageSwift (autolinker aggregator)', () => { + const out = generateAutolinkedPackageSwift({}); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('generateSynthPackageSwift (per-dep synth wrapper)', () => { + const out = generateSynthPackageSwift({ + swiftName: 'MyDep', + exclude: [], + publicHeadersPath: '.', + spmDependencies: [], + hasReactDep: false, + hasXcfwHeaders: false, + hasDepsHeaders: false, + codegenHeadersIncluded: false, + }); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('emitScaffoldedPackageSwift (community-lib scaffold)', () => { + const out = emitScaffoldedPackageSwift({ + swiftName: 'foo', + sources: [], + headerSearchPaths: [], + coreReactNative: false, + siblingNames: [], + extraFrameworks: [], + weakFrameworks: [], + compilerFlags: [], + publicHeadersPath: null, + resources: [], + warnings: [], + }); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); + + it('codegen Package.swift.spm-template (static file)', () => { + const templatePath = path.resolve( + __dirname, + '..', + '..', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ); + const out = fs.readFileSync(templatePath, 'utf8'); + expect(firstLineOf(out)).toMatch(TOOLS_VERSION_RE); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js new file mode 100644 index 000000000000..0591735c5e49 --- /dev/null +++ b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js @@ -0,0 +1,118 @@ +/** + * 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 {main} = require('../sync-spm-autolinking'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('sync-spm-autolinking main', () => { + let appRoot; + let rnRoot; + let logSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-app-')); + rnRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-sync-rn-')); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + fs.rmSync(appRoot, {recursive: true, force: true}); + fs.rmSync(rnRoot, {recursive: true, force: true}); + }); + + function makeDeps(overrides = {}) { + return { + runCodegenAndInstallTemplate: jest.fn(), + generateAutolinking: jest.fn(), + installSpmCodegenTemplate: jest.fn(), + buildPerAppHeaderTree: jest.fn(), + findProjectRoot: jest.fn(() => appRoot), + // Obsolete collaborators are deliberately supplied to prove sync does + // not download artifacts or regenerate the runtime package graph. + downloadArtifacts: jest.fn(), + generatePackage: jest.fn(), + ...overrides, + }; + } + + function run(deps) { + return main(['--app-root', appRoot, '--react-native-root', rnRoot], deps); + } + + function stampPath() { + return path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-sync-stamp', + ); + } + + it('regenerates only invariant autolinking output and writes a stamp', async () => { + const deps = makeDeps(); + await run(deps); + + expect(deps.runCodegenAndInstallTemplate).toHaveBeenCalledWith( + appRoot, + appRoot, + rnRoot, + expect.any(Object), + {installTemplate: false}, + ); + expect(deps.installSpmCodegenTemplate).toHaveBeenCalledWith( + appRoot, + rnRoot, + expect.any(Object), + ); + expect(deps.generateAutolinking).toHaveBeenCalledWith([ + '--app-root', + appRoot, + '--react-native-root', + rnRoot, + ]); + expect(deps.buildPerAppHeaderTree).toHaveBeenCalledWith( + appRoot, + expect.any(Object), + ); + expect(deps.downloadArtifacts).not.toHaveBeenCalled(); + expect(deps.generatePackage).not.toHaveBeenCalled(); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('continues with existing output when codegen fails', async () => { + const deps = makeDeps({ + runCodegenAndInstallTemplate: jest.fn(() => { + throw new Error('codegen failed'); + }), + }); + await expect(run(deps)).resolves.toBeUndefined(); + expect(deps.generateAutolinking).toHaveBeenCalledTimes(1); + expect(fs.existsSync(stampPath())).toBe(true); + }); + + it('installs the codegen template before fail-closed plugin execution', async () => { + const deps = makeDeps({ + generateAutolinking: jest.fn(() => { + throw new Error('plugin failed'); + }), + }); + await expect(run(deps)).rejects.toThrow(/plugin failed/); + expect( + deps.installSpmCodegenTemplate.mock.invocationCallOrder[0], + ).toBeLessThan(deps.generateAutolinking.mock.invocationCallOrder[0]); + expect(fs.existsSync(stampPath())).toBe(false); + }); +}); diff --git a/packages/react-native/scripts/spm/autolinking-plugins.js b/packages/react-native/scripts/spm/autolinking-plugins.js new file mode 100644 index 000000000000..aa001e4a1cb1 --- /dev/null +++ b/packages/react-native/scripts/spm/autolinking-plugins.js @@ -0,0 +1,331 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +/** + * SwiftPM autolinking plugins — the extension seam for frameworks (Expo first) + * that need to contribute their own SwiftPM package refs / product deps / + * generated sources into the autolinked package graph. + * + * PREVIEW / UNSTABLE CONTRACT. The discovery mechanism and the plugin + * function's context/return shape may change while the first real consumers + * (Expo) validate it. + * + * Why a plugin and not a post-process: the Xcode auto-sync build phase + * re-runs autolinking on every dependency change, so a one-shot rewrite of the + * generated Package.swift is clobbered on the next sync. Plugins are invoked + * from generate-spm-autolinking.js:main() — the single function both `add`/ + * `update` and the build-time `sync` call — so a contribution runs on EVERY + * regeneration. + * + * Discovery (transitive, mirrors CocoaPods' `use_expo_modules!`): any + * autolinked dependency can register a plugin from its own + * react-native.config.js — + * + * // node_modules/expo/react-native.config.js + * module.exports = { spm: { autolinkingPlugin: './spm/plugin.js' } }; + * + * Installing the framework is enough; no app-level config is required. An app + * MAY still exclude a plugin via `spm.denyPlugins` (npm-name list) in its own + * react-native.config.js — an escape hatch, not a required allowlist. + * + * Contract: + * + * module.exports = function plugin(context) { + * // context: {appRoot, projectRoot, reactNativeRoot, autolinking, + * // outputDir, react} + * // context.react (?ReactDescriptor): how to depend on React — + * // {packageRef, products}. packageRef is local ({name, path (absolute), + * // relPath}) or remote ({name, url, version}); products is the set RN + * // gives its own autolinked targets (incl. ReactAppHeaders from the + * // separate React-GeneratedCode package), filtered to what resolves + * // this run. Use it instead of re-deriving RN's path/identity/products. + * return { + * packageDependencies: [{name, path}] | [{name, url, version}], + * productDependencies: [{name, package}], + * // Sources compiled INTO THE APP TARGET (not the static Autolinked + * // aggregate) — the only place an @objc class reaches the ObjC + * // classlist, so emit files here when a class must be reachable by + * // name (e.g. via NSClassFromString), as Expo's ExpoModulesProvider + * // needs. Recorded to `.spm-plugin-generated-sources.json`; the + * // `spm add`/`update` xcodeproj injector wires each into the app + * // target (PBXFileReference + Sources-phase entry), marker-tracked + * // so deinit reverts and update reconciles. + * generatedSources: [{path}], + * // Precompiled dynamic frameworks selected and embedded by RN outside + * // the SwiftPM graph. Both flavors are mandatory. + * flavoredFrameworks: [{id, frameworkName, linkage: 'dynamic', + * flavors: {debug, release}}], + * // Absolute paths (dirs OR files) the Xcode auto-sync build phase + * // should watch for staleness — e.g. the plugin dep's own + * // `Package.swift`, `expo-module.config.json`, and per-module + * // manifests. Folded into `.spm-sync-watch-paths`; a file edit or a + * // 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'], + * }; + * }; + * + * The plugin returns DATA; it never writes into RN's generated tree. RN owns + * the merge, so regeneration stays deterministic and idempotent. + */ + +const path = require('path'); + +/*:: import type { + AutolinkedDep, + PluginContext, + PluginResult, + DiscoveredPlugin, +} from './spm-types'; +*/ + +/** + * Discover plugins declared by autolinked deps. `readConfig(root)` returns the + * dep's parsed react-native.config.js (or null). `denyList` is the app's + * `spm.denyPlugins` (npm names to skip). Fail-closed: a declared-but-missing + * or unloadable plugin throws, naming the dep — a framework silently dropping + * its modules is worse than a loud stop. + */ +function discoverPlugins( + deps /*: ReadonlyArray */, + readConfig /*: (root: string) => ?{readonly [string]: unknown} */, + denyList /*: ReadonlyArray */ = [], +) /*: Array */ { + const denied = new Set(denyList); + const found /*: Array */ = []; + for (const dep of deps) { + if (denied.has(dep.name)) { + continue; + } + const config = readConfig(dep.root); + // $FlowFixMe[incompatible-use] config has a dynamic shape + const rel = config?.spm?.autolinkingPlugin; + if (rel == null) { + continue; + } + if (typeof rel !== 'string' || rel.length === 0) { + throw new Error( + `react-native spm: '${dep.name}' declares an invalid spm.autolinkingPlugin ` + + `(expected a module path string).`, + ); + } + const pluginPath = path.resolve(dep.root, rel); + let fn: unknown; + try { + // $FlowFixMe[unsupported-syntax] dynamic require by computed path + fn = require(pluginPath); + } catch (e) { + throw new Error( + `react-native spm: failed to load the autolinking plugin for '${dep.name}' ` + + `at ${pluginPath}: ${e.message}`, + ); + } + // Support `module.exports = fn` and `{default: fn}` / `{plugin: fn}`. + const resolved = + typeof fn === 'function' + ? fn + : // $FlowFixMe[incompatible-use] interop shapes + typeof fn?.default === 'function' + ? fn.default + : // $FlowFixMe[incompatible-use] + typeof fn?.plugin === 'function' + ? fn.plugin + : null; + if (resolved == null) { + throw new Error( + `react-native spm: the autolinking plugin for '${dep.name}' at ${pluginPath} ` + + `does not export a function.`, + ); + } + found.push({depName: dep.name, pluginPath, plugin: resolved}); + } + return found; +} + +/** + * Invoke discovered plugins and merge their results. Each plugin gets the same + * context. Fail-closed: a throwing plugin aborts (named), and a malformed + * return is rejected. Dedupe package + product deps by name so a plugin and an + * npm dep that both reference the same package don't double-declare. + */ +function invokePlugins( + plugins /*: ReadonlyArray */, + context /*: PluginContext */, + logger /*: {warn: (msg: string) => void} */ = { + warn: msg => console.warn(msg), + }, +) /*: PluginResult */ { + const packageDependencies /*: Array<{name: string, path?: string, url?: string, version?: string}> */ = + []; + const productDependencies /*: Array<{name: string, package: string}> */ = []; + const generatedSources /*: Array<{path: string}> */ = []; + const flavoredFrameworks /*: Array<{id: string, frameworkName: string, linkage: 'dynamic', flavors: {debug: string, release: string}}> */ = + []; + const watchPaths /*: Array */ = []; + const seenPackages /*: Set */ = new Set(); + const seenProducts /*: Set */ = new Set(); + const seenFrameworkIds /*: Set */ = new Set(); + const seenFrameworkNames /*: Set */ = new Set(); + + for (const {depName, pluginPath, plugin} of plugins) { + let result: unknown; + try { + result = plugin(context); + } catch (e) { + throw new Error( + `react-native spm: the autolinking plugin for '${depName}' (${pluginPath}) ` + + `threw: ${e.message}`, + ); + } + if (result == null) { + continue; + } + if (typeof result !== 'object') { + throw new Error( + `react-native spm: the autolinking plugin for '${depName}' returned a ` + + `${typeof result}; expected an object or undefined.`, + ); + } + // $FlowFixMe[incompatible-use] validated field-by-field below + const pkgs = result.packageDependencies ?? []; + // $FlowFixMe[incompatible-use] + const prods = result.productDependencies ?? []; + // $FlowFixMe[incompatible-use] + const srcs = result.generatedSources ?? []; + // $FlowFixMe[incompatible-use] + const rawFrameworks = result.flavoredFrameworks ?? []; + // $FlowFixMe[incompatible-use] + const rawWatch = result.watchPaths ?? []; + if (!Array.isArray(rawFrameworks)) { + throw new Error( + `react-native spm: '${depName}' returned a non-array flavoredFrameworks.`, + ); + } + // Watch paths are best-effort staleness hints, so + // a non-array is ignored (warn, never fatal). + if (!Array.isArray(rawWatch)) { + logger.warn( + `react-native spm: '${depName}' returned a non-array watchPaths ` + + `— ignoring it.`, + ); + } + const watch = Array.isArray(rawWatch) ? rawWatch : []; + for (const p of pkgs) { + if (p == null || typeof p.name !== 'string') { + throw new Error( + `react-native spm: '${depName}' returned a packageDependency without a name.`, + ); + } + if (p.path == null && (p.url == null || p.version == null)) { + throw new Error( + `react-native spm: '${depName}' packageDependency '${p.name}' needs either ` + + `a path or a url+version.`, + ); + } + if (!seenPackages.has(p.name)) { + seenPackages.add(p.name); + packageDependencies.push(p); + } + } + for (const p of prods) { + if ( + p == null || + typeof p.name !== 'string' || + typeof p.package !== 'string' + ) { + throw new Error( + `react-native spm: '${depName}' returned a productDependency needing name + package.`, + ); + } + const key = `${p.package}/${p.name}`; + if (!seenProducts.has(key)) { + seenProducts.add(key); + productDependencies.push(p); + } + } + for (const s of srcs) { + if (s == null || typeof s.path !== 'string') { + throw new Error( + `react-native spm: '${depName}' returned a generatedSource without a path.`, + ); + } + generatedSources.push(s); + } + for (const framework of rawFrameworks) { + if ( + framework == null || + typeof framework.id !== 'string' || + !/^[A-Za-z0-9_.-]+$/.test(framework.id) || + typeof framework.frameworkName !== 'string' || + framework.frameworkName.length === 0 || + framework.linkage !== 'dynamic' || + framework.flavors == null || + typeof framework.flavors.debug !== 'string' || + typeof framework.flavors.release !== 'string' || + !path.isAbsolute(framework.flavors.debug) || + !path.isAbsolute(framework.flavors.release) + ) { + throw new Error( + `react-native spm: '${depName}' returned an invalid flavoredFramework ` + + '(need {id, frameworkName, linkage: "dynamic", flavors: ' + + '{debug, release}} with absolute flavor paths).', + ); + } + if (seenFrameworkIds.has(framework.id)) { + throw new Error( + `react-native spm: duplicate flavored framework id '${framework.id}'.`, + ); + } + if (seenFrameworkNames.has(framework.frameworkName)) { + throw new Error( + `react-native spm: multiple plugins embed '${framework.frameworkName}.framework'.`, + ); + } + seenFrameworkIds.add(framework.id); + seenFrameworkNames.add(framework.frameworkName); + flavoredFrameworks.push({ + id: framework.id, + frameworkName: framework.frameworkName, + linkage: 'dynamic', + flavors: { + debug: framework.flavors.debug, + release: framework.flavors.release, + }, + }); + } + // watchPaths are dropped (with a warning), not fatal. A non-string, empty, + // or relative entry only + // means one staleness input is missed, not a broken build. Absolute-only so + // the generated phase (which has no cwd context) can test them directly. + for (const w of watch) { + if (typeof w !== 'string' || w.length === 0 || !path.isAbsolute(w)) { + logger.warn( + `react-native spm: '${depName}' returned an invalid watchPath ` + + `(need a non-empty absolute path string) — dropping it.`, + ); + continue; + } + watchPaths.push(w); + } + } + + return { + packageDependencies, + productDependencies, + generatedSources, + flavoredFrameworks, + watchPaths, + }; +} + +module.exports = { + discoverPlugins, + invokePlugins, +}; diff --git a/packages/react-native/scripts/spm/download-spm-artifacts.js b/packages/react-native/scripts/spm/download-spm-artifacts.js new file mode 100644 index 000000000000..4b428f354090 --- /dev/null +++ b/packages/react-native/scripts/spm/download-spm-artifacts.js @@ -0,0 +1,1409 @@ +/** + * 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'; + +/*:: import type {DownloadArgs, ResolvedArtifact, ProcessResult, ArtifactResultEntry} from './spm-types'; */ + +/** + * download-spm-artifacts.js + * + * Downloads the three React Native iOS xcframeworks from Maven, extracts + * them to a local cache directory, and writes artifacts.json for use by + * generate-spm-package.js. + * + * Supports stable releases, nightlies, and snapshot builds, matching + * the same resolution logic used by the existing CocoaPods scripts. + * + * Artifacts handled: + * React – react-native-core tarball from Maven + * ReactNativeDependencies – react-native-dependencies tarball from Maven + * hermes-engine – hermes-ios tarball from Maven + * + * Usage: + * node scripts/download-spm-artifacts.js [options] + * + * Options: + * --version RN version. Defaults to version in package.json. + * Use "nightly" to resolve the latest nightly. + * --flavor debug (default) or release. + * --output Where to write xcframeworks. + * Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/ + * (downloaded tarballs are shared with CocoaPods in + * ~/Library/Caches/ReactNative/; RCT_SKIP_CACHES=1 bypasses.) + * + * Per-artifact version overrides (mirrors existing env vars): + * HERMES_VERSION= + * RN_DEP_VERSION= + * ENTERPRISE_REPOSITORY= Custom Maven mirror (must match Maven structure) + * + * Output: + * /React.xcframework/ + * /ReactNativeDependencies.xcframework/ + * /hermes-engine.xcframework/ + * /artifacts.json ← maps target names to xcframework paths + */ + +const { + defaultCacheDir, + displayPath, + makeLogger, + sharedCacheDir, +} = require('./spm-utils'); +const {execFileSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const stream = require('stream'); +const yargs = require('yargs'); + +const {log, warn, die} = makeLogger('download-spm-artifacts'); + +function parseArgs(argv /*: Array */) /*: DownloadArgs */ { + const parsed = yargs(argv) + .version(false) + .option('version', { + alias: 'v', + type: 'string', + describe: + 'RN version. Defaults to version in package.json. Use "nightly" to resolve the latest nightly.', + }) + .option('flavor', { + type: 'string', + default: 'debug', + describe: 'debug or release', + }) + .option('output', { + alias: 'o', + type: 'string', + describe: + 'Where to write xcframeworks. Default: ~/Library/Caches/ReactNative/spm-artifacts/{version}/{flavor}/', + }) + .option('core-tarball', { + type: 'string', + describe: + 'Local React core tarball to use instead of downloading (e.g. the prebuild output). Env fallback: RN_CORE_TARBALL_PATH.', + }) + .option('headers-tarball', { + type: 'string', + describe: + 'Local ReactNativeHeaders tarball to use instead of downloading. Env fallback: RN_HEADERS_TARBALL_PATH.', + }) + .option('deps-tarball', { + type: 'string', + describe: + 'Local ReactNativeDependencies tarball to use instead of downloading (e.g. the deps prebuild output). Carries the headers sidecar beside the binary, so this subsumes --deps-headers-tarball for a local build. Env fallback: RN_DEPS_TARBALL_PATH.', + }) + .option('deps-headers-tarball', { + type: 'string', + describe: + 'Local ReactNativeDependenciesHeaders tarball. Normally unneeded — the sidecar ships inside the ReactNativeDependencies tarball. Env fallback: RN_DEPS_HEADERS_TARBALL_PATH.', + }) + .usage( + 'Usage: $0 [options]\n\nDownloads React Native iOS xcframeworks from Maven.', + ) + .help() + .parseSync(); + + return { + version: parsed.version ?? null, + flavor: parsed.flavor.toLowerCase(), + output: parsed.output ?? null, + coreTarball: + parsed['core-tarball'] ?? process.env.RN_CORE_TARBALL_PATH ?? null, + headersTarball: + parsed['headers-tarball'] ?? process.env.RN_HEADERS_TARBALL_PATH ?? null, + depsTarball: + parsed['deps-tarball'] ?? process.env.RN_DEPS_TARBALL_PATH ?? null, + depsHeadersTarball: + parsed['deps-headers-tarball'] ?? + process.env.RN_DEPS_HEADERS_TARBALL_PATH ?? + null, + }; +} + +const MAVEN_RELEASE = + process.env.ENTERPRISE_REPOSITORY ?? 'https://repo1.maven.org/maven2'; +const MAVEN_SNAPSHOT = + 'https://central.sonatype.com/repository/maven-snapshots'; + +function rnCoreReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-core-${flavor}.tar.gz` + ); +} +function rnDepsReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/react/react-native-artifacts/${version}/` + + `react-native-artifacts-${version}-reactnative-dependencies-${flavor}.tar.gz` + ); +} +function hermesReleaseUrl( + version /*: string */, + flavor /*: string */, +) /*: string */ { + return ( + `${MAVEN_RELEASE}/com/facebook/hermes/hermes-ios/${version}/` + + `hermes-ios-${version}-hermes-ios-${flavor}.tar.gz` + ); +} + +/** + * Resolves a Maven snapshot URL by fetching maven-metadata.xml and extracting + * the latest timestamp+buildNumber. Mirrors computeNightlyTarballURL() in utils.js. + * + * @param {string} version Base version without -SNAPSHOT suffix (e.g. "0.85.0") + * @param {string} subGroup com/facebook/ + * @param {string} coordinate Maven artifact coordinate (e.g. "react-native-artifacts") + * @param {string} artifactName Classifier part of the filename (e.g. "reactnative-core-debug.tar.gz") + */ +async function resolveSnapshotUrl( + version /*: string */, + subGroup /*: string */, + coordinate /*: string */, + artifactName /*: string */, +) /*: Promise */ { + const metadataUrl = + `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` + + `${version}-SNAPSHOT/maven-metadata.xml`; + + log(` Fetching snapshot metadata: ${metadataUrl}`); + const res = await fetch(metadataUrl); + if (!res.ok) { + throw new Error( + `Failed to fetch snapshot metadata (${res.status}): ${metadataUrl}`, + ); + } + const xml = await res.text(); + + const ts = (xml.match(/(.*?)<\/timestamp>/) ?? [])[1]; + const bn = (xml.match(/(.*?)<\/buildNumber>/) ?? [])[1]; + if (!ts || !bn) { + throw new Error( + `Could not parse timestamp/buildNumber from ${metadataUrl}`, + ); + } + + const fullVersion = `${version}-${ts}-${bn}`; + return ( + `${MAVEN_SNAPSHOT}/com/facebook/${subGroup}/${coordinate}/` + + `${version}-SNAPSHOT/${coordinate}-${fullVersion}-${artifactName}` + ); +} + +async function rnCoreSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'react', + 'react-native-artifacts', + `reactnative-core-${flavor}.tar.gz`, + ); +} +async function rnDepsSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'react', + 'react-native-artifacts', + `reactnative-dependencies-${flavor}.tar.gz`, + ); +} +async function hermesSnapshotUrl( + version /*: string */, + flavor /*: string */, +) /*: Promise */ { + return resolveSnapshotUrl( + version, + 'hermes', + 'hermes-ios', + `hermes-ios-${flavor}.tar.gz`, + ); +} + +async function resolveNightlyVersion( + npmPackage /*: string */, +) /*: Promise */ { + log(` Resolving nightly version from npm: ${npmPackage}`); + + const res = await fetch(`https://registry.npmjs.org/${npmPackage}/nightly`); + if (!res.ok) { + throw new Error(`npm lookup failed for ${npmPackage}: ${res.status}`); + } + const ver = (await res.json())?.version; + assertSafeVersion(ver, `${npmPackage}/nightly`); + log(` Resolved nightly: ${ver}`); + return ver; +} + +// A version string flows into Maven URLs and local tarball filenames (which are +// then passed to tar/cp via execFileSync). Constrain it to a safe charset so a +// malformed/hostile registry response can't produce a surprising path or a +// confusing 404 — and so static analysis sees an explicit sanitizer. +function assertSafeVersion( + ver /*: unknown */, + source /*: string */, +) /*: void */ { + if (typeof ver !== 'string' || !/^[A-Za-z0-9._-]+$/.test(ver)) { + throw new Error( + `npm response for ${source} has no usable "version" field (got: ${String(ver)})`, + ); + } +} + +/** + * Returns the cache-slot key for a given raw version label. + * + * Stable versions ('0.80.0', '0.81.0', …) become their own slot. + * Dev / nightly labels ('1000.0.0', 'nightly') resolve to the current + * nightly version (e.g. '0.85.0-nightly-20260515-abc') so each published + * nightly is its own slot — a new nightly invalidates automatically + * instead of sticking on a stale `1000.0.0` cache forever. + * + * If the npm registry lookup fails (offline, transient error), falls back + * to the raw label so a previously-cached slot under that label can still + * be used. A subsequent download attempt would surface the real error. + */ +async function resolveCacheSlotVersion( + rawVersion /*: string */, +) /*: Promise */ { + if (rawVersion !== '1000.0.0' && rawVersion !== 'nightly') { + return rawVersion; + } + try { + return await resolveNightlyVersion('react-native'); + } catch { + return rawVersion; + } +} + +async function resolveLatestV1Version() /*: Promise */ { + log(' Resolving latest-v1 Hermes from npm...'); + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch( + 'https://registry.npmjs.org/hermes-compiler/latest-v1', + ); + if (!res.ok) { + throw new Error(`npm lookup failed: ${res.status}`); + } + const ver = (await res.json())?.version; + assertSafeVersion(ver, 'hermes-compiler/latest-v1'); + log(` Resolved latest-v1: ${ver}`); + return ver; +} + +async function exists(url /*: string */) /*: Promise */ { + try { + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch(url, {method: 'HEAD'}); + return res.status === 200; + } catch { + return false; + } +} + +/** + * Returns {url, version} for the React Native core xcframework tarball. + * Resolution order: + * 1. Stable release on Maven Central + * 2. Snapshot build on Sonatype + */ +async function resolveRNCoreArtifact( + version /*: string */, + flavor /*: string */, + localTarball /*: ?string */, +) /*: Promise */ { + // Local-tarball override (--core-tarball / RN_CORE_TARBALL_PATH): use a + // locally built core tarball (e.g. the prebuild's output) instead of + // downloading. processArtifact() treats an existing local path as "already + // downloaded" and always re-extracts it. NOTE: distinct from CocoaPods' + // RCT_TESTONLY_RNCORE_TARBALL_PATH — that one belongs to pod install. + if (localTarball != null && localTarball !== '') { + if (!fs.existsSync(localTarball)) { + throw new Error( + `core tarball override is set to ${localTarball} but the file does not exist`, + ); + } + log(` Using LOCAL core tarball: ${localTarball}`); + return {url: localTarball, version: `${version}-local`}; + } + const releaseUrl = rnCoreReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await rnCoreSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +/** + * Returns {url, version} for ReactNativeDependencies. + * Respects RN_DEP_VERSION env var. + */ +async function resolveRNDepsArtifact( + rnVersion /*: string */, + flavor /*: string */, + localTarball /*: ?string */, +) /*: Promise */ { + // Local-tarball override (--deps-tarball / RN_DEPS_TARBALL_PATH): use a + // locally built deps tarball (the deps prebuild output) instead of + // downloading. It carries ReactNativeDependenciesHeaders.xcframework beside + // the binary, so the existing companion-staging path (COMPANION_XCFRAMEWORKS + // 'rndeps' -> ReactNativeDependenciesHeaders) supplies the sidecar too — no + // separate --deps-headers-tarball needed for a local build. + if (localTarball != null && localTarball !== '') { + if (!fs.existsSync(localTarball)) { + throw new Error( + `deps tarball override is set to ${localTarball} but the file does not exist`, + ); + } + log(` Using LOCAL deps tarball: ${localTarball}`); + return {url: localTarball, version: `${rnVersion}-local`}; + } + let version = process.env.RN_DEP_VERSION ?? rnVersion; + if (version === 'nightly') { + version = await resolveNightlyVersion('react-native'); + } + + const releaseUrl = rnDepsReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await rnDepsSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +/** + * 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): + * + * HERMES_VERSION unset → 'latest-v1' dist-tag + * HERMES_VERSION=latest-v1 → same (explicit) + * HERMES_VERSION=nightly → hermes-compiler@nightly dist-tag + * HERMES_VERSION= → use that version verbatim + * + * Note: rnVersion / rawVersion are intentionally not consulted. There is no + * guarantee a hermes-ios artifact exists for any given RN nightly hash — + * tying them together produces 404s like #(repro case from spikes/MyApp). + */ +async function resolveHermesArtifact( + rnVersion /*: string */, + flavor /*: string */, + rawVersion /*: string | null */, +) /*: Promise */ { + let version = process.env.HERMES_VERSION ?? 'latest-v1'; + + if (version === 'nightly') { + version = await resolveNightlyVersion('hermes-compiler'); + } else if (version === 'latest-v1') { + version = await resolveLatestV1Version(); + } + + const releaseUrl = hermesReleaseUrl(version, flavor); + if (await exists(releaseUrl)) { + log(` Using stable release: ${releaseUrl}`); + return {url: releaseUrl, version}; + } + log(` Release not found, trying snapshot...`); + const snapshotUrl = await hermesSnapshotUrl(version, flavor); + return {url: snapshotUrl, version}; +} + +function formatBytes(bytes /*: number */) /*: string */ { + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function formatSpeed(bytesPerSec /*: number */) /*: string */ { + if (bytesPerSec < 1024 * 1024) { + return `${(bytesPerSec / 1024).toFixed(0)} KB/s`; + } + return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`; +} + +/** + * Creates a multi-line progress display that keeps N lines pinned at the + * bottom of the terminal. Each line is prefixed and truncated to the current + * terminal width — without truncation, a long line (e.g. a FAILED message + * carrying a URL) wraps to a second row and `\x1b[2K` only clears the first, + * leaving stray fragments after the next update. + */ +function createProgressDisplay( + lineCount /*: number */, + prefix /*: string */ = '', +) /*: {update: (index: number, text: string) => void} */ { + let initialized = false; + + function truncateToWidth(s /*: string */) /*: string */ { + // $FlowFixMe[prop-missing] columns lives on tty$WriteStream not stream$Writable + const cols = process.stdout.columns ?? 120; + const budget = Math.max(10, cols - 1); + let out = ''; + let visLen = 0; + let i = 0; + while (i < s.length) { + if (s[i] === '\x1b' && s[i + 1] === '[') { + // CSI escape: forward through the final letter without counting. + let j = i + 2; + while (j < s.length && !/[a-zA-Z]/.test(s[j])) j++; + out += s.slice(i, j + 1); + i = j + 1; + } else { + if (visLen >= budget - 1) return out + '…\x1b[0m'; + out += s[i]; + visLen++; + i++; + } + } + return out; + } + + function update(index /*: number */, text /*: string */) { + if (!initialized) { + for (let i = 0; i < lineCount; i++) { + process.stdout.write('\n'); + } + initialized = true; + } + const moveUp = lineCount - index; + const line = truncateToWidth(prefix + text); + process.stdout.write(`\x1b[${moveUp}A\x1b[2K\r${line}\x1b[${moveUp}B\r`); + } + + return {update}; +} + +/*:: +type ProgressCallback = (label: string, downloaded: number, total: number, speed: number, done: boolean, elapsed: number) => void; +*/ + +async function download( + url /*: string */, + destPath /*: string */, + onProgress /*:: ?: ProgressCallback */, +) /*: Promise */ { + if (fs.existsSync(destPath)) { + log(` Already cached: ${path.basename(destPath)}`); + return; + } + + const tmpPath = destPath + '.download'; + try { + // $FlowFixMe[incompatible-call] global fetch not in Flow stubs + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`); + } + + const totalBytes = parseInt(res.headers.get('content-length') ?? '0', 10); + let downloadedBytes = 0; + let lastPrintTime = Date.now(); + let lastPrintBytes = 0; + const startTime = Date.now(); + + const fileStream = fs.createWriteStream(tmpPath); + + const reportProgress = (final /*: boolean */ = false) => { + const now = Date.now(); + const elapsed = (now - startTime) / 1000; + const intervalMs = now - lastPrintTime; + const intervalBytes = downloadedBytes - lastPrintBytes; + const speed = intervalMs > 0 ? (intervalBytes / intervalMs) * 1000 : 0; + + if (onProgress) { + onProgress( + path.basename(destPath), + downloadedBytes, + totalBytes, + speed, + final, + elapsed, + ); + } else { + // Fallback: single-line progress (used when not in parallel mode) + let line = ` ${formatBytes(downloadedBytes)}`; + if (totalBytes > 0) { + const pct = ((downloadedBytes / totalBytes) * 100).toFixed(1); + line += ` / ${formatBytes(totalBytes)} (${pct}%)`; + } + line += ` @ ${formatSpeed(speed)}`; + + if (final) { + const totalMb = formatBytes(downloadedBytes); + const totalSec = elapsed.toFixed(1); + const avgSpeed = + elapsed > 0 ? formatSpeed(downloadedBytes / elapsed) : ''; + process.stdout.write( + `\r Done: ${totalMb} in ${totalSec}s (avg ${avgSpeed}) \n`, + ); + } else { + process.stdout.write(`\r${line} `); + } + } + + if (!final) { + lastPrintTime = now; + lastPrintBytes = downloadedBytes; + } + }; + + if (res.body) { + // fetch() returns a Web ReadableStream, not a Node.js Readable. + // Convert it so we can pipe to a file stream and track progress. + // $FlowFixMe[prop-missing] stream.Readable.fromWeb not in Flow stubs + const nodeReadable = stream.Readable.fromWeb(res.body); + + await new Promise((resolve, reject) => { + let progressInterval; + try { + progressInterval = setInterval(() => reportProgress(), 500); + + nodeReadable + .on('data', chunk => { + downloadedBytes += chunk.length; + }) + .on('error', err => { + clearInterval(progressInterval); + reject(err); + }) + .pipe(fileStream) + .on('finish', () => { + clearInterval(progressInterval); + reportProgress(true); + resolve(); + }) + .on('error', err => { + clearInterval(progressInterval); + reject(err); + }); + } catch (err) { + if (progressInterval != null) clearInterval(progressInterval); + reject(err); + } + }); + } else { + const buf = await res.arrayBuffer(); + downloadedBytes = buf.byteLength; + fs.writeFileSync(tmpPath, Buffer.from(buf)); + reportProgress(true); + } + + fs.renameSync(tmpPath, destPath); + } catch (err) { + // Clean up partial .download temp file on failure + try { + fs.unlinkSync(tmpPath); + } catch { + // temp file may not exist yet + } + throw err; + } +} + +/** + * Extracts a .tar.gz and returns the path to the first .xcframework found. + */ +function extractXCFramework( + tarPath /*: string */, + extractDir /*: string */, +) /*: string */ { + fs.mkdirSync(extractDir, {recursive: true}); + log(` Extracting ${path.basename(tarPath)}...`); + execFileSync('tar', ['-xzf', tarPath, '-C', extractDir], {stdio: 'pipe'}); + + const found = findFirst(extractDir, name => name.endsWith('.xcframework'), 8); + if (found == null) { + throw new Error(`No .xcframework found after extracting ${tarPath}`); + } + log(` Found: ${path.relative(extractDir, found)}`); + return found; +} + +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; +} + +/** + * The hermes-ios tarball ships its public C++ API headers in + * `destroot/include/hermes` alongside the framework — which extractXCFramework + * discards (it keeps only the .xcframework). Stage the `hermes/` namespace into + * `/hermes-headers/hermes` so headers-compose can fold it into + * ReactNativeHeaders (making `` resolve for any RN-linking + * target). Only `hermes/` is staged — `jsi/` is already vended elsewhere. + * Best-effort: a tarball without these headers just leaves hermes unavailable. + */ +function stageHermesHeaders( + extractDir /*: string */, + outputDir /*: string */, +) /*: void */ { + let includeDir = path.join(extractDir, 'destroot', 'include'); + if (!fs.existsSync(path.join(includeDir, 'hermes', 'hermes.h'))) { + // Fall back to locating the include dir wherever it landed in the tarball. + const hit = findFirst(extractDir, name => name === 'include', 8); + if (hit != null) { + includeDir = hit; + } + } + const src = path.join(includeDir, 'hermes'); + if (!fs.existsSync(path.join(src, 'hermes.h'))) { + log(' Hermes public headers not found in tarball — skipping header stage'); + return; + } + const destRoot = path.join(outputDir, 'hermes-headers'); + const dest = path.join(destRoot, 'hermes'); + fs.rmSync(dest, {recursive: true, force: true}); + fs.mkdirSync(destRoot, {recursive: true}); + execFileSync('/bin/cp', ['-R', src, dest], {stdio: 'pipe'}); + log(' Staged Hermes public headers → hermes-headers/hermes'); +} + +const DEPS_HEADERS_NAME = 'ReactNativeDependenciesHeaders'; + +// Headers-only companion xcframeworks that ship INSIDE another artifact's +// tarball: the ReactCore tarball carries ReactNativeHeaders.xcframework +// beside React.xcframework, and the ReactNativeDependencies tarball carries +// the ReactNativeDependenciesHeaders sidecar beside the binary (the binary is +// framework-type, so only the LIBRARY-type sidecar can serve the deps +// namespaces — folly/glog/boost/... — to SwiftPM; ReactNativeHeaders is +// pure-RN). extractXCFramework keeps only the first xcframework, so +// companions are staged separately into their own +// `/.xcframework`. +const COMPANION_XCFRAMEWORKS /*: {[label: string]: string} */ = { + 'react-core': 'ReactNativeHeaders', + rndeps: DEPS_HEADERS_NAME, +}; + +/** + * Stages a companion xcframework out of an extract dir. Returns true when + * staged; false when the tarball predates the companion. + */ +function stageCompanionXcframework( + extractDir /*: string */, + outputDir /*: string */, + name /*: string */, +) /*: boolean */ { + const src = findFirst(extractDir, n => n === `${name}.xcframework`, 8); + if (src == null) { + return false; + } + const dest = path.join(outputDir, `${name}.xcframework`); + fs.rmSync(dest, {recursive: true, force: true}); + fs.renameSync(src, dest); + log(` Staged companion ${name}.xcframework`); + return true; +} + +/** + * Self-heal: stage a companion xcframework into an already-extracted slot + * (the fast path skips extraction, so the companion was never staged). + * Prefers a CACHED tarball (no network); downloads only as a last resort. + */ +async function ensureCompanionStaged( + url /*: string */, + downloadDir /*: string */, + sharedTarballName /*: ?string */, + outputDir /*: string */, + name /*: string */, +) /*: Promise */ { + const candidates = [ + !/^https?:\/\//.test(url) ? url : null, // local-tarball override + path.join(downloadDir, url.split('/').pop() ?? ''), + sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null, + ].filter(Boolean); + let tarPath /*: ?string */ = candidates.find( + p => p != null && fs.existsSync(p), + ); + if (tarPath == null) { + if (!/^https?:\/\//.test(url)) { + return; // local override missing — nothing to recover from + } + const localPath = path.join( + downloadDir, + url.split('/').pop() ?? 'artifact.tar.gz', + ); + fs.mkdirSync(downloadDir, {recursive: true}); + await download(url, localPath); + tarPath = localPath; + } + const tmp = path.join(outputDir, `.companion-tmp-${name}`); + fs.rmSync(tmp, {recursive: true, force: true}); + fs.mkdirSync(tmp, {recursive: true}); + try { + execFileSync('tar', ['-xzf', tarPath, '-C', tmp], {stdio: 'pipe'}); + stageCompanionXcframework(tmp, outputDir, name); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +/** + * Self-heal: stage Hermes headers into an already-extracted slot (the fast + * path skips extraction, so the headers were never staged). Prefers a CACHED + * hermes tarball (no network); downloads only as a last resort so the slot + * can't get stuck "incomplete" forever. No-op only when the headers can't be + * obtained at all (e.g. a missing local-tarball override). + */ +async function ensureHermesHeadersStaged( + url /*: string */, + downloadDir /*: string */, + sharedTarballName /*: ?string */, + outputDir /*: string */, +) /*: Promise */ { + const candidates = [ + !/^https?:\/\//.test(url) ? url : null, // local-tarball override + path.join(downloadDir, url.split('/').pop() ?? ''), + sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null, + ].filter(Boolean); + let tarPath /*: ?string */ = candidates.find( + p => p != null && fs.existsSync(p), + ); + if (tarPath == null) { + if (!/^https?:\/\//.test(url)) { + return; // local override missing — nothing to recover from + } + const localPath = path.join( + downloadDir, + url.split('/').pop() ?? 'hermes.tar.gz', + ); + fs.mkdirSync(downloadDir, {recursive: true}); + await download(url, localPath); + tarPath = localPath; + } + const tmp = path.join(outputDir, '.hermes-hdr-tmp'); + fs.rmSync(tmp, {recursive: true, force: true}); + fs.mkdirSync(tmp, {recursive: true}); + try { + execFileSync('tar', ['-xzf', tarPath, '-C', tmp], {stdio: 'pipe'}); + stageHermesHeaders(tmp, outputDir); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} + +/** + * Downloads a tarball, extracts the xcframework, and places it directly in + * the output directory as .xcframework/. + * + * SPM binaryTarget(path:) accepts a bare .xcframework directory — no zip or + * checksum needed for local path-based targets. + * + * @param {string} label Internal label (used for log messages) + * @param {string} xcframeworkName The SPM target name (e.g. "React", "hermes-engine") + * @param resolvedArtifact {url, version} from resolve*Artifact() + * @param {string} downloadDir Where to cache downloaded tarballs + * @param {string} outputDir Where to place the final .xcframework directory + * @param sharedTarballName Filename in the flat shared cache to reuse/populate + * (matches CocoaPods' convention), or null to not share. + */ +async function processArtifact( + label /*: string */, + xcframeworkName /*: string */, + resolvedArtifact /*: ResolvedArtifact */, + downloadDir /*: string */, + outputDir /*: string */, + onProgress /*:: ?: ProgressCallback */, + sharedTarballName /*:: ?: ?string */, +) /*: Promise */ { + const {url, version} = resolvedArtifact; + + const destXcfwPath = path.join(outputDir, `${xcframeworkName}.xcframework`); + // Local-tarball override: `url` is an existing local file. Always + // re-extract (a changed local tarball must win over a previous extraction) + // and never touch the shared cache. + const isLocalTarball = !/^https?:\/\//.test(url) && fs.existsSync(url); + if (isLocalTarball && fs.existsSync(destXcfwPath)) { + fs.rmSync(destXcfwPath, {recursive: true, force: true}); + } + if (fs.existsSync(destXcfwPath)) { + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Already extracted: ${xcframeworkName}.xcframework`); + } + // The xcframework is cached, but a slot from older tooling won't have the + // Hermes headers staged. Backfill them from a cached tarball (no network). + if ( + label === 'hermes' && + !fs.existsSync(path.join(outputDir, 'hermes-headers', 'hermes')) + ) { + try { + await ensureHermesHeadersStaged( + url, + downloadDir, + sharedTarballName, + outputDir, + ); + } catch (e) { + log(` Hermes header backfill failed (${e.message}) — continuing`); + } + } + // Same for the headers-only companions (ReactNativeHeaders from the core + // tarball, the ReactNativeDependenciesHeaders sidecar from the deps + // tarball): backfill into slots extracted by older tooling from the + // cached tarball. + const companion = COMPANION_XCFRAMEWORKS[label]; + if ( + companion != null && + !fs.existsSync(path.join(outputDir, `${companion}.xcframework`)) + ) { + try { + await ensureCompanionStaged( + url, + downloadDir, + sharedTarballName, + outputDir, + companion, + ); + } catch (e) { + log(` ${companion} backfill failed (${e.message}) — continuing`); + } + } + return {label, version, xcframeworkPath: destXcfwPath, url}; + } + + // Tarball acquisition: prefer the flat shared cache (~/Library/Caches/ + // ReactNative/) that CocoaPods also populates, so SPM and `pod install` + // reuse the same download. RCT_SKIP_CACHES=1 bypasses it (mirrors CocoaPods). + const skipCaches = process.env.RCT_SKIP_CACHES === '1'; + const sharedPath = + !skipCaches && sharedTarballName != null + ? path.join(sharedCacheDir(), sharedTarballName) + : null; + + const downloadAndCache = async () /*: Promise */ => { + const tarName = url.split('/').pop() ?? ''; + const localPath = path.join(downloadDir, tarName); + await download( + url, + localPath, + onProgress + ? (name, downloaded, total, speed, done, elapsed) => + onProgress(xcframeworkName, downloaded, total, speed, done, elapsed) + : undefined, + ); + // Best-effort: save into the flat shared cache for future SPM/CocoaPods runs. + if (sharedPath != null) { + try { + fs.mkdirSync(sharedCacheDir(), {recursive: true}); + fs.copyFileSync(localPath, sharedPath); + } catch { + // ignore shared-cache write failures + } + } + return localPath; + }; + + let tarPath: string; + let fromShared = false; + if (isLocalTarball) { + tarPath = url; + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Using local tarball: ${url}`); + } + } else if (sharedPath != null && fs.existsSync(sharedPath)) { + // Shared cache hit — skip the download entirely. + tarPath = sharedPath; + fromShared = true; + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, true, 0); + } else { + log(` Shared cache hit: ${path.basename(sharedPath)}`); + } + } else { + tarPath = await downloadAndCache(); + } + + // Extract to a temp dir, rename to the expected name, then move into outputDir + if (onProgress) { + onProgress(xcframeworkName, 0, 0, 0, false, 0); + } + const tmpExtractDir = path.join(outputDir, '.extract-tmp', label); + let xcfwPath: string; + try { + xcfwPath = extractXCFramework(tarPath, tmpExtractDir); + } catch (e) { + // A poisoned shared tarball must not permanently break SPM: drop it and + // re-download to the local dir once. + if (fromShared) { + try { + fs.rmSync(tarPath, {force: true}); + } catch {} + tarPath = await downloadAndCache(); + xcfwPath = extractXCFramework(tarPath, tmpExtractDir); + } else { + throw e; + } + } + + const actualBasename = path.basename(xcfwPath); + const expectedBasename = `${xcframeworkName}.xcframework`; + if (actualBasename !== expectedBasename) { + const renamed = path.join(tmpExtractDir, expectedBasename); + fs.renameSync(xcfwPath, renamed); + fs.renameSync(renamed, destXcfwPath); + } else { + fs.renameSync(xcfwPath, destXcfwPath); + } + + // Hermes ships its public headers in the same tarball; stage them next to + // the xcframeworks so headers-compose can fold `hermes/` into + // ReactNativeHeaders. (Other artifacts have no such headers — no-op.) + if (label === 'hermes') { + try { + stageHermesHeaders(tmpExtractDir, outputDir); + } catch (e) { + log(` Hermes header staging failed (${e.message}) — continuing`); + } + } + + // Headers-only companions ship inside the same tarball (ReactNativeHeaders + // in the core tarball, the ReactNativeDependenciesHeaders sidecar in the + // deps tarball) — stage them as their own artifacts. + const companionName = COMPANION_XCFRAMEWORKS[label]; + if (companionName != null) { + if (!stageCompanionXcframework(tmpExtractDir, outputDir, companionName)) { + log( + ` ${companionName}.xcframework not present in the ${label} tarball (pre-companion artifact)`, + ); + } + } + + fs.rmSync(tmpExtractDir, {recursive: true, force: true}); + + return {label, version, xcframeworkPath: destXcfwPath, url}; +} + +async function main(argv /*:: ?: Array */) /*: Promise */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + const rnRoot = path.resolve(__dirname, '../..'); + const flavor = args.flavor; + + // Resolve base RN version + // rawVersion preserves the original --version arg (e.g. 'nightly') before resolution. + // It is passed to Hermes resolution so it can independently resolve its nightly. + let rawVersion = args.version; + let rnVersion = args.version; + if (rnVersion == null) { + // $FlowFixMe[incompatible-type] JSON.parse returns any + const rnPkg /*: {version: string} */ = JSON.parse( + fs.readFileSync(path.join(rnRoot, 'package.json'), 'utf8'), + ); + rnVersion = rnPkg.version; + } + if (rnVersion === '1000.0.0') { + log('Detected dev version (1000.0.0), resolving as nightly...'); + rawVersion = 'nightly'; + } + if (rnVersion === 'nightly' || rnVersion === '1000.0.0') { + rnVersion = await resolveNightlyVersion('react-native'); + } + if (rnVersion == null) { + die('Could not determine RN version'); + } + // Re-bind to const so Flow keeps the non-null narrowing across the closures + // below (let-bound vars are widened across function boundaries). + const resolvedRnVersion /*: string */ = rnVersion; + + // Cache key: stable versions slot under their own number. Dev / nightly + // labels use the resolved nightly hash (e.g. "0.85.0-nightly-20260515-abc") + // so each published nightly is its own slot — picks up new specs and fixes + // automatically instead of sticking on a stale "1000.0.0" cache forever. + const cacheVersionKey = + rawVersion === 'nightly' || rawVersion === '1000.0.0' || rawVersion == null + ? resolvedRnVersion + : rawVersion; + const outputDir = + args.output != null + ? path.resolve(args.output) + : defaultCacheDir(cacheVersionKey, flavor); + // Tarballs are cached in a .downloads/ subdirectory to keep them separate + // from the extracted .xcframework directories. + const downloadDir = path.join(outputDir, '.downloads'); + + fs.mkdirSync(outputDir, {recursive: true}); + fs.mkdirSync(downloadDir, {recursive: true}); + + log(`RN version : ${resolvedRnVersion}`); + log(`Flavor : ${flavor}`); + log(`Output : ${displayPath(outputDir)}`); + log(''); + + // Download all three artifacts in parallel for faster setup + log('Downloading artifacts in parallel...'); + + // `sharedName` builds the flat shared-cache filename in the canonical + // ~/Library/Caches/ReactNative/ dir, matching the names other RN tooling uses + // (CocoaPods' rncore.rb / rndependencies.rb for core+deps, and the hermes + // prebuilt tarball name) so SPM and `pod install` reuse the same downloads. + // `v` is each artifact's resolved version (RN version for core/deps, the + // hermes-ios version for hermes). + const artifactSpecs = [ + { + label: 'react-core', + name: 'React', + resolve: () => + resolveRNCoreArtifact(resolvedRnVersion, flavor, args.coreTarball), + // Local overrides skip the shared cache (test artifacts must not + // poison the canonical downloads). + sharedName: + args.coreTarball != null + ? null + : (v /*: string */) => `reactnative-core-${v}-${flavor}.tar.gz`, + }, + { + label: 'rndeps', + name: 'ReactNativeDependencies', + resolve: () => + resolveRNDepsArtifact(resolvedRnVersion, flavor, args.depsTarball), + // Local override skips the shared cache (as with core) so a local deps + // build can't poison the canonical downloads. + sharedName: + args.depsTarball != null + ? null + : (v /*: string */) => + `reactnative-dependencies-${v}-${flavor}.tar.gz`, + }, + { + label: 'hermes', + name: 'hermes-engine', + resolve: () => + resolveHermesArtifact(resolvedRnVersion, flavor, rawVersion), + sharedName: (v /*: string */) => `hermes-ios-${v}-${flavor}.tar.gz`, + }, + ]; + + // ReactNativeHeaders is normally staged straight out of the ReactCore + // tarball (it ships beside React.xcframework). The override exists for + // testing a standalone headers tarball (e.g. the prebuild output at + // .build/output/xcframeworks//ReactNativeHeaders.xcframework.tar.gz, + // published on Maven under classifier reactnative-headers-). + const headersTarball = args.headersTarball; + if (headersTarball != null && headersTarball !== '') { + if (!fs.existsSync(headersTarball)) { + die( + `headers tarball override is set to ${headersTarball} but the file does not exist`, + ); + } + artifactSpecs.push({ + label: 'rnheaders', + name: 'ReactNativeHeaders', + resolve: () => + Promise.resolve({ + url: headersTarball, + version: `${resolvedRnVersion}-local`, + }), + sharedName: null, + }); + } + + // ReactNativeDependenciesHeaders: normally staged straight out of the deps + // tarball (the sidecar ships inside it). The override exists for testing a + // standalone sidecar tarball (classifier + // reactnative-dependencies-headers-). + const depsHeadersTarball = args.depsHeadersTarball; + if (depsHeadersTarball != null && depsHeadersTarball !== '') { + if (!fs.existsSync(depsHeadersTarball)) { + die( + `deps-headers tarball override is set to ${depsHeadersTarball} but the file does not exist`, + ); + } + artifactSpecs.push({ + label: 'rndeps-headers', + name: DEPS_HEADERS_NAME, + resolve: () => + Promise.resolve({ + url: depsHeadersTarball, + version: `${resolvedRnVersion}-local`, + }), + sharedName: null, + }); + } + + const progress = createProgressDisplay( + artifactSpecs.length, + '\x1b[32m[download-spm-artifacts]\x1b[0m ', + ); + + const makeCallback = (index /*: number */) /*: ProgressCallback */ => + (name, downloaded, total, speed, done, elapsed) => { + if (done && downloaded === 0 && total === 0) { + progress.update(index, ` ${name}: already cached`); + } else if (done) { + const avg = elapsed > 0 ? formatSpeed(downloaded / elapsed) : ''; + progress.update( + index, + ` ${name}: done ${formatBytes(downloaded)} in ${elapsed.toFixed(1)}s (${avg})`, + ); + } else if (total > 0) { + const pct = ((downloaded / total) * 100).toFixed(1); + progress.update( + index, + ` ${name}: ${formatBytes(downloaded)} / ${formatBytes(total)} (${pct}%) @ ${formatSpeed(speed)}`, + ); + } else { + progress.update(index, ` ${name}: extracting...`); + } + }; + + const results /*: Array */ = await Promise.all( + artifactSpecs.map(async (spec, index) => { + try { + const artifact = await spec.resolve(); + progress.update(index, ` ${spec.name}: resolving...`); + const sharedTarballName = + spec.sharedName != null ? spec.sharedName(artifact.version) : null; + const r = await processArtifact( + spec.label, + spec.name, + artifact, + downloadDir, + outputDir, + makeCallback(index), + sharedTarballName, + ); + const ok /*: ArtifactResultEntry */ = { + name: spec.name, + error: undefined, + ...r, + }; + return ok; + } catch (e) { + progress.update(index, ` ${spec.name}: FAILED - ${e.message}`); + const failed /*: ArtifactResultEntry */ = { + name: spec.name, + error: e.message, + }; + return failed; + } + }), + ); + log(''); + + const succeeded = results.filter(r => r.error == null); + const failed = results.filter(r => r.error != null); + + log('='.repeat(60)); + if (succeeded.length > 0) { + log('Extracted xcframeworks:'); + log(''); + for (const r of succeeded) { + if (r.error == null) { + log(` ${r.name}`); + log(` path: ${displayPath(r.xcframeworkPath)}`); + log(''); + } + } + } + // Abort on ANY failure — every artifact (React, ReactNativeDependencies, + // hermes-engine + the headers companions) is required; proceeding with a + // partial set would only surface as a confusing build error in Xcode. We + // also intentionally do NOT + // write artifacts.json when there are failures: the orchestrator uses its + // presence as the "already present" signal, so a partial write would mask + // the problem and prevent retries. + if (failed.length > 0) { + log('Failed:'); + for (const r of failed) { + warn(` ${r.name}: ${r.error ?? 'unknown error'}`); + } + die( + `Failed to download ${failed.length} of ${results.length} artifact(s): ` + + failed.map(r => r.name).join(', '), + ); + } + + // Write artifacts.json only on full success. + const artifactsJson /*: {[string]: {xcframeworkPath: string, url: string}} */ = + {}; + for (const r of succeeded) { + if (r.error == null) { + artifactsJson[r.name] = {xcframeworkPath: r.xcframeworkPath, url: r.url}; + } + } + // The headers-only companions are staged by their parent's extraction (or + // by the --headers-tarball / --deps-headers-tarball overrides above, which + // already produced entries). A slot without them cannot resolve any + // non-React namespace (ReactNativeHeaders) or the -style deps + // includes (the sidecar; ReactNativeHeaders is pure-RN) — fail closed + // rather than hand Xcode a package graph that breaks with header-not-found + // errors much later. + const companions = [ + { + name: 'ReactNativeHeaders', + parent: 'React', + advice: + 'The React core tarball predates the headers-spec layout — use a ' + + 'matching react-native version, or pass --headers-tarball / ' + + 'RN_HEADERS_TARBALL_PATH.', + }, + { + name: DEPS_HEADERS_NAME, + parent: 'ReactNativeDependencies', + advice: + 'The ReactNativeDependencies tarball predates the headers sidecar — ' + + 'use a matching react-native version, or pass ' + + '--deps-headers-tarball / RN_DEPS_HEADERS_TARBALL_PATH.', + }, + ]; + for (const {name, parent, advice} of companions) { + if (artifactsJson[name] != null) { + continue; + } + const companionPath = path.join(outputDir, `${name}.xcframework`); + if (!fs.existsSync(companionPath)) { + die(`${name}.xcframework is missing from the artifact slot. ${advice}`); + } + const parentEntry = artifactsJson[parent]; + artifactsJson[name] = { + xcframeworkPath: companionPath, + url: parentEntry != null ? parentEntry.url : '', + }; + } + const artifactsJsonPath = path.join(outputDir, 'artifacts.json'); + fs.writeFileSync( + artifactsJsonPath, + JSON.stringify(artifactsJson, null, 2) + '\n', + 'utf8', + ); + log(`Artifact index: ${displayPath(artifactsJsonPath)}`); +} + +// Canonical set of xcframework artifacts an SPM slot must provide. The +// xcodeproj references them as package products; missing any one surfaces as +// "Missing package product" only at Xcode build time. Used by +// `setup-apple-spm.js` to validate the cache before skipping a re-download. +// ReactNativeHeaders (pure-RN) and ReactNativeDependenciesHeaders (the deps +// sidecar) are the two headers-only targets — both required: without the +// sidecar no -style include resolves. +const REQUIRED_ARTIFACTS = [ + 'React', + 'ReactNativeHeaders', + 'ReactNativeDependencies', + 'ReactNativeDependenciesHeaders', + 'hermes-engine', +]; + +/** + * Returns null if `artifacts.json` is present, complete (covers every entry + * in REQUIRED_ARTIFACTS), and each entry's xcframework dir exists on disk. + * Otherwise returns a string describing what's wrong — caller treats that as + * "needs re-download". Catches stale partial-write states from older runs + * that didn't fail loudly on download errors. + */ +function validateArtifactsCache( + artifactsDir /*: string */, +) /*: string | null */ { + const artifactsJsonPath = path.join(artifactsDir, 'artifacts.json'); + if (!fs.existsSync(artifactsJsonPath)) { + return `artifacts.json missing in ${artifactsDir}`; + } + let json: {[string]: {xcframeworkPath: string, url: string}}; + try { + // $FlowFixMe[unclear-type] JSON.parse returns any + const parsed /*: any */ = JSON.parse( + fs.readFileSync(artifactsJsonPath, 'utf8'), + ); + json = parsed; + } catch (e) { + return `artifacts.json is unreadable: ${e.message}`; + } + for (const name of REQUIRED_ARTIFACTS) { + const entry = json[name]; + if (entry == null) { + return `artifacts.json missing entry for "${name}"`; + } + if (!fs.existsSync(entry.xcframeworkPath)) { + return `xcframework for "${name}" not found at ${entry.xcframeworkPath}`; + } + } + // The Hermes public headers must be staged for headers-compose to fold + // `` into ReactNativeHeaders. A slot from older tooling won't + // have them — report incomplete so ensureArtifacts re-runs the download + // (which, with the xcframeworks already present, only backfills the headers + // from the cached tarball — no network re-download). + if (!fs.existsSync(path.join(artifactsDir, 'hermes-headers', 'hermes'))) { + return 'Hermes public headers not staged (hermes-headers/hermes)'; + } + return null; +} + +if (require.main === module) { + main().catch(err => { + console.error(`\x1b[31m${err.message}\x1b[0m`); + process.exitCode = 1; + }); +} + +module.exports = { + main, + resolveCacheSlotVersion, + resolveHermesArtifact, + REQUIRED_ARTIFACTS, + validateArtifactsCache, + // Exposed for unit tests (pure / fetch-stubbable helpers). + rnCoreReleaseUrl, + rnDepsReleaseUrl, + hermesReleaseUrl, + resolveSnapshotUrl, + resolveNightlyVersion, + resolveLatestV1Version, + resolveRNCoreArtifact, + resolveRNDepsArtifact, + exists, + formatBytes, + formatSpeed, + findFirst, + extractXCFramework, +}; diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js new file mode 100644 index 000000000000..0b23b983f0f0 --- /dev/null +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -0,0 +1,216 @@ +/** + * 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 {toSwiftName} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +/** + * expand-spm-dependencies.js — Resolves transitive native deps declared via + * `spm.dependencies` in a library's react-native.config.js. + * + * SPM has no equivalent of CocoaPods' podspec `s.dependency`, so library + * authors declare the same relationships explicitly: + * + * // react-native-reanimated/react-native.config.js + * module.exports = { + * dependency: { platforms: { ios: {} } }, + * spm: { dependencies: ['react-native-worklets'] }, + * }; + * + * This module reads the directly-autolinked deps (from autolinking.json), + * follows each one's spm.dependencies recursively, and returns the deduped + * 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 + * testable. + */ + +/*:: +import type {AutolinkedDep} from './spm-types'; + +// react-native.config.js entries have a user-defined shape, so we use an +// inexact object type and access properties dynamically. +type RnConfig = {...}; +type ReadConfig = (root: string) => ?RnConfig; +type ResolveDep = (name: string, fromRoot: string) => ?string; +type Options = { + readConfig: ReadConfig, + resolveDep: ResolveDep, +}; +*/ + +// 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`). +function resolveSwiftName( + npmName /*: string */, + config /*: ?RnConfig */, +) /*: string */ { + // $FlowFixMe[prop-missing] config has dynamic shape + const override = config?.spm?.name; + if (override == null) { + return toSwiftName(npmName); + } + 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)}.`, + ); + } + // 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.`, + ); + } + return override; +} + +function expandSpmDependencies( + directDeps /*: Array */, + options /*: Options */, +) /*: Array */ { + const {readConfig, resolveDep} = options; + const byName /*: Map */ = new Map(); + for (const dep of directDeps) { + byName.set(dep.name, {...dep, spmDependencies: []}); + } + + const queue /*: Array */ = directDeps.map(d => d.name); + while (queue.length > 0) { + const currentName = queue.shift(); + if (typeof currentName !== 'string') { + continue; + } + const current = byName.get(currentName); + if (current == null) { + continue; + } + const config = readConfig(current.root); + // 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); + } + // $FlowFixMe[prop-missing] config has dynamic shape + const transitives /*: Array */ = config?.spm?.dependencies ?? []; + + const currentSpmDeps /*: Array */ = []; + for (const transitiveName of transitives) { + if (!byName.has(transitiveName)) { + const transitiveRoot = resolveDep(transitiveName, current.root); + if (transitiveRoot == null) { + throw new Error( + `react-native autolinking: '${currentName}' declares an unresolvable spm.dependency '${transitiveName}'. Ensure '${transitiveName}' is installed and visible via Node module resolution from ${current.root}.`, + ); + } + + const transitiveConfig = readConfig(transitiveRoot); + // $FlowFixMe[prop-missing] config has dynamic shape + const iosPlatform = transitiveConfig?.dependency?.platforms?.ios; + if (iosPlatform == null) { + // No iOS native code — nothing to autolink and nothing to declare + // as an SPM target dep; mirrors the silent skip in + // autolinkingDepToSpmTarget for android-only deps. + continue; + } + + byName.set(transitiveName, { + name: transitiveName, + root: transitiveRoot, + platforms: {ios: iosPlatform}, + swiftName: resolveSwiftName(transitiveName, transitiveConfig), + spmDependencies: [], + }); + queue.push(transitiveName); + } + currentSpmDeps.push(transitiveName); + } + current.spmDependencies = currentSpmDeps; + } + + // 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 + // clear message instead of letting SPM emit a confusing duplicate-target + // error later. + // Key case-INSENSITIVELY: resolveSwiftName permits lowercase ('worklets') + // while toSwiftName produces TitleCase ('Worklets') — an exact-equality check + // 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()) { + const swiftName = dep.swiftName; + if (swiftName == null) { + continue; + } + const key = swiftName.toLowerCase(); + const existing = seen.get(key); + if (existing != null) { + const same = existing.swiftName === swiftName; + throw new Error( + `react-native autolinking: SPM Swift name collision: '${existing.name}' ('${existing.swiftName}') and '${dep.name}' ('${swiftName}') ` + + (same + ? `both resolve to '${swiftName}'.` + : `differ only in case, which collides on case-insensitive filesystems.`) + + ` Set a distinct 'spm.name' in one of their react-native.config.js files.`, + ); + } + seen.set(key, {name: dep.name, swiftName}); + } + + return Array.from(byName.values()); +} + +// --------------------------------------------------------------------------- +// Default I/O implementations +// --------------------------------------------------------------------------- + +function defaultReadConfig(root /*: string */) /*: ?RnConfig */ { + const configPath = path.join(root, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return null; + } + try { + // $FlowFixMe[unsupported-syntax] + return require(configPath); + } catch { + return null; + } +} + +function defaultResolveDep( + name /*: string */, + fromRoot /*: string */, +) /*: ?string */ { + try { + const pkgJsonPath = require.resolve(`${name}/package.json`, { + paths: [fromRoot], + }); + return path.dirname(pkgJsonPath); + } catch { + return null; + } +} + +module.exports = { + expandSpmDependencies, + resolveSwiftName, + defaultReadConfig, + defaultResolveDep, +}; diff --git a/packages/react-native/scripts/spm/flavored-frameworks.js b/packages/react-native/scripts/spm/flavored-frameworks.js new file mode 100644 index 000000000000..d59e3692a541 --- /dev/null +++ b/packages/react-native/scripts/spm/flavored-frameworks.js @@ -0,0 +1,1008 @@ +/** + * 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 {execFileSync} = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// Info.plist parsing goes through plutil (as the pre-B1 swap script did): the +// `plist` npm module is NOT a react-native dependency, so it only resolves +// inside this monorepo via hoisting — requiring it breaks `spm add` in a +// fresh consumer app with "Cannot find module 'plist'". +function plistJson(p /*: string */) /*: $FlowFixMe */ { + return JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', p]).toString(), + ); +} + +/*:: import type { + FlavoredFrameworkManifestEntry, + FlavoredFrameworksManifest, + ParsedXcframework, + PluginFlavoredFramework, + XcframeworkSlice, +} from './spm-types'; */ + +/*:: +type ArtifactIndexEntry = {xcframeworkPath: string, url?: string}; +type ArtifactIndex = {[string]: ArtifactIndexEntry}; +type ValidatedFramework = { + id: string, + frameworkName: string, + executableName: string, + linkage: 'dynamic', + slices: Array, +}; +type StagedFramework = { + id: string, + frameworkName: string, + executableName: string, + linkage: 'dynamic', + artifactRelativePath: string, + slices: Array, + sourcePaths: {debug: string, release: string}, +}; +*/ + +const FLAVORS /*: ReadonlyArray<'debug' | 'release'> */ = ['debug', 'release']; + +const FLAVORED_FRAMEWORKS_MANIFEST = 'flavored-frameworks.json'; +const PENDING_ARTIFACT_STAMP = '.artifact-stamp.pending'; +const PLUGIN_FRAMEWORKS_MANIFEST /*: string */ = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-flavored-frameworks.json', +); + +const BUILTIN_FRAMEWORKS = [ + {id: 'react', artifactName: 'React'}, + {id: 'react-native-dependencies', artifactName: 'ReactNativeDependencies'}, + {id: 'hermes', artifactName: 'hermes-engine'}, +]; + +const INVARIANT_BINARY_TARGETS = [ + 'ReactNativeHeaders', + 'ReactNativeDependenciesHeaders', +]; + +function readJson(filePath /*: string */) /*: unknown */ { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function requireString( + value /*: unknown */, + label /*: string */, +) /*: string */ { + if (typeof value !== 'string') { + throw new Error(`${label} must be a string`); + } + return value; +} + +function readArtifactIndex(artifactsDir /*: string */) /*: ArtifactIndex */ { + const indexPath = path.join(artifactsDir, 'artifacts.json'); + if (!fs.existsSync(indexPath)) { + throw new Error(`artifacts.json missing in ${artifactsDir}`); + } + const parsed = readJson(indexPath); + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`artifacts.json in ${artifactsDir} is not an object`); + } + const result /*: ArtifactIndex */ = {}; + for (const name of Object.keys(parsed)) { + const entry = parsed[name]; + if ( + entry == null || + typeof entry !== 'object' || + typeof entry.xcframeworkPath !== 'string' + ) { + throw new Error( + `artifacts.json entry '${name}' in ${artifactsDir} is invalid`, + ); + } + result[name] = { + xcframeworkPath: entry.xcframeworkPath, + ...(typeof entry.url === 'string' ? {url: entry.url} : {}), + }; + } + return result; +} + +function frameworkNameFromLibraryPath(libraryPath /*: string */) /*: string */ { + if (!libraryPath.endsWith('.framework')) { + throw new Error( + `flavored binary '${libraryPath}' is not a dynamic framework; ` + + 'only .framework XCFramework libraries are supported', + ); + } + return path.basename(libraryPath, '.framework'); +} + +function sdkConditionForSlice( + platform /*: string */, + variant /*: ?string */, +) /*: string */ { + if (platform === 'ios' && variant === 'simulator') { + return 'iphonesimulator*'; + } + if (platform === 'ios' && variant === 'maccatalyst') { + return 'macosx*'; + } + if (platform === 'ios') { + return 'iphoneos*'; + } + if (platform === 'tvos' && variant === 'simulator') { + return 'appletvsimulator*'; + } + if (platform === 'tvos') { + return 'appletvos*'; + } + if (platform === 'xros' && variant === 'simulator') { + return 'xrsimulator*'; + } + if (platform === 'xros') { + return 'xros*'; + } + if (platform === 'macos') { + return 'macosx*'; + } + throw new Error( + `unsupported XCFramework platform '${platform}'` + + (variant != null ? ` variant '${variant}'` : ''), + ); +} + +function parseXcframework( + xcframeworkPath /*: string */, +) /*: ParsedXcframework */ { + const infoPath = path.join(xcframeworkPath, 'Info.plist'); + if (!fs.existsSync(infoPath)) { + throw new Error(`XCFramework Info.plist missing at ${infoPath}`); + } + const info = plistJson(infoPath); + const libraries = info.AvailableLibraries; + if (!Array.isArray(libraries) || libraries.length === 0) { + throw new Error( + `XCFramework has no AvailableLibraries: ${xcframeworkPath}`, + ); + } + let frameworkName = null; + let executableName = null; + const slices = libraries.map(library => { + const identifier = library.LibraryIdentifier; + const libraryPath = library.LibraryPath; + const platform = library.SupportedPlatform; + const variant = library.SupportedPlatformVariant ?? null; + const architectures = library.SupportedArchitectures; + if ( + typeof identifier !== 'string' || + typeof libraryPath !== 'string' || + typeof platform !== 'string' || + !Array.isArray(architectures) || + !architectures.every(a => typeof a === 'string') + ) { + throw new Error( + `XCFramework contains an invalid library entry: ${xcframeworkPath}`, + ); + } + const sliceFrameworkName = frameworkNameFromLibraryPath(libraryPath); + const binaryPath = + typeof library.BinaryPath === 'string' + ? library.BinaryPath + : path.join(libraryPath, sliceFrameworkName); + const sliceExecutableName = path.basename(binaryPath); + if (frameworkName == null) { + frameworkName = sliceFrameworkName; + executableName = sliceExecutableName; + } else if ( + frameworkName !== sliceFrameworkName || + executableName !== sliceExecutableName + ) { + throw new Error( + `XCFramework changes framework or executable name between slices: ${xcframeworkPath}`, + ); + } + return { + sdk: sdkConditionForSlice(platform, variant), + platform, + variant, + architectures: [...architectures].sort(), + libraryIdentifier: identifier, + libraryPath, + binaryPath, + }; + }); + if (frameworkName == null || executableName == null) { + throw new Error( + `could not identify framework binary in ${xcframeworkPath}`, + ); + } + slices.sort((a, b) => + `${a.sdk}:${a.libraryIdentifier}`.localeCompare( + `${b.sdk}:${b.libraryIdentifier}`, + ), + ); + return { + path: xcframeworkPath, + frameworkName, + executableName, + slices, + }; +} + +function sliceShape(parsed /*: ParsedXcframework */) /*: string */ { + return JSON.stringify(parsed.slices); +} + +function walkFiles(root /*: string */) /*: Array */ { + const files = []; + const visit = (dir /*: string */) => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, String(entry.name)); + if (entry.isDirectory()) { + visit(full); + } else if (entry.isSymbolicLink()) { + const stat = fs.statSync(full); + if (stat.isDirectory()) { + visit(full); + } else { + files.push(full); + } + } else if (entry.isFile()) { + files.push(full); + } + } + }; + if (fs.existsSync(root) && fs.statSync(root).isFile()) { + files.push(root); + } else if (fs.existsSync(root)) { + visit(root); + } + return files.sort(); +} + +function hashTrees(roots /*: Array */) /*: string */ { + const hash = crypto.createHash('sha256'); + for (const root of roots) { + const realRoot = fs.realpathSync(root); + for (const file of walkFiles(realRoot)) { + hash.update(path.relative(realRoot, file)); + hash.update('\0'); + hash.update(fs.readFileSync(file)); + hash.update('\0'); + } + } + return hash.digest('hex'); +} + +function frameworkHeaderHashes( + parsed /*: ParsedXcframework */, +) /*: Array */ { + return parsed.slices.map(slice => { + const framework = path.join( + parsed.path, + slice.libraryIdentifier, + slice.libraryPath, + ); + const roots = []; + const headers = path.join(framework, 'Headers'); + const moduleMap = path.join(framework, 'Modules', 'module.modulemap'); + if (fs.existsSync(headers)) { + roots.push(headers); + } + if (fs.existsSync(moduleMap)) { + roots.push(moduleMap); + } + return roots.length === 0 ? '' : hashTrees(roots); + }); +} + +function invariantHeadersHashes( + xcframeworkPath /*: string */, +) /*: Array */ { + const info = plistJson(path.join(xcframeworkPath, 'Info.plist')); + if (!Array.isArray(info.AvailableLibraries)) { + throw new Error(`XCFramework has no libraries: ${xcframeworkPath}`); + } + return info.AvailableLibraries.map(library => { + const identifier = library.LibraryIdentifier; + const headersPath = library.HeadersPath; + if (typeof identifier !== 'string' || typeof headersPath !== 'string') { + throw new Error( + `headers XCFramework has an invalid slice: ${xcframeworkPath}`, + ); + } + return hashTrees([path.join(xcframeworkPath, identifier, headersPath)]); + }); +} + +function assertOneHash( + label /*: string */, + hashes /*: Array */, +) /*: string */ { + const distinct = Array.from(new Set(hashes)); + if (distinct.length !== 1) { + throw new Error(`${label} headers differ between XCFramework slices`); + } + return distinct[0]; +} + +function assertDynamicFramework(parsed /*: ParsedXcframework */) /*: void */ { + for (const slice of parsed.slices) { + const binary = path.join( + parsed.path, + slice.libraryIdentifier, + slice.binaryPath, + ); + if (!fs.existsSync(binary)) { + throw new Error( + `${parsed.frameworkName} binary is missing from slice ${slice.libraryIdentifier}: ${binary}`, + ); + } + let output; + try { + output = execFileSync('otool', ['-l', binary], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (e) { + throw new Error(`could not inspect ${binary}: ${e.message}`); + } + if (!output.includes('LC_ID_DYLIB')) { + throw new Error( + `${parsed.frameworkName} is not a dynamic framework: ${binary}`, + ); + } + const frameworkRoot = path.join( + parsed.path, + slice.libraryIdentifier, + slice.libraryPath, + ); + const nested = walkFiles(frameworkRoot).find(file => + file.includes(`${path.sep}Frameworks${path.sep}`), + ); + if (nested != null) { + throw new Error( + `${parsed.frameworkName} contains nested code that RN cannot manage: ${nested}`, + ); + } + } +} + +function validatePair( + id /*: string */, + debugPath /*: string */, + releasePath /*: string */, + declaredFrameworkName /*: ?string */, + validateBinaries /*: boolean */, +) /*: ValidatedFramework */ { + const debug = parseXcframework(debugPath); + const release = parseXcframework(releasePath); + if ( + debug.frameworkName !== release.frameworkName || + debug.executableName !== release.executableName || + sliceShape(debug) !== sliceShape(release) + ) { + throw new Error( + `flavored framework '${id}' has incompatible Debug and Release XCFrameworks`, + ); + } + if ( + declaredFrameworkName != null && + declaredFrameworkName !== debug.frameworkName + ) { + throw new Error( + `flavored framework '${id}' declared '${declaredFrameworkName}' but contains '${debug.frameworkName}'`, + ); + } + // Headers and Swift module interfaces may legitimately differ between SDK + // slices (for example device vs simulator generated `-Swift.h` files and + // target-specific `.swiftinterface` names). `sliceShape` above already + // guarantees a one-to-one slice mapping, so compare each Debug slice with + // its matching Release slice instead of requiring every slice within one + // XCFramework to be byte-identical. + const debugHeaders = frameworkHeaderHashes(debug); + const releaseHeaders = frameworkHeaderHashes(release); + if (JSON.stringify(debugHeaders) !== JSON.stringify(releaseHeaders)) { + throw new Error( + `flavored framework '${id}' exposes different Debug and Release headers`, + ); + } + if (validateBinaries) { + assertDynamicFramework(debug); + assertDynamicFramework(release); + } + return { + id, + frameworkName: debug.frameworkName, + executableName: debug.executableName, + linkage: 'dynamic', + slices: debug.slices, + }; +} + +function transformReactModuleMap(contents /*: string */) /*: string */ { + if (!/framework\s+module\s+React\b/.test(contents)) { + throw new Error('React module map does not declare framework module React'); + } + return contents + .replace(/framework\s+module\s+React\b/, 'module React') + .replace( + /(umbrella header|textual header|private header|header)\s+"([^"]+)"/g, + (match, kind, header) => + `${kind} "${header.startsWith('React/') ? header : `React/${header}`}"`, + ); +} + +function replaceDirectoryAtomically( + destination /*: string */, + populate /*: (temp: string) => void */, +) /*: void */ { + const temp = `${destination}.tmp-${process.pid}`; + fs.rmSync(temp, {recursive: true, force: true}); + fs.mkdirSync(temp, {recursive: true}); + populate(temp); + fs.rmSync(destination, {recursive: true, force: true}); + fs.renameSync(temp, destination); +} + +function stageReactHeadersTarget( + packageRoot /*: string */, + canonicalReact /*: ParsedXcframework */, +) /*: void */ { + const slice = canonicalReact.slices[0]; + const framework = path.join( + canonicalReact.path, + slice.libraryIdentifier, + slice.libraryPath, + ); + const sourceHeaders = path.join(framework, 'Headers'); + const sourceModuleMap = path.join(framework, 'Modules', 'module.modulemap'); + if (!fs.existsSync(sourceHeaders) || !fs.existsSync(sourceModuleMap)) { + throw new Error( + `React headers or module map missing from canonical framework ${framework}`, + ); + } + const destination = path.join(packageRoot, 'ReactHeadersTarget'); + const transformed = transformReactModuleMap( + fs.readFileSync(sourceModuleMap, 'utf8'), + ); + const expectedHash = crypto + .createHash('sha256') + .update(hashTrees([sourceHeaders])) + .update(transformed) + .digest('hex'); + const marker = path.join(destination, '.headers-hash'); + try { + if (fs.readFileSync(marker, 'utf8') === `${expectedHash}\n`) { + return; + } + } catch {} + replaceDirectoryAtomically(destination, temp => { + const include = path.join(temp, 'include'); + fs.mkdirSync(include, {recursive: true}); + fs.cpSync(sourceHeaders, path.join(include, 'React'), { + recursive: true, + dereference: true, + }); + fs.writeFileSync( + path.join(include, 'module.modulemap'), + transformed, + 'utf8', + ); + fs.writeFileSync( + path.join(temp, 'empty.c'), + 'void RNReactHeadersAnchor(void) {}\n', + 'utf8', + ); + fs.writeFileSync(path.join(temp, '.headers-hash'), `${expectedHash}\n`); + }); +} + +function ensureSymlink(target /*: string */, link /*: string */) /*: void */ { + try { + if (fs.lstatSync(link).isSymbolicLink()) { + const current = path.resolve(path.dirname(link), fs.readlinkSync(link)); + if (current === path.resolve(target)) { + return; + } + } + } catch {} + const temp = `${link}.tmp-${process.pid}`; + fs.rmSync(temp, {recursive: true, force: true}); + fs.mkdirSync(path.dirname(link), {recursive: true}); + fs.symlinkSync(target, temp); + fs.rmSync(link, {recursive: true, force: true}); + fs.renameSync(temp, link); +} + +function symlinkPointsTo( + target /*: string */, + link /*: string */, +) /*: boolean */ { + try { + return ( + fs.lstatSync(link).isSymbolicLink() && + path.resolve(path.dirname(link), fs.readlinkSync(link)) === + path.resolve(target) + ); + } catch { + return false; + } +} + +function sourceIdentity(framework /*: StagedFramework */) /*: { + debug: {path: string, info: number, binaries: Array<{path: string, size: number, mtime: number}>}, + release: {path: string, info: number, binaries: Array<{path: string, size: number, mtime: number}>}, +} */ { + const identity /*: {[string]: {path: string, info: number, binaries: Array<{path: string, size: number, mtime: number}>}} */ = + {}; + for (const flavor of FLAVORS) { + const xcframeworkPath = framework.sourcePaths[flavor]; + identity[flavor] = { + path: fs.realpathSync(xcframeworkPath), + info: fs.statSync(path.join(xcframeworkPath, 'Info.plist')).mtimeMs, + binaries: framework.slices.map(slice => { + const binary = path.join( + xcframeworkPath, + slice.libraryIdentifier, + slice.binaryPath, + ); + const stat = fs.statSync(binary); + return {path: slice.binaryPath, size: stat.size, mtime: stat.mtimeMs}; + }), + }; + } + return { + debug: identity.debug, + release: identity.release, + }; +} + +function publicationIsCurrent( + packageRoot /*: string */, + sources /*: ReadonlyArray */, + debugIndex /*: ArtifactIndex */, + stamp /*: string */, + manifestContents /*: string */, +) /*: boolean */ { + try { + const pendingStamp = path.join(packageRoot, PENDING_ARTIFACT_STAMP); + const currentStamp = fs.existsSync(pendingStamp) + ? pendingStamp + : path.join(packageRoot, '.artifact-stamp'); + if ( + fs.readFileSync(currentStamp, 'utf8') !== stamp || + fs.readFileSync( + path.join(packageRoot, FLAVORED_FRAMEWORKS_MANIFEST), + 'utf8', + ) !== manifestContents || + !fs.existsSync( + path.join(packageRoot, 'ReactHeadersTarget', '.headers-hash'), + ) + ) { + return false; + } + for (const flavor of FLAVORS) { + for (const framework of sources) { + if ( + !symlinkPointsTo( + framework.sourcePaths[flavor], + path.join(packageRoot, flavor, framework.artifactRelativePath), + ) + ) { + return false; + } + } + } + return INVARIANT_BINARY_TARGETS.every(name => + symlinkPointsTo( + debugIndex[name].xcframeworkPath, + path.join(packageRoot, `${name}.xcframework`), + ), + ); + } catch { + return false; + } +} + +function publishDirectoryAtomically( + staged /*: string */, + destination /*: string */, +) /*: void */ { + const previous = `${destination}.previous-${process.pid}`; + fs.rmSync(previous, {recursive: true, force: true}); + const hadPrevious = fs.existsSync(destination); + if (hadPrevious) { + fs.renameSync(destination, previous); + } + try { + fs.renameSync(staged, destination); + } catch (error) { + if (hadPrevious && !fs.existsSync(destination)) { + fs.renameSync(previous, destination); + } + throw error; + } + fs.rmSync(previous, {recursive: true, force: true}); +} + +function writeIfChanged(filePath /*: string */, contents /*: string */) { + try { + if (fs.readFileSync(filePath, 'utf8') === contents) { + return false; + } + } catch {} + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, contents, 'utf8'); + return true; +} + +function readPluginFrameworks( + appRoot /*: string */, +) /*: Array */ { + const manifestPath = path.join(appRoot, PLUGIN_FRAMEWORKS_MANIFEST); + if (!fs.existsSync(manifestPath)) { + return []; + } + const parsed = readJson(manifestPath); + if (!Array.isArray(parsed)) { + throw new Error(`${PLUGIN_FRAMEWORKS_MANIFEST} must contain an array`); + } + const frameworks /*: Array */ = []; + for (const plugin of parsed) { + if ( + plugin == null || + typeof plugin !== 'object' || + typeof plugin.id !== 'string' || + !/^[A-Za-z0-9_.-]+$/.test(plugin.id) || + typeof plugin.frameworkName !== 'string' || + plugin.linkage !== 'dynamic' || + plugin.flavors == null || + typeof plugin.flavors !== 'object' || + typeof plugin.flavors.debug !== 'string' || + typeof plugin.flavors.release !== 'string' + ) { + throw new Error( + 'plugin flavoredFrameworks entries require a stable id, frameworkName, ' + + 'linkage="dynamic", and absolute debug/release paths', + ); + } + frameworks.push({ + id: plugin.id, + frameworkName: plugin.frameworkName, + linkage: 'dynamic', + flavors: { + debug: plugin.flavors.debug, + release: plugin.flavors.release, + }, + }); + } + return frameworks; +} + +function prepareFlavoredFrameworks( + opts /*: { + appRoot: string, + debugArtifactsDir: string, + releaseArtifactsDir: string, + validateBinaries?: boolean, + } */, +) /*: FlavoredFrameworksManifest */ { + const appRoot = path.resolve(opts.appRoot); + const packageRoot = path.join(appRoot, 'build', 'xcframeworks'); + const debugIndex = readArtifactIndex(path.resolve(opts.debugArtifactsDir)); + const releaseIndex = readArtifactIndex( + path.resolve(opts.releaseArtifactsDir), + ); + const validateBinaries = opts.validateBinaries !== false; + const sources /*: Array */ = []; + const destinations /*: Set */ = new Set(); + const frameworkIds /*: Set */ = new Set(); + + for (const builtin of BUILTIN_FRAMEWORKS) { + const debugEntry = debugIndex[builtin.artifactName]; + const releaseEntry = releaseIndex[builtin.artifactName]; + if (debugEntry == null || releaseEntry == null) { + throw new Error( + `both flavors must contain '${builtin.artifactName}' in artifacts.json`, + ); + } + const normalized = validatePair( + builtin.id, + path.resolve(debugEntry.xcframeworkPath), + path.resolve(releaseEntry.xcframeworkPath), + null, + validateBinaries, + ); + const artifactRelativePath = `${builtin.artifactName}.xcframework`; + if (destinations.has(normalized.frameworkName)) { + throw new Error( + `multiple flavored frameworks embed as ${normalized.frameworkName}.framework`, + ); + } + frameworkIds.add(normalized.id); + destinations.add(normalized.frameworkName); + sources.push({ + id: normalized.id, + frameworkName: normalized.frameworkName, + executableName: normalized.executableName, + linkage: 'dynamic', + slices: normalized.slices, + artifactRelativePath, + sourcePaths: { + debug: path.resolve(debugEntry.xcframeworkPath), + release: path.resolve(releaseEntry.xcframeworkPath), + }, + }); + } + + for (const plugin of readPluginFrameworks(appRoot)) { + if ( + plugin == null || + typeof plugin !== 'object' || + typeof plugin.id !== 'string' || + !/^[A-Za-z0-9_.-]+$/.test(plugin.id) || + typeof plugin.frameworkName !== 'string' || + plugin.linkage !== 'dynamic' || + plugin.flavors == null || + typeof plugin.flavors.debug !== 'string' || + typeof plugin.flavors.release !== 'string' || + !path.isAbsolute(plugin.flavors.debug) || + !path.isAbsolute(plugin.flavors.release) + ) { + throw new Error( + 'plugin flavoredFrameworks entries require a stable id, frameworkName, ' + + 'linkage="dynamic", and absolute debug/release paths', + ); + } + const normalized = validatePair( + plugin.id, + plugin.flavors.debug, + plugin.flavors.release, + plugin.frameworkName, + validateBinaries, + ); + if (frameworkIds.has(normalized.id)) { + throw new Error(`duplicate flavored framework id '${normalized.id}'`); + } + if (destinations.has(normalized.frameworkName)) { + throw new Error( + `multiple flavored frameworks embed as ${normalized.frameworkName}.framework`, + ); + } + frameworkIds.add(normalized.id); + destinations.add(normalized.frameworkName); + sources.push({ + id: normalized.id, + frameworkName: normalized.frameworkName, + executableName: normalized.executableName, + linkage: 'dynamic', + slices: normalized.slices, + artifactRelativePath: `plugins/${plugin.id}.xcframework`, + sourcePaths: { + debug: plugin.flavors.debug, + release: plugin.flavors.release, + }, + }); + } + + for (const name of INVARIANT_BINARY_TARGETS) { + const debugEntry = debugIndex[name]; + const releaseEntry = releaseIndex[name]; + if (debugEntry == null || releaseEntry == null) { + throw new Error(`both flavors must contain invariant target '${name}'`); + } + const debugHash = assertOneHash( + `${name} Debug`, + invariantHeadersHashes(debugEntry.xcframeworkPath), + ); + const releaseHash = assertOneHash( + `${name} Release`, + invariantHeadersHashes(releaseEntry.xcframeworkPath), + ); + if (debugHash !== releaseHash) { + throw new Error(`${name} headers differ between Debug and Release`); + } + } + + const canonicalReact = parseXcframework(debugIndex.React.xcframeworkPath); + const manifest /*: FlavoredFrameworksManifest */ = { + version: 1, + frameworks: sources.map(({sourcePaths, ...framework}) => framework), + }; + const contents = `${JSON.stringify(manifest, null, 2)}\n`; + const stampMaterial = JSON.stringify( + sources.map(framework => ({ + id: framework.id, + sourceIdentity: sourceIdentity(framework), + })), + ); + const stamp = `${crypto + .createHash('sha256') + .update(contents) + .update(stampMaterial) + .digest('hex')}\n`; + if (publicationIsCurrent(packageRoot, sources, debugIndex, stamp, contents)) { + return manifest; + } + + const staged = `${packageRoot}.publish-${process.pid}`; + fs.rmSync(staged, {recursive: true, force: true}); + fs.mkdirSync(staged, {recursive: true}); + try { + for (const flavor of FLAVORS) { + for (const framework of sources) { + ensureSymlink( + framework.sourcePaths[flavor], + path.join(staged, flavor, framework.artifactRelativePath), + ); + } + } + for (const name of INVARIANT_BINARY_TARGETS) { + ensureSymlink( + debugIndex[name].xcframeworkPath, + path.join(staged, `${name}.xcframework`), + ); + } + stageReactHeadersTarget(staged, canonicalReact); + writeIfChanged(path.join(staged, FLAVORED_FRAMEWORKS_MANIFEST), contents); + writeIfChanged(path.join(staged, PENDING_ARTIFACT_STAMP), stamp); + const existingPackage = path.join(packageRoot, 'Package.swift'); + if (fs.existsSync(existingPackage)) { + fs.copyFileSync(existingPackage, path.join(staged, 'Package.swift')); + } + const existingStamp = path.join(packageRoot, '.artifact-stamp'); + if (fs.existsSync(existingStamp)) { + fs.copyFileSync(existingStamp, path.join(staged, '.artifact-stamp')); + } + fs.mkdirSync(path.dirname(packageRoot), {recursive: true}); + publishDirectoryAtomically(staged, packageRoot); + } finally { + fs.rmSync(staged, {recursive: true, force: true}); + } + return manifest; +} + +function finalizeArtifactPublication(appRoot /*: string */) /*: boolean */ { + const packageRoot = path.join(path.resolve(appRoot), 'build', 'xcframeworks'); + const pending = path.join(packageRoot, PENDING_ARTIFACT_STAMP); + if (!fs.existsSync(pending)) { + return false; + } + const stamp = fs.readFileSync(pending, 'utf8'); + writeIfChanged(path.join(packageRoot, '.artifact-stamp'), stamp); + fs.rmSync(pending, {force: true}); + return true; +} + +function readFlavoredFrameworksManifest( + appRoot /*: string */, +) /*: FlavoredFrameworksManifest */ { + const manifestPath = path.join( + appRoot, + 'build', + 'xcframeworks', + FLAVORED_FRAMEWORKS_MANIFEST, + ); + if (!fs.existsSync(manifestPath)) { + throw new Error( + `${FLAVORED_FRAMEWORKS_MANIFEST} is missing; run npx react-native spm update`, + ); + } + const parsed = readJson(manifestPath); + if ( + parsed == null || + typeof parsed !== 'object' || + parsed.version !== 1 || + !Array.isArray(parsed.frameworks) + ) { + throw new Error(`${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid format`); + } + const frameworks /*: Array */ = []; + for (const framework of parsed.frameworks) { + if ( + framework == null || + typeof framework !== 'object' || + typeof framework.id !== 'string' || + typeof framework.frameworkName !== 'string' || + typeof framework.executableName !== 'string' || + framework.linkage !== 'dynamic' || + typeof framework.artifactRelativePath !== 'string' || + !Array.isArray(framework.slices) + ) { + throw new Error(`${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid entry`); + } + const id = framework.id; + const frameworkName = framework.frameworkName; + const executableName = framework.executableName; + const artifactRelativePath = framework.artifactRelativePath; + const rawSlices = framework.slices; + const slices /*: Array */ = []; + for (const slice of rawSlices) { + if ( + slice == null || + typeof slice !== 'object' || + typeof slice.sdk !== 'string' || + typeof slice.platform !== 'string' || + (slice.variant != null && typeof slice.variant !== 'string') || + !Array.isArray(slice.architectures) || + typeof slice.libraryIdentifier !== 'string' || + typeof slice.libraryPath !== 'string' || + typeof slice.binaryPath !== 'string' + ) { + throw new Error( + `${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid XCFramework slice`, + ); + } + const architectures /*: Array */ = []; + for (const architecture of slice.architectures) { + if (typeof architecture !== 'string') { + throw new Error( + `${FLAVORED_FRAMEWORKS_MANIFEST} has an invalid XCFramework architecture`, + ); + } + architectures.push(architecture); + } + const sdk = requireString(slice.sdk, 'XCFramework SDK'); + const platform = requireString(slice.platform, 'XCFramework platform'); + const variant = typeof slice.variant === 'string' ? slice.variant : null; + const libraryIdentifier = requireString( + slice.libraryIdentifier, + 'XCFramework library identifier', + ); + const libraryPath = requireString( + slice.libraryPath, + 'XCFramework library path', + ); + const binaryPath = requireString( + slice.binaryPath, + 'XCFramework binary path', + ); + slices.push({ + sdk, + platform, + variant, + architectures, + libraryIdentifier, + libraryPath, + binaryPath, + }); + } + frameworks.push({ + id, + frameworkName, + executableName, + linkage: 'dynamic', + artifactRelativePath, + slices, + }); + } + return {version: 1, frameworks}; +} + +module.exports = { + BUILTIN_FRAMEWORKS, + FLAVORED_FRAMEWORKS_MANIFEST, + PENDING_ARTIFACT_STAMP, + INVARIANT_BINARY_TARGETS, + PLUGIN_FRAMEWORKS_MANIFEST, + parseXcframework, + finalizeArtifactPublication, + prepareFlavoredFrameworks, + readFlavoredFrameworksManifest, + sdkConditionForSlice, + transformReactModuleMap, + validatePair, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking-config.js b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js new file mode 100644 index 000000000000..58ebb96b2f92 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking-config.js @@ -0,0 +1,161 @@ +/** + * 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'; + +/** + * generate-spm-autolinking-config.js — JS port of the autolinking.json + * generation step in packages/react-native/scripts/cocoapods/autolinking.rb. + * + * Invokes the React Native community CLI to produce its config and writes the + * raw JSON to /build/generated/autolinking/autolinking.json. + * + * No filtering or reshaping happens here — the downstream consumer + * (generate-spm-autolinking.js) does its own iOS-only filtering when reading + * the file. + * + * Removes the implicit `pod install` dependency the SPM flow has today for + * external dep discovery. + */ + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: +import type {CliConfigJson} from './spm-types'; + +type CliRunnerResult = { + stdout: string, + stderr: string, + exitCode: number, +}; +type CliRunner = ( + command: Array, + opts: {cwd: string}, +) => CliRunnerResult; +type Options = { + projectRoot: string, + configCommand?: Array, + cliRunner?: CliRunner, +}; +type GenerateAutolinkingConfigResult = { + config: CliConfigJson, + outputPath: string, + rawJson: string, +}; +*/ + +const FALLBACK_CONFIG_COMMAND = [ + 'npx', + '--no-install', + '@react-native-community/cli', + 'config', +]; + +function resolveDefaultConfigCommand( + projectRoot /*: string */, +) /*: Array */ { + try { + const pkgJsonPath = require.resolve( + '@react-native-community/cli/package.json', + {paths: [projectRoot]}, + ); + // $FlowFixMe[unclear-type] package.json has dynamic shape + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const bin = pkgJson.bin; + const binPath = + typeof bin === 'string' + ? bin + : typeof bin?.['rnc-cli'] === 'string' + ? bin['rnc-cli'] + : bin != null && typeof bin === 'object' + ? bin[Object.keys(bin)[0]] + : null; + + if (typeof binPath === 'string' && binPath.length > 0) { + return [ + process.execPath, + path.join(path.dirname(pkgJsonPath), binPath), + 'config', + ]; + } + } catch { + // Fall through to a no-install npx invocation for older layouts. + } + + return FALLBACK_CONFIG_COMMAND; +} + +function defaultCliRunner( + command /*: Array */, + opts /*: {cwd: string} */, +) /*: CliRunnerResult */ { + const result = spawnSync(command[0], command.slice(1), { + cwd: opts.cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + }); + return { + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + exitCode: typeof result.status === 'number' ? result.status : 1, + }; +} + +function generateAutolinkingConfig( + opts /*: Options */, +) /*: GenerateAutolinkingConfigResult */ { + const { + projectRoot, + configCommand = resolveDefaultConfigCommand(projectRoot), + cliRunner = defaultCliRunner, + } = opts; + + if (!Array.isArray(configCommand) || configCommand.length === 0) { + throw new Error( + 'generate-spm-autolinking-config: config command must be a non-empty array of strings', + ); + } + + const result = cliRunner(configCommand, {cwd: projectRoot}); + + if (result.exitCode !== 0) { + throw new Error( + `generate-spm-autolinking-config: '${configCommand.join(' ')}' exited with status ${result.exitCode}\n${result.stderr}`, + ); + } + + const rawJson = result.stdout; + const config /*: CliConfigJson */ = JSON.parse(rawJson); + + const iosSourceDir = config?.project?.ios?.sourceDir; + if (typeof iosSourceDir !== 'string' || iosSourceDir.length === 0) { + throw new Error( + 'generate-spm-autolinking-config: CLI config did not provide project.ios.sourceDir', + ); + } + + const outPath = path.join( + iosSourceDir, + 'build', + 'generated', + 'autolinking', + 'autolinking.json', + ); + + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, rawJson); + + return {config, outputPath: outPath, rawJson}; +} + +module.exports = {generateAutolinkingConfig, resolveDefaultConfigCommand}; diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js new file mode 100644 index 000000000000..29ec69e33939 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -0,0 +1,1888 @@ +/** + * 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'; + +/*:: import type { + AggregatorInput, + AutolinkedDep, + AutolinkingArgs, + DiscoveredPlugin, + NpmDepRef, + PluginFlavoredFramework, + PluginPackageDep, + PluginProductDep, + ReactDescriptor, + RawAutolinkingJson, + SpmModuleConfig, + SpmTarget, + SynthPackageSpec, + TargetEntry, +} from './spm-types'; */ + +/** + * generate-spm-autolinking.js – Generates autolinked/Package.swift, the SPM + * equivalent of CocoaPods' `use_native_modules!`. + * + * Usage: + * node generate-spm-autolinking.js [options] + * + * Options: + * --app-root Path to the app directory (default: cwd) + * --react-native-root Path to react-native package root + * --autolinking-json Path to autolinking.json (default: build/generated/autolinking/autolinking.json) + * --output Output dir (default: autolinked/) + * + * Reads: + * - build/generated/autolinking/autolinking.json (produced by react-native codegen) + * - react-native.config.js (for spm.modules extra modules, optional) + * + * Generates: + * - autolinked/Package.swift + * + * V1 behavior: + * - Processes npm-package native modules with platforms.ios != null from autolinking.json + * - Also processes any `spm.modules` entries from react-native.config.js for local modules + * - Targets resolve React headers via SPM product dependencies (no flags) + * + * V2 behavior (future): + * - npm packages with their own Package.swift use .package(url: ...) instead of inline targets + */ + +const {discoverPlugins, invokePlugins} = require('./autolinking-plugins'); +const { + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, +} = require('./expand-spm-dependencies'); +const {readPodspec} = require('./read-podspec'); +const { + RemoteVersionError, + findProjectRoot, + makeLogger, + remotePackageConfig, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log, warn} = makeLogger('generate-spm-autolinking'); + +// Targets compiling against React get all headers via SPM product +// dependencies — no search-path flags: the React module from the invariant +// ReactHeaders Clang target, every other RN namespace from ReactNativeHeaders +// binaryTarget (pure-RN), the third-party deps namespaces (folly/glog/ +// boost/...) from the ReactNativeDependenciesHeaders sidecar, and the app's +// generated headers from the ReactAppHeaders target in the codegen package. +// +// Remote mode (remotePackageConfig): the ReactNative-family products come +// from the single remote package identity instead of the local path-based +// package, so app + every library unify on one SPM-resolved version. +let remoteCfg /*: ?{url: string, version: string, identity: string} */ = null; + +function reactNativePackageLabel() /*: string */ { + return remoteCfg != null ? remoteCfg.identity : 'ReactNative'; +} +function reactNativePackageDecl(localDecl /*: string */) /*: string */ { + return remoteCfg != null + ? `.package(url: "${remoteCfg.url}", exact: "${remoteCfg.version}")` + : localDecl; +} +// The React product set — single source of truth shared by reactProductDeps() +// (the emitted string) and the plugin ReactDescriptor. The invariant ReactNative +// products come from the RN package (identity varies local vs remote); +// ReactAppHeaders is in the separate, per-app React-GeneratedCode package +// (easy to miss when hand-rolling — hence exposing it to plugins). +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'}, + ]; +} +function reactProductDeps() /*: string */ { + return reactProducts() + .map(p => `.product(name: "${p.name}", package: "${p.package}")`) + .join(', '); +} + +// Structured React descriptor handed to autolinking plugins: how to depend on +// React through one source of truth, so a plugin's own Package.swift doesn't +// re-derive RN's package path / identity / product names. +// +// `packageRef` is local or remote (distinguished by which keys are present): +// - local: {name, path (ABSOLUTE), relPath (relative to outputDir)} +// - remote: {name, url, version} +// The canonical `path` is ABSOLUTE because a plugin may write its manifests in +// arbitrary subdirs of outputDir, for which an outputDir-relative path would be +// wrong; the generated manifests are gitignored + regenerated every sync, so an +// absolute path carries no portability cost. `relPath` is a convenience for the +// common case. `products` derives from the SAME reactProducts() RN wires into +// its own autolinked targets (parity: a plugin's target gets exactly RN's React +// surface), filtered to only products whose package is resolvable this run — +// the invariant is that any listed product can be depended on without guarding. +// Returns null when there is no resolvable React dependency. +function reactDescriptor( + absXcframeworks /*: ?string */, + xcframeworksRelPath /*: ?string */, + codegenPackageExists /*: boolean */, +) /*: ?ReactDescriptor */ { + let packageRef; + if (remoteCfg != null) { + packageRef = { + name: remoteCfg.identity, + url: remoteCfg.url, + version: remoteCfg.version, + }; + } else if (absXcframeworks != null) { + packageRef = { + name: 'ReactNative', + path: toPosix(absXcframeworks), + relPath: + xcframeworksRelPath != null ? toPosix(xcframeworksRelPath) : undefined, + }; + } else { + return null; + } + const products = reactProducts().filter( + p => p.package !== 'React-GeneratedCode' || codegenPackageExists, + ); + return {packageRef, products}; +} + +// Normalize a (possibly Windows) path to posix separators for embedding in +// a Package.swift `.package(path:)` literal — SPM expects forward slashes. +function toPosix(p /*: string */) /*: string */ { + return p.split(path.sep).join('/'); +} + +function parseArgs(argv /*: Array */) /*: AutolinkingArgs */ { + const parsed = yargs(argv) + .option('app-root', { + type: 'string', + default: process.cwd(), + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + describe: 'Path to react-native package root', + }) + .option('autolinking-json', { + type: 'string', + describe: + 'Path to autolinking.json (default: build/generated/autolinking/autolinking.json)', + }) + .option('output', { + type: 'string', + describe: 'Output dir (default: autolinked/)', + }) + .option('xcframeworks-path', { + type: 'string', + describe: + 'Path to the xcframeworks sub-package (absolute or relative to appRoot)', + }) + .usage( + 'Usage: $0 [options]\n\nGenerates autolinked/Package.swift for SPM autolinking.', + ) + .help() + .parseSync(); + + return { + appRoot: parsed['app-root'], + reactNativeRoot: parsed['react-native-root'] ?? null, + autolinkingJson: parsed['autolinking-json'] ?? null, + output: parsed.output ?? null, + xcframeworksPath: parsed['xcframeworks-path'] ?? null, + }; +} + +/** + * Reads autolinking.json and returns dependencies with iOS platform support. + */ +function readAutolinkingJson( + filePath /*: string */, +) /*: RawAutolinkingJson | null */ { + if (!fs.existsSync(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * Attempts to read react-native.config.js to find spm.modules entries. + * These are extra modules not discoverable via autolinking.json. + * + * Expected structure in react-native.config.js: + * module.exports = { + * ... + * spm: { + * modules: [ + * { + * name: "MyNativeModule", + * path: "ios/MyNativeModule", // relative to appRoot + * exclude: ["*.js", "*.podspec"], // optional + * publicHeadersPath: ".", // optional + * } + * ] + * } + * } + */ +function readSpmModulesFromConfig( + appRoot /*: string */, +) /*: Array */ { + const configPath = path.join(appRoot, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return []; + } + try { + // $FlowFixMe[unsupported-syntax] dynamic require by computed path + const config = require(configPath); + return config.spm?.modules ?? []; + } catch (e) { + // Config might use Ruby interop or other patterns – skip + return []; + } +} + +/** + * 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 + * framework's plugin OUT); no allowlist is required. + */ +function readDenyPluginsFromConfig(appRoot /*: string */) /*: Array */ { + const configPath = path.join(appRoot, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + return []; + } + try { + // $FlowFixMe[unsupported-syntax] dynamic require by computed path + const config = require(configPath); + return config.spm?.denyPlugins ?? []; + } catch (e) { + return []; + } +} + +/** + * Returns "." if the source directory has .h/.hpp files directly at its root + * AND no subdirectories exist at that root (adjacent subdirectories would cause + * Clang to reject the umbrella header). + * Returns null otherwise. + */ +function inferPublicHeadersPath(sourcePath /*: string */) /*: string | null */ { + if (!fs.existsSync(sourcePath)) return null; + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(sourcePath, {withFileTypes: true}); + const hasHeaders = entries.some( + e => + (e.isFile() || e.isSymbolicLink()) && + (e.name.endsWith('.h') || e.name.endsWith('.hpp')), + ); + const hasSubdirs = entries.some(e => e.isDirectory()); + // Only use "." if headers at root AND no adjacent subdirectories. + // If both headers and subdirectories exist, Clang rejects the module map + // (umbrella header + adjacent directories = error). + return hasHeaders && !hasSubdirs ? '.' : null; +} + +/*:: +type ExtensionFilter = ReadonlySet; +*/ + +const HEADER_EXTENSIONS /*: ExtensionFilter */ = new Set(['.h', '.hpp']); +const IMPL_EXTENSIONS /*: ExtensionFilter */ = new Set([ + '.m', + '.mm', + '.c', + '.cpp', + '.swift', +]); +const ALL_SOURCE_EXTENSIONS /*: ExtensionFilter */ = new Set([ + ...HEADER_EXTENSIONS, + ...IMPL_EXTENSIONS, +]); + +// Directory names whose contents should never be included in an SPM target — +// test fixtures, Android sources, vendored modules. Shared between the source +// walker and the header linker so they agree on what to skip. +const SKIP_DIRS_DEFAULT /*: ReadonlySet */ = new Set([ + 'android', + 'tests', + '__tests__', + '__mocks__', + 'test', + 'jest', + 'node_modules', +]); + +// Name of the dir-symlink inside each wrapper that points at the dep's real +// source dir. With target.path = "." (the wrapper), SPM resolves source paths +// like "/Foo.mm" by following this link. +const WRAPPER_ROOT_NAME = 'root'; + +// The config-gated C++ defines the prebuilt React.framework is built with (see +// packages/react-native/Package.swift). Autolinked Fabric C++ must compile with +// the same NDEBUG state, else DebugStringConvertible's vtable / ShadowNode +// layout diverges from a Release React.framework and the link fails. +const REACT_CXX_CONFIG_DEFINES = [ + '.define("DEBUG", .when(configuration: .debug))', + '.define("NDEBUG", .when(configuration: .release))', +]; + +// Marker the autolinker stamps onto every synth Package.swift it writes. +// Files lacking this marker are treated as user-managed (self-managed) and +// are referenced directly rather than wrapped — see findSelfManagedPackageDir. +const AUTOGEN_MARKER = + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js'; + +/** + * A dep is "self-managed" when it ships a hand-written Package.swift + * (i.e. one that lacks our AUTOGEN_MARKER). The autolinker skips wrapping + * it and references the manifest's directory directly — useful for + * libraries that want to ship a real SPM manifest and have full control + * over their target settings. + * + * Two layouts are recognized: + * 1. /Package.swift — manifest at the npm-package root + * 2. /ios/Package.swift — manifest co-located with ObjC + * sources, keeping the npm-package + * root free of SPM artifacts + * (.build/, .swiftpm/, Package.resolved) + * + * Returns the directory that contains the hand-authored manifest, or null + * when no candidate exists. That directory is what the aggregator hands to + * SPM as `.package(path:)` — for layout 2 that means `/ios`. + */ +function findSelfManagedPackageDir(absSource /*: string */) /*: ?string */ { + for (const sub of ['', 'ios']) { + const dir = sub === '' ? absSource : path.join(absSource, sub); + try { + const content = fs.readFileSync(path.join(dir, 'Package.swift'), 'utf8'); + if (!content.includes(AUTOGEN_MARKER)) { + return dir; + } + } catch { + // candidate does not exist; try the next one + } + } + return null; +} + +/** + * Does this dep ship a CocoaPods podspec? (Checked at the dep root and under + * ios/.) A missing manifest is auto-scaffoldable only when a podspec exists — + * the scaffolder translates the podspec into a Package.swift. + */ +function hasPodspec(absSource /*: string */) /*: boolean */ { + for (const sub of ['', 'ios']) { + const dir = sub === '' ? absSource : path.join(absSource, sub); + try { + if ( + fs + .readdirSync(dir) + .some(e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-')) + ) { + return true; + } + } catch { + // dir does not exist; try the next candidate + } + } + return false; +} + +/** + * True when a dep has BOTH Swift and C-family (.m/.mm/.c/.cpp) sources. SPM + * cannot compile mixed-language sources in a single target, and RN libs that + * mix them are typically bidirectionally coupled (ObjC↔Swift) — which can't be + * split into two targets either (it would be a circular dependency). So such a + * dep is unsupportable by the scaffolder; we surface a clear, distinct error + * instead of emitting a manifest that fails with a cryptic SPM resolve error. + * Heuristic filesystem scan (bounded depth; skips examples/tests/build noise). + */ +function hasMixedLanguageSources(absSource /*: string */) /*: boolean */ { + const SKIP /*: Set */ = new Set([ + 'node_modules', + 'Pods', + 'build', + '.git', + '__tests__', + 'example', + 'Example', + 'examples', + ]); + let hasSwift = false; + let hasClang = false; + const walk = (dir /*: string */, depth /*: number */) => { + if (depth > 6 || (hasSwift && hasClang)) return; + let entries: Array<{name: string, isDirectory(): boolean}>; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(dir, {withFileTypes: true}); + } catch { + return; + } + for (const e of entries) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs + const name /*: string */ = e.name; + if (e.isDirectory()) { + if (!name.startsWith('.') && !SKIP.has(name)) { + walk(path.join(dir, name), depth + 1); + } + } else if (/\.swift$/i.test(name)) { + hasSwift = true; + } else if (/\.(mm?|c|cc|cpp|cxx)$/i.test(name)) { + hasClang = true; + } + if (hasSwift && hasClang) return; + } + }; + walk(absSource, 0); + return hasSwift && hasClang; +} + +/** + * Error thrown when one or more autolinked community npm deps have no Swift + * Package Manager manifest (neither a shipped Package.swift nor a scaffolded + * one). The autolinker no longer silently synthesizes a manifest for these — + * that hid the gap and duplicated the scaffolder. Carries the dep list so the + * CLI can surface a precise, actionable message and set a distinct exit code + * (the Xcode build phase keys off it to fail the build). + */ +class MissingManifestError extends Error { + /*:: missingManifests: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}>; */ + constructor( + deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */, + ) { + super( + `${deps.length} autolinked native module(s) have no Package.swift. ` + + 'Run `npx react-native spm scaffold` to generate them.', + ); + this.name = 'MissingManifestError'; + this.missingManifests = deps; + } +} + +/** + * Prints one `error:`-prefixed line per missing-manifest dep so Xcode surfaces + * each as a build error (Xcode parses lines beginning with `error: `), then + * returns the MissingManifestError to throw. Kept together so the message and + * the thrown error never drift. + */ +function reportMissingManifests( + deps /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */, +) /*: MissingManifestError */ { + for (const d of deps) { + if (d.mixed === true) { + console.error( + `error: "${d.npmName}" has mixed Swift + Objective-C/C++ sources, which Swift Package Manager cannot compile in a single target (and its Swift↔ObjC interop typically can't be split into two targets without a circular dependency).\n` + + ` • Opt it out of SPM autolinking in your app's react-native.config.js:\n` + + ` module.exports = { dependencies: { '${d.npmName}': { platforms: { ios: null } } } };\n` + + ` • Or consume ${d.npmName} as a prebuilt binary (xcframework) instead.`, + ); + continue; + } + if (d.hasPodspec) { + console.error( + `error: Package.swift is missing for library "${d.npmName}" — it ships no Swift Package Manager support.\n` + + ` 1. Run \`npx react-native spm scaffold\` to generate a Package.swift for ${d.npmName}.\n` + + ` 2. Persist it with a patch: \`npx patch-package ${d.npmName}\`, and commit the patch (node_modules is not committed).\n` + + ` 3. Ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).\n` + + ' 4. Without a committed patch, this same error returns whenever node_modules is reset (fresh install / CI).', + ); + } else { + console.error( + `error: Package.swift is missing for library "${d.npmName}", and it ships no podspec so it cannot be scaffolded automatically.\n` + + ` • It needs Swift Package Manager support added manually — ask ${d.npmName}'s maintainer to ship a Package.swift upstream (or contribute one).`, + ); + } + } + return new MissingManifestError(deps); +} + +/** + * Mirrors every header file under `srcDir` as a relative symlink at the same + * relative location under `destDir`. Used for the centralized cross-package + * headers tree at `/headers//` so consumers can resolve + * `#import ` via a single `-I /headers` flag. + * + * Idempotent: existing symlinks pointing at the right target are left alone; + * stale entries are pruned. Header symlinks here are inert to Xcode (it + * doesn't navigate them as editable source — they're compiler-only). + */ +function linkHeaderTree( + srcDir /*: string */, + destDir /*: string */, + skipDirNames /*: Set */ = new Set(), +) /*: void */ { + if (!srcDir || !path.isAbsolute(srcDir)) { + throw new Error( + `linkHeaderTree: srcDir must be a non-empty absolute path, got: "${srcDir}"`, + ); + } + if (!destDir || !path.isAbsolute(destDir)) { + throw new Error( + `linkHeaderTree: destDir must be a non-empty absolute path, got: "${destDir}"`, + ); + } + if (!fs.existsSync(srcDir)) { + return; + } + + /*:: type HeaderEntry = {relSrc: string, absSrc: string}; */ + const headers /*: Array */ = []; + function collect(dir /*: string */, relBase /*: string */) /*: void */ { + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const {name} = entry; + if (entry.isDirectory()) { + if (SKIP_DIRS_DEFAULT.has(name) || skipDirNames.has(name)) continue; + collect(path.join(dir, name), path.join(relBase, name)); + } else if (entry.isFile() && HEADER_EXTENSIONS.has(path.extname(name))) { + headers.push({ + relSrc: path.join(relBase, name), + absSrc: path.join(dir, name), + }); + } + } + } + collect(srcDir, ''); + + if (headers.length === 0) { + try { + if (fs.lstatSync(destDir).isDirectory()) { + fs.rmSync(destDir, {recursive: true, force: true}); + } + } catch { + // destDir does not exist – fine + } + return; + } + + fs.mkdirSync(destDir, {recursive: true}); + + const expected /*: Set */ = new Set(); + for (const {relSrc, absSrc} of headers) { + const linkPath = path.join(destDir, relSrc); + expected.add(relSrc); + fs.mkdirSync(path.dirname(linkPath), {recursive: true}); + const desiredTarget = path.relative(path.dirname(linkPath), absSrc); + try { + const existing = fs.lstatSync(linkPath); + if ( + existing.isSymbolicLink() && + fs.readlinkSync(linkPath) === desiredTarget + ) { + continue; + } + fs.unlinkSync(linkPath); + } catch { + // nothing to remove + } + fs.symlinkSync(desiredTarget, linkPath); + } + + // Prune stale entries: walk destDir and delete anything not in `expected`. + function pruneWalk(dir /*: string */, relBase /*: string */) /*: void */ { + if (!fs.existsSync(dir)) return; + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const rel = path.join(relBase, entry.name); + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + pruneWalk(abs, rel); + if (fs.readdirSync(abs).length === 0) { + fs.rmdirSync(abs); + } + } else { + if (!expected.has(rel)) { + fs.unlinkSync(abs); + } + } + } + } + pruneWalk(destDir, ''); +} + +/** + * Searches sourcePath for a PrivacyInfo.xcprivacy file (at root or one level deep). + * Returns the relative path from sourcePath if found, null otherwise. + */ +function findPrivacyManifest(sourcePath /*: string */) /*: string | null */ { + if (!fs.existsSync(sourcePath)) return null; + // Check root level + if (fs.existsSync(path.join(sourcePath, 'PrivacyInfo.xcprivacy'))) { + return 'PrivacyInfo.xcprivacy'; + } + // Check one level deep (e.g. ios/PrivacyInfo.xcprivacy) + const entries /*: Array<{name: string, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(sourcePath, {withFileTypes: true}); + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = path.join(sourcePath, entry.name, 'PrivacyInfo.xcprivacy'); + if (fs.existsSync(nested)) { + return path.join(entry.name, 'PrivacyInfo.xcprivacy'); + } + } + } + return null; +} + +/** + * Recursively yields forward-slash paths (relative to sourcePath) for every + * regular file under sourcePath, skipping directories whose name is in + * SKIP_DIRS_DEFAULT. Used as the building block for both the auto-discovery + * (collectSpmSources) and explicit-glob (expandSpmSourceGlobs) paths so they + * agree on what's a candidate before extension/glob filtering applies. + */ +function walkSourceFiles(sourcePath /*: string */) /*: Array */ { + const out /*: Array */ = []; + if (!fs.existsSync(sourcePath)) { + return out; + } + function walk(dir /*: string */, rel /*: string */) /*: void */ { + const entries /*: Array<{name: string, isDirectory(): boolean, isFile(): boolean, isSymbolicLink(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of entries) { + const {name} = entry; + const childRel = rel === '' ? name : `${rel}/${name}`; + if (entry.isDirectory()) { + if (SKIP_DIRS_DEFAULT.has(name)) continue; + walk(path.join(dir, name), childRel); + } else if (entry.isFile() || entry.isSymbolicLink()) { + out.push(childRel); + } + } + } + walk(sourcePath, ''); + return out; +} + +/** + * Idempotent symlink: ensure `linkPath` is a symlink to `target`. If it + * already is, leave it untouched (preserves inode). If it points elsewhere + * or is a real file/directory, replace it. Returns true when the symlink + * was created or replaced, false when it was already correct. + */ +function ensureSymlink( + linkPath /*: string */, + target /*: string */, +) /*: boolean */ { + try { + const stat = fs.lstatSync(linkPath); + if (stat.isSymbolicLink() && fs.readlinkSync(linkPath) === target) { + return false; + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + fs.unlinkSync(linkPath); + } else { + fs.rmSync(linkPath, {recursive: true, force: true}); + } + } catch { + // linkPath does not exist – fine + } + fs.symlinkSync(target, linkPath); + return true; +} + +// Default sources allowlist when no explicit glob is provided — analog of +// CocoaPods' `s.source_files` auto-discovery. +function collectSpmSources(sourcePath /*: string */) /*: Array */ { + return walkSourceFiles(sourcePath) + .filter(p => ALL_SOURCE_EXTENSIONS.has(path.extname(p))) + .sort(); +} + +// Filters walkSourceFiles output through CocoaPods-style globs via micromatch. +// Skip-dir filtering applies before matching, so `**/*.{h,mm}` never returns +// paths under `tests/`, `android/`, etc. — even if the pattern would match. +function expandSpmSourceGlobs( + sourcePath /*: string */, + patterns /*: Array */, +) /*: Array */ { + if (patterns.length === 0) { + return []; + } + // $FlowFixMe[untyped-import] micromatch ships no types + const micromatch = require('micromatch'); + return micromatch(walkSourceFiles(sourcePath), patterns).sort(); +} + +/** + * Converts an autolinking.json dependency to an SPM target spec. + * 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. + */ +/** + * Read the dep's podspec (if any) and extract its declared + * `pod_target_xcconfig` HEADER_SEARCH_PATHS, substituted relative to the dep + * source dir. Returns paths suitable for an SPM `.headerSearchPath()` + * directive whose target.path is the dep root (entries are NOT yet prefixed + * with the synth wrapper's `root/` — the emission site adds that prefix + * because the wrapper's target.path is `.`, not the source dir). + * + * Without these, path-style angle includes like + * `` (used by + * react-native-safe-area-context, reanimated, screens, etc.) fail to resolve + * because the headers live under the dep's `common/cpp/` rather than under + * the framework-imported xcframework headers. + */ +function extractPodspecHeaderSearchPaths( + sourceDir /*: string */, +) /*: Array */ { + let podspecPath /*: ?string */ = null; + try { + const entries = fs.readdirSync(sourceDir); + // Skip a crashed run's leftover `.spm-scaffold--.podspec` copy. + const candidate = entries.find( + e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-'), + ); + if (candidate != null) { + podspecPath = path.join(sourceDir, candidate); + } + } catch { + return []; + } + if (podspecPath == null) return []; + + let model; + try { + model = readPodspec(podspecPath); + } catch { + return []; + } + + const out /*: Array */ = []; + for (const raw of model.headerSearchPaths) { + const substituted = raw + .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.') + .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.'); + // Drop entries still containing unresolved Xcode tokens — emitting them + // verbatim would surface as clang "no such file" failures. + if (/\$[({]/.test(substituted)) continue; + const cleaned = substituted.replace(/^\.\//, '').replace(/^\//, ''); + if (cleaned.length > 0 && !out.includes(cleaned)) { + out.push(cleaned); + } + } + return out; +} + +function autolinkingDepToSpmTarget( + depName /*: string */, + dep /*: AutolinkedDep */, + outputDir /*: string */, + swiftNameByNpm /*: ?Map */, +) /*: SpmTarget | null */ { + const iosPlatform = dep.platforms.ios; + const sourceDir = iosPlatform.sourceDir ?? dep.root; + if (sourceDir == null) { + return null; + } + + // target.path is stored relative to the autolinker's outputDir so main()'s + // `path.resolve(outputDir, target.path)` recovers the absolute source dir — + // 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); + + // No exclude inference — main()'s emission loop emits `sources:` (an + // explicit allowlist). User-supplied excludes still work. + + // Detect PrivacyInfo.xcprivacy + const privacyManifest = findPrivacyManifest(sourceDir); + 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. + const spmDeps /*: Array */ = dep.spmDependencies ?? []; + const spmTargetDependencies = + spmDeps.length > 0 + ? spmDeps.map(n => swiftNameByNpm?.get(n) ?? toSwiftName(n)) + : undefined; + + const headerSearchPaths = extractPodspecHeaderSearchPaths(sourceDir); + + return { + name: targetName, + path: relSourcePath, + exclude: [], + publicHeadersPath: inferPublicHeadersPath(sourceDir), + resources, + spmTargetDependencies, + headerSearchPaths: + headerSearchPaths.length > 0 ? headerSearchPaths : undefined, + }; +} + +/** + * Generates the full autolinked/Package.swift content. + * + * xcframeworksRelPath – path to the xcframeworks sub-package relative to the + * autolinked/ directory (e.g. "../build/xcframeworks"). When non-null a + * React dependency is declared. Headers need no search paths — React/react + * come from the ReactHeaders target, every other RN namespace from + * ReactNativeHeaders, folly/glog/boost from ReactNativeDependenciesHeaders, + * and the app's generated headers from the ReactAppHeaders product — so + * , , , folly/glog/boost, + * and all resolve. + */ +/** + * Top-level autolinked/Package.swift — a thin aggregator that references each + * autolinked dep as its own sub-package (under packages/) and + * re-exports them through a single AutolinkedAggregate target. Per-dep + * settings (header paths, cFlags, link order) live in each synth sub-package; + * see generateSynthPackageSwift below. + * + * input: { deps: Array<{swiftName: string}> } + */ +function generateAutolinkedPackageSwift( + input /*: AggregatorInput */, +) /*: string */ { + const npmDeps /*: ReadonlyArray */ = input.npmDeps ?? []; + const inlineTargets /*: ReadonlyArray */ = + input.inlineTargets ?? []; + const hasReactDep /*: boolean */ = input.hasReactDep !== false; + // Relative path from autolinked/ to build/xcframeworks/, e.g. "../build/xcframeworks". + const xcframeworksRelPath /*: ?string */ = input.xcframeworksRelPath; + // Autolinking-plugin contributions (Expo & other frameworks). + const pluginPackageDeps /*: ReadonlyArray */ = + input.pluginPackageDeps ?? []; + const pluginProductDeps /*: ReadonlyArray */ = + input.pluginProductDeps ?? []; + + // Package-level dependencies: one .package(path:) per autolinked dep, + // plus ReactNative if any inline target needs to import React headers. + const packageDeps /*: Array */ = npmDeps.map(d => { + const pkgPath = d.packagePath ?? `packages/${d.swiftName}`; + return `.package(name: "${d.swiftName}", path: "${pkgPath}")`; + }); + // Framework plugin packages: local (path) or remote (url + exact version). + for (const p of pluginPackageDeps) { + packageDeps.push( + p.path != null + ? `.package(name: "${p.name}", path: "${p.path}")` + : `.package(url: "${p.url ?? ''}", exact: "${p.version ?? ''}")`, + ); + } + if ( + inlineTargets.length > 0 && + hasReactDep && + typeof xcframeworksRelPath === 'string' + ) { + packageDeps.push( + reactNativePackageDecl( + `.package(name: "ReactNative", 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")`); + } + + // AutolinkedAggregate's target dependencies: .product(...) for npm sub-package + // products and .target(...) for inline spmModule targets in the same package. + const aggregateDeps /*: Array */ = [ + ...npmDeps.map( + d => `.product(name: "${d.swiftName}", package: "${d.swiftName}")`, + ), + ...inlineTargets.map(t => `.target(name: "${t.name}")`), + ...pluginProductDeps.map( + p => `.product(name: "${p.name}", package: "${p.package}")`, + ), + ]; + + const inlineDecls = inlineTargets.map(t => { + const excludeLine = + t.exclude && t.exclude.length > 0 + ? `\n exclude: [${t.exclude.map(e => `"${e}"`).join(', ')}],` + : ''; + const publicHeadersLine = + t.publicHeadersPath != null + ? `\n publicHeadersPath: "${t.publicHeadersPath}",` + : ''; + const resourcesLine = + t.resources && t.resources.length > 0 + ? `\n resources: [${t.resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + return ` .target( + name: "${t.name}", + dependencies: [${reactProductDeps()}], + path: "${t.path}",${excludeLine}${publicHeadersLine}${resourcesLine} + cxxSettings: [${REACT_CXX_CONFIG_DEFINES.join(', ')}], + linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] + )`; + }); + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + const aggregateDepsLine = + aggregateDeps.length > 0 + ? `\n dependencies: [${aggregateDeps.join(', ')}],` + : ''; + + const inlineDeclsBlock = + inlineDecls.length > 0 ? `,\n${inlineDecls.join(',\n')}` : ''; + + // Eval-time missing-manifest guard. SwiftPM resolves the package graph BEFORE + // the Xcode "Sync SPM Autolinking" build phase runs, so a community library + // whose Package.swift is absent at resolution time (e.g. a scaffolded manifest + // wiped by a node_modules reset without a committed patch) fails resolution + // with an opaque "package manifest cannot be accessed" error — and the + // actionable sync-phase message never prints. Manifest evaluation may READ the + // filesystem (only writes are sandboxed), so the aggregator checks each + // referenced library here and explains the cause + fix at resolution time. + const guardEntries = npmDeps.map(d => { + const pkgPath = d.packagePath ?? `packages/${d.swiftName}`; + return ` (path: "${pkgPath}", npm: "${d.npmName ?? d.swiftName}")`; + }); + const guardBlock = + guardEntries.length > 0 + ? `// Eval-time guard: surface a wiped/absent library Package.swift here (at +// resolution) instead of the opaque SwiftPM "manifest cannot be accessed". +let __rnAutolinkedLibs: [(path: String, npm: String)] = [ +${guardEntries.join(',\n')}, +] +do { + let __here = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let __missing = __rnAutolinkedLibs.filter { + !FileManager.default.fileExists( + atPath: __here.appendingPathComponent($0.path) + .appendingPathComponent("Package.swift").path) + } + if !__missing.isEmpty { + var __msg = "" + for lib in __missing { + __msg += "error: Package.swift is missing for library \\"\\(lib.npm)\\" — its Swift Package Manager manifest is not present (a scaffolded manifest wiped by a node_modules reset without a committed patch, or the library ships none).\\n" + __msg += " 1. Run \`npx react-native spm scaffold\` to (re)generate it.\\n" + __msg += " 2. Persist it with \`npx patch-package \\(lib.npm)\` and commit the patch (node_modules is not committed).\\n" + __msg += " 3. Ask \\(lib.npm)'s maintainer to ship a Package.swift upstream.\\n" + __msg += " 4. Without a committed patch, this error returns on every fresh install / CI.\\n" + } + FileHandle.standardError.write(Data(__msg.utf8)) + fatalError("Missing Package.swift for: \\(__missing.map { $0.npm }.joined(separator: ", ")). See the message above.") + } +} + +` + : ''; + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually. +// Top-level Autolinked package. Every autolinked dep (npm or spmModule) is +// referenced as .package(path: ) — each has its own synth +// Package.swift written in-place. AutolinkedAggregate depends on every dep's +// product so the app build pulls them all in. + +import PackageDescription +import Foundation + +${guardBlock}let package = Package( + name: "Autolinked", + platforms: [.iOS(.v15)], + products: [ + .library(name: "Autolinked", targets: ["AutolinkedAggregate"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "AutolinkedAggregate",${aggregateDepsLine} + path: "AutolinkedAggregate" + )${inlineDeclsBlock} + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +/** + * Per-dep synthesized Package.swift, written at + * /packages//Package.swift. `targetPath` points at a + * `root` directory symlink to the real source dir, so source files stay real + * (Xcode atomic-save works). + * + * The React + codegen package references are plain relative paths supplied by + * the caller (`reactNativePackagePath` / `codegenPackagePath`), computed from + * the synth's fixed location under the autolinking dir — the manifest holds no + * runtime discovery and no absolute paths. Headers are served by the + * ReactHeaders/ReactNativeHeaders/ReactNativeDependenciesHeaders targets and + * the ReactAppHeaders product, so no + * search-path flags are needed. Siblings use their absolute synth path from + * `siblingSynthAbsolutePaths` (production) or a `siblingPackageBaseRelative` + * fallback (tests). + */ +function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ { + const swiftName /*: string */ = spec.swiftName; + const exclude /*: Array */ = spec.exclude ?? []; + const sources /*: ?Array */ = spec.sources; + const publicHeadersPath /*: ?string */ = spec.publicHeadersPath ?? null; + // Per-dep header search paths from the podspec's + // pod_target_xcconfig HEADER_SEARCH_PATHS — already prefixed by the caller + // with the synth wrapper's `root/` so they resolve relative to target.path. + // Emitted as `.headerSearchPath()` directives, which SPM accepts on + // cSettings / cxxSettings without needing absolute paths. + const headerSearchPaths /*: Array */ = spec.headerSearchPaths ?? []; + const spmDependencies /*: Array<{swiftName: string}> */ = + spec.spmDependencies ?? []; + const hasReactDep /*: boolean */ = spec.hasReactDep !== false; + const resources /*: ?Array */ = spec.resources; + const isDynamic /*: boolean */ = spec.isDynamic !== false; + const targetPath /*: string */ = spec.targetPath ?? `Sources/${swiftName}`; + const siblingSynthAbsolutePaths /*: {[string]: string} */ = + spec.siblingSynthAbsolutePaths ?? {}; + + // Package dependencies — ReactNative + each spm sibling synth package. + // The React + codegen package paths are plain relative strings computed by + // the caller at generation time (the synth always lives at a fixed depth + // under the autolinking dir, and is regenerated on every `react-native + // spm` run), so the manifest holds no runtime discovery. Siblings use their + // absolute synth path when the caller provides one (production); else a + // relative fallback. + const packageDeps /*: Array */ = []; + if (hasReactDep) { + const reactNativePackagePath /*: string */ = + spec.reactNativePackagePath ?? '../../../../xcframeworks'; + const codegenPackagePath /*: string */ = + spec.codegenPackagePath ?? '../../../ios'; + packageDeps.push( + reactNativePackageDecl( + `.package(name: "ReactNative", path: "${reactNativePackagePath}")`, + ), + ); + // Per-app generated headers come from the ReactAppHeaders product in + // the codegen package. + packageDeps.push( + `.package(name: "React-GeneratedCode", path: "${codegenPackagePath}")`, + ); + } + for (const dep of spmDependencies) { + const absPath = siblingSynthAbsolutePaths[dep.swiftName]; + if (absPath != null) { + packageDeps.push( + `.package(name: "${dep.swiftName}", path: "${absPath}")`, + ); + } else { + const siblingRel /*: string */ = spec.siblingPackageBaseRelative ?? '..'; + packageDeps.push( + `.package(name: "${dep.swiftName}", path: "${siblingRel}/${dep.swiftName}")`, + ); + } + } + + // Target dependencies — products from each declared package dep. + const targetDeps /*: Array */ = []; + if (hasReactDep) { + targetDeps.push(reactProductDeps()); + } + for (const dep of spmDependencies) { + targetDeps.push( + `.product(name: "${dep.swiftName}", package: "${dep.swiftName}")`, + ); + } + + const excludeLine = + exclude.length > 0 + ? `\n exclude: [${exclude.map(e => `"${e}"`).join(', ')}],` + : ''; + // sources: explicit allowlist. One file per line because lists can run to + // dozens of entries and an unbroken array becomes unreadable in diffs. + const sourcesLine = + sources != null && sources.length > 0 + ? `\n sources: [\n${sources.map(s => ` "${s}",`).join('\n')}\n ],` + : ''; + const publicHeadersLine = + publicHeadersPath != null + ? `\n publicHeadersPath: "${publicHeadersPath}",` + : ''; + const resourcesLine = + resources != null && resources.length > 0 + ? `\n resources: [${resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + // `.headerSearchPath(...)` entries from the podspec — first-class + // directives keep SPM's diagnostics meaningful (clang reports the + // dep-relative path on miss). React headers need no paths at all. + const headerSearchPathList = headerSearchPaths.map( + p => `.headerSearchPath("${p}")`, + ); + const cSettingsLine = + headerSearchPathList.length > 0 + ? `\n cSettings: [${headerSearchPathList.join(', ')}],` + : ''; + // ALWAYS emit the config-gated C++ defines (even with no header paths): a + // Fabric C++ target must compile with the same NDEBUG state as the prebuilt + // React.framework, else DebugStringConvertible's vtable / ShadowNode layout + // diverges and a Release build fails to link. Mirrors Package.swift. + const cxxEntries = [...REACT_CXX_CONFIG_DEFINES, ...headerSearchPathList]; + const cxxSettingsLine = `\n cxxSettings: [${cxxEntries.join(', ')}],`; + + return `// swift-tools-version: 6.0 +// AUTO-GENERATED by scripts/generate-spm-autolinking.js – do not edit manually. +// Synth Package.swift for autolinked dep "${swiftName}". + +import PackageDescription + +let package = Package( + name: "${swiftName}", + platforms: [.iOS(.v15)], + products: [ + .library(name: "${swiftName}"${isDynamic ? ', type: .dynamic' : ''}, targets: ["${swiftName}"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "${swiftName}", + dependencies: [${targetDeps.join(', ')}], + path: "${targetPath}",${excludeLine}${sourcesLine}${publicHeadersLine}${resourcesLine}${cSettingsLine}${cxxSettingsLine} + linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] + ), + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +function main(argv /*:: ?: Array */) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + // Resolve to absolute so path.join() produces absolute paths everywhere — + // entryAbsDirs, the headers farm, etc. all assume an absolute appRoot. + const appRoot = path.resolve(args.appRoot); + remoteCfg = remotePackageConfig(appRoot); + if (remoteCfg != null) { + log(`Remote ReactNative package: ${remoteCfg.url} @ ${remoteCfg.version}`); + } + + let rnRoot = args.reactNativeRoot; + if (rnRoot == null) { + rnRoot = path.join(appRoot, 'node_modules', 'react-native'); + if (!fs.existsSync(rnRoot)) { + // Monorepo: try walking up + let dir = appRoot; + for (let i = 0; i < 5; i++) { + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + const c = path.join(dir, 'node_modules', 'react-native'); + if (fs.existsSync(c)) { + rnRoot = c; + break; + } + } + } + if (rnRoot == null || !fs.existsSync(rnRoot)) { + console.error( + '[generate-spm-autolinking] Could not find react-native. Pass --react-native-root.', + ); + process.exitCode = 1; + return; + } + } + + const autolinkingJsonPath = + args.autolinkingJson ?? + path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); + + // Output lands under /build/generated/autolinking/ — co-located + // with autolinking.json (written by generate-spm-autolinking-config.js) and + // alongside the iOS-conventional build/ tree (Pods/, build/xcframeworks/…). + const outputDir = + args.output != null + ? path.resolve(appRoot, args.output) + : path.join(appRoot, 'build', 'generated', 'autolinking'); + + // Collect all targets along with their routing metadata. + const entries /*: Array */ = []; + // Autolinking plugins discovered from deps' react-native.config.js + // (populated during the dep walk below; invoked before the aggregator write). + let discoveredPlugins /*: Array */ = []; + + // 1. From autolinking.json (npm packages with iOS native modules), expanded + // with transitive deps declared via `spm.dependencies` in each package's + // react-native.config.js (analog of podspec `s.dependency`). + const autolinkingData = readAutolinkingJson(autolinkingJsonPath); + const depsMap = autolinkingData?.dependencies; + if (depsMap != null) { + // Narrow each on-disk AutolinkingDepJson into the validated AutolinkedDep + // shape expected by expandSpmDependencies and autolinkingDepToSpmTarget. + const directDeps /*: Array */ = []; + for (const name of Object.keys(depsMap)) { + const dep = depsMap[name]; + if (dep == null) continue; + const iosPlatform = dep.platforms?.ios; + const root = dep.root; + if (iosPlatform == null || root == null) continue; + directDeps.push({ + name, + root, + platforms: {ios: iosPlatform}, + }); + } + const allDeps = expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + }); + + // 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. + const swiftNameByNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + if (dep.swiftName != null) { + swiftNameByNpm.set(dep.name, dep.swiftName); + } + } + + // Discover framework autolinking plugins (Expo & others) from the same + // dep set — invoked before the aggregator is written (below). + discoveredPlugins = discoverPlugins( + allDeps, + defaultReadConfig, + readDenyPluginsFromConfig(appRoot), + ); + for (const p of discoveredPlugins) { + log(`Found SPM autolinking plugin: ${p.depName}`); + } + + // A dep that declares an autolinking plugin OWNS its native contribution: + // the plugin (invoked below) returns its package/product/generated-source + // deps. RN must not also try to source-build that package through the + // community-lib path — it typically has no Package.swift and may be mixed + // Swift/ObjC (e.g. expo → ExpoModulesCore), which would throw before the + // plugin ever runs. Mirrors CocoaPods: `use_expo_modules!` owns Expo's + // pods; podspec autolinking doesn't also build Expo as a community lib. + // Skipping here also keeps the dep out of `entries` → out of the + // missing-manifest scan and the aggregator's package refs. + const pluginHostDeps /*: Set */ = new Set( + discoveredPlugins.map(p => p.depName), + ); + + for (const dep of allDeps) { + if (pluginHostDeps.has(dep.name)) { + log( + `Skipping ${dep.name} target generation — provided by its SPM autolinking plugin`, + ); + continue; + } + const target = autolinkingDepToSpmTarget( + dep.name, + dep, + outputDir, + swiftNameByNpm, + ); + if (target != null) { + entries.push({ + target, + origin: 'npm', + npmName: dep.name, + root: dep.root, + }); + log(`Found npm native module: ${target.name} → ${target.path}`); + } + } + } else { + log( + `No autolinking.json found at ${path.relative(appRoot, autolinkingJsonPath)} or no dependencies. Using only built-in modules.`, + ); + } + + // 2. From react-native.config.js spm.modules (user-defined extra modules). + // If the module declares `sources: [glob, ...]` (CocoaPods-style), expand + // 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); + for (const mod of configModules) { + const absPath = path.resolve(appRoot, mod.path); + const relPath = path.relative(outputDir, absPath); + const userSources = + Array.isArray(mod.sources) && mod.sources.length > 0 + ? expandSpmSourceGlobs(absPath, mod.sources) + : null; + entries.push({ + target: { + name: mod.name, + path: relPath, + exclude: mod.exclude ?? [], + publicHeadersPath: mod.publicHeadersPath ?? null, + sources: userSources, + }, + origin: 'spmModule', + }); + log(`Config module: ${mod.name} → ${relPath}`); + } + + // Resolve xcframeworks package path relative to outputDir (autolinked/). + // When provided this causes each target to declare a React dependency so + // Xcode adds the xcframework's header search paths (needed for ). + // Always set xcframeworksRelPath to the default even if the directory doesn't + // exist yet — on first run, step 2 (autolinking) runs before step 4 + // (xcframework symlinks), but the generated Swift code resolves paths at + // Xcode build time, not generation time. + let xcframeworksRelPath /*: string | null */ = null; + const absXcframeworks /*: string */ = + args.xcframeworksPath != null + ? path.resolve(appRoot, args.xcframeworksPath) + : path.join(appRoot, 'build', 'xcframeworks'); + xcframeworksRelPath = path.relative(outputDir, absXcframeworks); + + if (xcframeworksRelPath != null) { + log( + `React xcframeworks → ${xcframeworksRelPath} (relative to autolinked/)`, + ); + } + + // Whether autolinked targets declare a React dependency at all. Headers are + // served by the ReactHeaders/ReactNativeHeaders/ReactNativeDependenciesHeaders + // binaryTargets and the ReactAppHeaders product — no `-I` flags anywhere. + const hasReactDep = xcframeworksRelPath != null; + + // Each entry gets a wrapper dir at /packages// that + // contains the synth Package.swift and a `root` directory symlink pointing + // at the dep's real source dir. SPM derives package identity from the path + // basename, so the wrapper's unique name (SwiftName) sidesteps the basename + // collision that in-place at the source dir would have. Files inside the + // source dir stay real, so Xcode's atomic-save works through the dir + // symlink (intermediate path components — even symlinks — resolve cleanly; + // the issue was only file-symlinks as the final path component). + const entryAbsDirs /*: Map */ = new Map(); + for (const entry of entries) { + entryAbsDirs.set( + entry.target.name, + path.resolve(outputDir, entry.target.path), + ); + } + + const packagesDir = path.join(outputDir, 'packages'); + const headersDir = path.join(outputDir, 'headers'); + // libs// symlinks for self-managed deps. The symlink basename + // is the Swift module name (guaranteed unique per dep), so SPM's + // path-basename-based package identity never collides — even when two + // libs ship their own Package.swift inside `ios/` (a common convention). + // Wiped on every run; populated below as self-managed deps are visited. + const libsDir = path.join(outputDir, 'libs'); + fs.mkdirSync(packagesDir, {recursive: true}); + fs.mkdirSync(headersDir, {recursive: true}); + fs.rmSync(libsDir, {recursive: true, force: true}); + fs.mkdirSync(libsDir, {recursive: true}); + + const wrapperDirs /*: Map */ = new Map(); + const selfManagedDirs /*: Map */ = new Map(); + const aggregatorPackageDeps /*: Array */ = []; + // Community npm deps that autolink but ship/scaffold no Package.swift. We no + // 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. + const missingManifests /*: Array<{name: string, npmName: string, hasPodspec: boolean, mixed?: boolean}> */ = + []; + + for (const entry of entries) { + const {target} = entry; + const absSource /*: string */ = entryAbsDirs.get(target.name) ?? ''; + if (!fs.existsSync(absSource)) { + log(`Skipping ${target.name}: source dir missing (${absSource})`); + continue; + } + const selfManagedDir = findSelfManagedPackageDir(absSource); + if (selfManagedDir != null) { + // Record the manifest's actual directory — for the nested layout this + // is /ios, not . SPM resolves `.package(path:)` against that + // directory expecting Package.swift to live alongside. + selfManagedDirs.set(target.name, selfManagedDir); + // If a wrapper exists from a prior synth-mode run (i.e. the dep WAS + // autolinker-wrapped, then later transitioned to self-managed via + // `spm scaffold` or shipping its own Package.swift), remove the + // wrapper now. Without this, the pruning loop below preserves it + // (because the dep is "active" via selfManagedDirs) and Xcode's + // SwiftPM cache picks up the stale wrapper Package.swift instead of + // the self-managed one. + const staleWrapper = path.join(packagesDir, target.name); + if (fs.existsSync(staleWrapper)) { + fs.rmSync(staleWrapper, {recursive: true, force: true}); + log(`Removed stale wrapper: packages/${target.name}/`); + } + log( + `Self-managed: ${target.name} → ${path.relative(appRoot, selfManagedDir)} (using its own Package.swift)`, + ); + continue; + } + if (entry.origin === 'npm') { + // No shipped or scaffolded manifest — this is the gap we now surface. + // A mixed-language dep is reported distinctly (it can't be scaffolded at + // all, so "run spm scaffold" would be misleading). + missingManifests.push({ + name: target.name, + npmName: entry.npmName ?? target.name, + hasPodspec: hasPodspec(absSource), + mixed: hasMixedLanguageSources(absSource), + }); + // Drop any stale wrapper from a previous synth-mode run so SPM doesn't + // resolve against it. + const staleWrapper = path.join(packagesDir, target.name); + if (fs.existsSync(staleWrapper)) { + fs.rmSync(staleWrapper, {recursive: true, force: true}); + } + 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 + // community-dep path uses instead of letting SPM emit the cryptic one. + if (hasMixedLanguageSources(absSource)) { + throw new Error( + `react-native autolinking: the spm.module "${target.name}" mixes Swift ` + + `and C-family (.m/.mm/.c/.cpp) sources, which SwiftPM cannot compile ` + + `in a single target. Split it into separate single-language modules, ` + + `or ship a hand-written Package.swift with multiple targets.`, + ); + } + const wrapperDir = path.join(packagesDir, target.name); + wrapperDirs.set(target.name, wrapperDir); + fs.mkdirSync(wrapperDir, {recursive: true}); + ensureSymlink(path.join(wrapperDir, WRAPPER_ROOT_NAME), absSource); + } + + // Fail before writing any wrappers/aggregator: a missing community-lib + // manifest is a hard error the developer must resolve by scaffolding (or the + // library shipping its own). reportMissingManifests prints one `error:` line + // per dep so Xcode renders them as build errors. + if (missingManifests.length > 0) { + throw reportMissingManifests(missingManifests); + } + + // Sibling refs: each synth Package.swift declares its sibling deps via the + // dep's actual package root — wrapper dir for autolinker-managed deps, + // source dir for self-managed ones. SPM identity stays unique either way + // (wrapper basename = SwiftName; self-managed manifests declare the same + // package name). + const siblingPackagePaths /*: {[string]: string} */ = {}; + for (const [name, wrapper] of wrapperDirs.entries()) { + siblingPackagePaths[name] = wrapper; + } + for (const [name, sourceDir] of selfManagedDirs.entries()) { + siblingPackagePaths[name] = sourceDir; + } + + for (const entry of entries) { + const {target} = entry; + const absSource /*: string */ = entryAbsDirs.get(target.name) ?? ''; + + // Self-managed deps: skip the synth step entirely. The dep's own + // Package.swift handles its targets, headers, and React framework + // wiring. We just register it with the aggregator so the app pulls it + // in alongside autolinker-managed deps. The central headers// + // tree still gets populated so consumers (host app + sibling synths + // that hit -I autolinking/headers) can resolve `` + // by file path — synth packages use `-fno-implicit-module-maps`, so + // we can't rely on SPM's auto-generated module map alone. + if (selfManagedDirs.has(target.name)) { + // Centralized headers tree walks the WHOLE dep root, not just the + // manifest's directory — headers may live anywhere (e.g. common/cpp/ + // outside of ios/), and cross-package consumers should still resolve + // them via the centralized -I path. + linkHeaderTree(absSource, path.join(headersDir, target.name)); + // Route the manifest reference through a uniquely-named symlink at + // libs// so SPM derives the package identity from the + // alias basename. Two libs that both ship Package.swift inside their + // own `ios/` subdir would otherwise collide with identity "ios". + const realPackageDir = selfManagedDirs.get(target.name) ?? absSource; + const aliasPath = path.join(libsDir, target.name); + ensureSymlink(aliasPath, realPackageDir); + aggregatorPackageDeps.push({ + swiftName: target.name, + packagePath: `libs/${target.name}`, + npmName: entry.npmName ?? target.name, + }); + continue; + } + + const wrapperDir = wrapperDirs.get(target.name); + if (wrapperDir == null) continue; + const skipDirNames = new Set( + (target.exclude || []) + .filter(e => e.endsWith('/')) + .map(e => e.slice(0, -1)), + ); + + const siblingSynthAbsolutePaths /*: {[string]: string} */ = {}; + for (const sibling of target.spmTargetDependencies ?? []) { + const sibPath = siblingPackagePaths[sibling]; + if (sibPath != null) { + siblingSynthAbsolutePaths[sibling] = sibPath; + } + } + + // target.path = "." (the wrapper dir) so SPM sees an empty `include/` + // sibling of `root/` for its required `publicHeadersPath`. Without that, + // SPM defaults publicHeadersPath to "include" and errors out when no + // such dir exists inside the dep's source tree. Sources come from + // `root/<...>` via the dir symlink — paths from auto-discovery or + // user globs are relative to the dep's source dir, so we prefix with + // `root/` to keep them inside target.path. + const withRoot = (p /*: string */) => `${WRAPPER_ROOT_NAME}/${p}`; + const prefixedExclude /*: Array */ = (target.exclude ?? []).map( + withRoot, + ); + const prefixedResources /*: ?Array */ = + target.resources != null ? target.resources.map(withRoot) : undefined; + + // sources: explicit allowlist. Pre-resolved on the target (spmModule + // glob expansion) or auto-collected here. We always emit `sources:` so + // SPM never falls back to scanning the source dir verbatim (which would + // pick up tests/, *.js, *.podspec, etc.). + const rawSources /*: Array */ = + target.sources != null && target.sources.length > 0 + ? target.sources + : collectSpmSources(absSource); + const prefixedSources /*: ?Array */ = + rawSources.length > 0 ? rawSources.map(withRoot) : null; + + // Podspec HEADER_SEARCH_PATHS were captured relative to the dep's source + // dir. The wrapper exposes the source dir under `root/` (target.path is + // `.`, the wrapper dir), so each entry must be prefixed with `root/` so + // clang sees the real subtree. + const prefixedHeaderSearchPaths /*: ?Array */ = + target.headerSearchPaths != null && target.headerSearchPaths.length > 0 + ? target.headerSearchPaths.map(withRoot) + : null; + + const synthContent = generateSynthPackageSwift({ + swiftName: target.name, + exclude: prefixedExclude, + sources: prefixedSources, + // Stub include/ subdir lives in the wrapper dir; satisfies SPM's + // publicHeadersPath requirement without exposing anything. Cross-pkg + // angle includes resolve through the merged header tree (the autolinking + // header farm at /headers is folded into it). + publicHeadersPath: 'include', + resources: prefixedResources, + headerSearchPaths: prefixedHeaderSearchPaths, + spmDependencies: (target.spmTargetDependencies ?? []).map(swiftName => ({ + swiftName, + })), + hasReactDep, + // Relative paths from the synth dir (/packages/) to the + // app's React xcframeworks + codegen packages. Computed here because the + // synth's depth is fixed and it is regenerated every run — no runtime + // discovery needed in the manifest. + reactNativePackagePath: toPosix( + path.relative(wrapperDir, absXcframeworks), + ), + codegenPackagePath: toPosix( + path.relative( + wrapperDir, + path.join(appRoot, 'build', 'generated', 'ios'), + ), + ), + isDynamic: false, + targetPath: '.', + siblingSynthAbsolutePaths, + }); + + fs.writeFileSync( + path.join(wrapperDir, 'Package.swift'), + synthContent, + 'utf8', + ); + // Centralized headers tree at /headers//.h. + // Used two ways: + // * SPM-internal: cFlags add `-I /headers`, so cross-package + // angle includes like resolve. + // * Host app + sibling consumers: each wrapper's `include/` is a dir + // symlink to its slice of this tree, so `#import ` + // (e.g. ) resolves through SPM's + // publicHeadersPath propagation (-I .../packages//include). + const pkgHeadersDir = path.join(headersDir, target.name); + linkHeaderTree(absSource, pkgHeadersDir, skipDirNames); + + const includePath = path.join(wrapperDir, 'include'); + if (fs.existsSync(pkgHeadersDir)) { + ensureSymlink(includePath, pkgHeadersDir); + } else { + // Header-less package (rare): keep an empty dir so SPM's + // publicHeadersPath: "include" requirement is still satisfied. + fs.mkdirSync(includePath, {recursive: true}); + } + + log( + `Synth: packages/${target.name}/ → ${path.relative(appRoot, absSource)}`, + ); + + aggregatorPackageDeps.push({ + swiftName: target.name, + packagePath: `packages/${target.name}`, + }); + } + + // Prune stale wrappers + header dirs for entries no longer autolinked. + // Preserve both wrapper-managed and self-managed names; only entries that + // are no longer autolinked at all get removed. Note: `packages/` only has + // wrapper-managed names (self-managed deps live in their own source dirs), + // but `headers/` has both since we populate the central tree for everyone. + const activeNames /*: Set */ = new Set([ + ...wrapperDirs.keys(), + ...selfManagedDirs.keys(), + ]); + for (const subdir of ['packages', 'headers']) { + const dir = path.join(outputDir, subdir); + try { + const existing /*: Array<{name: string, isSymbolicLink(): boolean, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent typing + fs.readdirSync(dir, {withFileTypes: true}); + for (const entry of existing) { + if (activeNames.has(entry.name)) continue; + const stale = path.join(dir, entry.name); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + fs.unlinkSync(stale); + } else { + fs.rmSync(stale, {recursive: true, force: true}); + } + log(`Removed stale ${subdir}/${entry.name}`); + } + } catch { + // dir doesn't exist – fine + } + } + + // Invoke discovered framework plugins now that the RN dep graph + autolinking + // data are final. Their package/product contributions merge into the + // aggregator below; this runs in add/update AND every build-time sync (both + // call main), so a plugin's contribution survives every regeneration. + let pluginPackageDeps /*: Array */ = []; + let pluginProductDeps /*: Array */ = []; + let pluginGeneratedSources /*: Array<{path: string}> */ = []; + let pluginFlavoredFrameworks /*: Array */ = []; + let pluginWatchPaths /*: 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 + // descriptor only lists its products when it actually resolves. + const codegenPackageExists = fs.existsSync( + path.join(outputDir, '..', 'ios', 'Package.swift'), + ); + const result = invokePlugins( + discoveredPlugins, + { + appRoot, + projectRoot: findProjectRoot(appRoot), + reactNativeRoot: rnRoot, + autolinking: autolinkingData ?? {}, + outputDir, + react: reactDescriptor( + absXcframeworks, + xcframeworksRelPath, + codegenPackageExists, + ), + }, + {warn}, + ); + pluginPackageDeps = result.packageDependencies; + pluginProductDeps = result.productDependencies; + pluginGeneratedSources = result.generatedSources; + pluginFlavoredFrameworks = result.flavoredFrameworks; + pluginWatchPaths = result.watchPaths; + log( + `SPM plugins contributed ${pluginPackageDeps.length} package(s), ` + + `${pluginProductDeps.length} product(s), ` + + `${pluginGeneratedSources.length} generated source(s), ` + + `${pluginFlavoredFrameworks.length} flavored framework(s)`, + ); + } + + // Plugin sidecars. Both 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}); + // Generated-source registration (e.g. Expo's ExpoModulesProvider.swift), + // consumed by the injector at add/update time — the provider ordering + // contract is still Preview and co-designed with the first consumer. + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-generated-sources.json'), + JSON.stringify(pluginGeneratedSources, null, 2) + '\n', + 'utf8', + ); + // Flavored-framework sidecar consumed by `spm add` / `spm update` when it + // regenerates the app's linker settings and sole embed phase. + fs.writeFileSync( + path.join(outputDir, '.spm-plugin-flavored-frameworks.json'), + JSON.stringify(pluginFlavoredFrameworks, null, 2) + '\n', + 'utf8', + ); + + // Top-level aggregator: references every entry as .package(path:) and + // depends on each via .product(...). No more inline targets — every + // autolinked dep is a real SPM package in its own source dir. + const aggregatorContent = generateAutolinkedPackageSwift({ + npmDeps: aggregatorPackageDeps, + hasReactDep, + xcframeworksRelPath, + pluginPackageDeps, + pluginProductDeps, + }); + fs.mkdirSync(outputDir, {recursive: true}); + const outputPath = path.join(outputDir, 'Package.swift'); + fs.writeFileSync(outputPath, aggregatorContent, 'utf8'); + log(`Generated: ${path.relative(appRoot, outputPath)}`); + + // .spm-sync-watch-paths: absolute paths (dirs OR files) the Xcode auto-sync + // build phase watches for staleness. Three kinds of input, mixed freely: + // 1. Each module's source dir (entryAbsDirs) — a dir; adding/removing a + // child bumps its mtime so `find -newer` trips and the `sources:` + // allowlist regenerates. + // 2. Each npm dep's checked-in root `Package.swift` (a file) and its + // `.react-native/` metadata dir. Editing a manifest does NOT bump the + // source dir's mtime, so without these a manifest edit stays silently + // stale until an unrelated install triggers a sync. The dep ROOT is + // threaded through the entry (entry.root, from the autolinking model), + // not derived by walking up from the (possibly nested) source dir. + // 3. Plugin-contributed paths (e.g. Expo's own Package.swift / per-module + // manifests) — already validated absolute in invokePlugins. + // The phase distinguishes dir vs file with `-d`/`-f` at build time (no + // markers). The existsSync filter here is safe: these paths come from a + // successful sync so they exist now; a path that later VANISHES is caught at + // phase time against this file and forces a re-sync. + const watchCandidates /*: Array */ = [...entryAbsDirs.values()]; + for (const entry of entries) { + const root = entry.root; + if (entry.origin !== 'npm' || root == null) { + continue; + } + watchCandidates.push(path.join(root, 'Package.swift')); + watchCandidates.push(path.join(root, '.react-native')); + } + watchCandidates.push(...pluginWatchPaths); + const watchPaths = Array.from(new Set(watchCandidates)) + .filter(p => p.length > 0 && fs.existsSync(p)) + .sort(); + fs.writeFileSync( + path.join(outputDir, '.spm-sync-watch-paths'), + watchPaths.join('\n') + (watchPaths.length > 0 ? '\n' : ''), + 'utf8', + ); + + // AutolinkedAggregate is glue; needs at least one source file (Swift, so we + // sidestep the Obj-C public-headers-dir requirement). + const aggregateDir = path.join(outputDir, 'AutolinkedAggregate'); + fs.mkdirSync(aggregateDir, {recursive: true}); + const stubPath = path.join(aggregateDir, 'AutolinkedAggregate.swift'); + if (!fs.existsSync(stubPath)) { + fs.writeFileSync( + stubPath, + '// Placeholder. Real native modules live in transitively-referenced sub-packages.\n', + 'utf8', + ); + } + const legacyStub = path.join(aggregateDir, 'AutolinkedAggregate.m'); + if (fs.existsSync(legacyStub)) { + fs.unlinkSync(legacyStub); + } + + // One-time migration cleanup: remove the legacy /autolinked/ tree + // and any stale in-source `Package.swift` / `include//` from the + // prior in-place layout (those files lived in user source dirs and have + // been replaced by the wrapper layout under outputDir). + const legacyAutolinkedDir = path.join(appRoot, 'autolinked'); + if ( + fs.existsSync(legacyAutolinkedDir) && + path.resolve(legacyAutolinkedDir) !== path.resolve(outputDir) + ) { + fs.rmSync(legacyAutolinkedDir, {recursive: true, force: true}); + log(`Removed legacy autolinked/ tree`); + } + for (const absSource of entryAbsDirs.values()) { + const legacyPkg = path.join(absSource, 'Package.swift'); + let removedLegacyPkg = false; + try { + const content = fs.readFileSync(legacyPkg, 'utf8'); + if (content.includes(AUTOGEN_MARKER)) { + fs.unlinkSync(legacyPkg); + removedLegacyPkg = true; + log( + `Removed legacy in-place synth: ${path.relative(appRoot, legacyPkg)}`, + ); + } + } catch { + // not present – fine + } + // Only treat include/ as a legacy leftover when the in-place synth + // manifest was just removed alongside it: a SCAFFOLDED dep legitimately + // owns include// (the namespaced shim headers behind its + // publicHeadersPath: "include") — nuking it unconditionally would break + // every dependent's `#import ` on the next sync. + if (removedLegacyPkg) { + const legacyInclude = path.join(absSource, 'include'); + try { + if (fs.lstatSync(legacyInclude).isDirectory()) { + fs.rmSync(legacyInclude, {recursive: true, force: true}); + log( + `Removed legacy in-place include/: ${path.relative(appRoot, legacyInclude)}`, + ); + } + } catch { + // not present – fine + } + } + } +} + +if (require.main === module) { + try { + main(); + } catch (e) { + if (e instanceof RemoteVersionError) { + log(e.message); + process.exitCode = 2; + } else { + throw e; + } + } +} + +module.exports = { + main, + generateAutolinkedPackageSwift, + generateSynthPackageSwift, + reactDescriptor, + linkHeaderTree, + collectSpmSources, + expandSpmSourceGlobs, + findSelfManagedPackageDir, + hasPodspec, + hasMixedLanguageSources, + MissingManifestError, + reportMissingManifests, + AUTOGEN_MARKER, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-package.js b/packages/react-native/scripts/spm/generate-spm-package.js new file mode 100644 index 000000000000..813359cf0d46 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-package.js @@ -0,0 +1,302 @@ +/** + * 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'; + +/*:: import type {GeneratePackageArgs} from './spm-types'; */ + +/** + * generate-spm-package.js – Generates the xcframeworks sub-package for a + * React Native app using prebuilt XCFrameworks via Swift Package Manager. + * + * Usage: + * node generate-spm-package.js [options] + * + * Options: + * --app-root Path to the app directory (default: cwd) + * --react-native-root Path to react-native package root + * --version RN version for Maven artifact URLs + * --debug-artifacts-dir Complete Debug artifact cache slot + * --release-artifacts-dir Complete Release artifact cache slot + * --app-name App/package name (default: from package.json) + * --target-name Main app target name (default: derived from app-name) + * --source-path Path to app source relative to app-root (default: auto-detected) + * --ios-version Minimum iOS version (default: 15) + * + * Generates build/xcframeworks/Package.swift + symlinks. The xcodeproj + * references this sub-package directly; no separate app-level Package.swift + * is needed. + */ + +const {prepareFlavoredFrameworks} = require('./flavored-frameworks'); +const { + deriveAppName, + displayPath, + findProjectRoot, + makeLogger, + readPackageJson, + resolveReactNativeRoot, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log} = makeLogger('generate-spm-package'); + +function parseArgs(argv /*: Array */) /*: GeneratePackageArgs */ { + const parsed = yargs(argv) + .version(false) + .option('app-root', { + type: 'string', + default: process.cwd(), + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + describe: 'Path to react-native package root', + }) + .option('version', { + type: 'string', + describe: 'RN version for Maven artifact URLs', + }) + .option('debug-artifacts-dir', { + type: 'string', + describe: 'Path to the complete Debug artifacts cache slot', + }) + .option('release-artifacts-dir', { + type: 'string', + describe: 'Path to the complete Release artifacts cache slot', + }) + .option('app-name', { + type: 'string', + describe: 'App/package name (default: from package.json)', + }) + .option('target-name', { + type: 'string', + describe: 'Main app target name (default: derived from app-name)', + }) + .option('source-path', { + type: 'string', + describe: + 'Path to app source relative to app-root (default: auto-detected)', + }) + .option('ios-version', { + type: 'string', + default: '15', + describe: 'Minimum iOS version', + }) + .usage( + 'Usage: $0 [options]\n\nGenerates the xcframeworks sub-package for a React Native app using SPM.', + ) + .help() + .parseSync(); + + return { + appRoot: parsed['app-root'], + reactNativeRoot: parsed['react-native-root'] ?? null, + version: parsed.version ?? null, + debugArtifactsDir: parsed['debug-artifacts-dir'] ?? null, + releaseArtifactsDir: parsed['release-artifacts-dir'] ?? null, + appName: parsed['app-name'] ?? null, + targetName: parsed['target-name'] ?? null, + sourcePath: parsed['source-path'] ?? null, + iosVersion: parsed['ios-version'], + }; +} + +/** + * Find the app's main Swift/ObjC source directory. + * Looks for directories that contain native iOS source files. + */ +function findSourcePath( + appRoot /*: string */, + packageName /*: string */, +) /*: string */ { + // Derive from package name (e.g. "@react-native/tester" -> "Tester") + const derived = toSwiftName(packageName.replace(/^@[^/]+\//, '')); + + // Also check "RN" + derived (e.g. "Tester" -> "RNTester") and "RN" + whole name + const rnPrefixed = 'RN' + derived; + const candidates = [derived, rnPrefixed, 'ios', 'App', 'Sources', 'src']; + for (const c of candidates) { + if (fs.existsSync(path.join(appRoot, c))) { + return c; + } + } + + // Scan for a directory that looks like an iOS source root + // (contains .m, .mm, .swift, or .h files) + try { + const entries /*: Array<{name: string, isDirectory(): boolean}> */ = + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow but always string here + fs.readdirSync(appRoot, {withFileTypes: true}); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const dirPath = path.join(appRoot, entry.name); + const subEntries = fs.readdirSync(dirPath); + const hasNativeSources = subEntries.some((f /*: string | Buffer */) => + /\.(m|mm|swift|cpp|h|hpp)$/.test(String(f)), + ); + if (hasNativeSources) { + return entry.name; + } + } + } catch (_) { + // ignore + } + + return derived; +} + +/** + * Generates the Package.swift for the xcframeworks sub-package. + * + * When using local xcframeworks (from the cache), we put the binary targets in + * a dedicated Package.swift at build/xcframeworks/. The generated .xcodeproj + * references this sub-package via XCLocalSwiftPackageReference; the codegen + * Package.swift also imports it as a named package dependency. + */ +function generateXCFrameworksPackageSwift() /*: string */ { + 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", + products: [ + .library(name: "ReactHeaders", targets: ["ReactHeaders"]), + .library(name: "ReactNativeHeaders", targets: ["ReactNativeHeaders"]), + .library(name: "ReactNativeDependenciesHeaders", targets: ["ReactNativeDependenciesHeaders"]), + ], + targets: [ + .target( + name: "ReactHeaders", + dependencies: ["ReactNativeHeaders"], + path: "ReactHeadersTarget", + publicHeadersPath: "include" + ), + .binaryTarget( + name: "ReactNativeHeaders", + path: "ReactNativeHeaders.xcframework" + ), + .binaryTarget( + name: "ReactNativeDependenciesHeaders", + path: "ReactNativeDependenciesHeaders.xcframework" + ), + ] +) +`; +} + +function main( + argv /*:: ?: Array */, + dependencies /*:: ?: {prepareFlavoredFrameworks?: typeof prepareFlavoredFrameworks} */, +) /*: void */ { + const args = parseArgs(argv ?? process.argv.slice(2)); + // Ensure appRoot is always absolute so path.join/path.resolve produce absolute paths + // even when called with --app-root . or other relative paths. + const appRoot = path.resolve(args.appRoot); + + // Read app package.json + // package.json may be in a parent directory (e.g. when appRoot is ios/). + const projectRoot = findProjectRoot(appRoot); + const pkgJson = readPackageJson(projectRoot); + if (!pkgJson) { + throw new Error( + `[generate-spm-package] No package.json found in ${appRoot} or parent directories`, + ); + } + + let rnRoot = + args.reactNativeRoot != null + ? path.resolve(args.reactNativeRoot) + : resolveReactNativeRoot(appRoot, projectRoot); + if (rnRoot == null) { + throw new Error( + '[generate-spm-package] Could not find react-native. Pass --react-native-root.', + ); + } + + let version = args.version; + if (version == null) { + const rnPkg = readPackageJson(rnRoot); + version = rnPkg?.version ?? '0.0.0'; + } + + const rawName = pkgJson.name ?? path.basename(appRoot); + const sourcePath = args.sourcePath ?? findSourcePath(appRoot, rawName); + const appName = args.appName ?? deriveAppName(rawName, sourcePath); + const targetName = args.targetName ?? appName + 'App'; + + log(`App name: ${appName}`); + log(`Target name: ${targetName}`); + log(`Source path: ${sourcePath}`); + log(`Version: ${version}`); + + const debugArtifactsDir = args.debugArtifactsDir; + const releaseArtifactsDir = args.releaseArtifactsDir; + if ((debugArtifactsDir == null) !== (releaseArtifactsDir == null)) { + throw new Error( + '[generate-spm-package] both --debug-artifacts-dir and --release-artifacts-dir are required', + ); + } + if (debugArtifactsDir != null && releaseArtifactsDir != null) { + const prepare = + dependencies?.prepareFlavoredFrameworks ?? prepareFlavoredFrameworks; + prepare({ + appRoot, + debugArtifactsDir, + releaseArtifactsDir, + }); + const xcfwLinksDir = path.join(appRoot, 'build', 'xcframeworks'); + const xcfwPkgContent = generateXCFrameworksPackageSwift(); + const xcfwPkgPath = path.join(xcfwLinksDir, 'Package.swift'); + let packageChanged = true; + try { + packageChanged = fs.readFileSync(xcfwPkgPath, 'utf8') !== xcfwPkgContent; + } catch {} + if (packageChanged) { + fs.writeFileSync(xcfwPkgPath, xcfwPkgContent, 'utf8'); + } + log( + `${packageChanged ? 'Generated' : 'Unchanged'}: ${path.relative( + appRoot, + xcfwPkgPath, + )}`, + ); + log( + `Artifact slots: debug=${displayPath(debugArtifactsDir)}, ` + + `release=${displayPath(releaseArtifactsDir)}`, + ); + } else { + // Auto-detect an already-generated invariant package. + const xcfwLinksDir = path.join(appRoot, 'build', 'xcframeworks'); + if (fs.existsSync(path.join(xcfwLinksDir, 'Package.swift'))) { + log(`Auto-detected local xcframeworks: build/xcframeworks`); + } + } +} + +if (require.main === module) { + try { + main(); + } catch (e) { + console.error(e.message); + process.exitCode = 1; + } +} + +module.exports = { + main, + generateXCFrameworksPackageSwift, + findSourcePath, +}; diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js new file mode 100644 index 000000000000..f22fe26455e7 --- /dev/null +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -0,0 +1,2224 @@ +/** + * 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'; + +/** + * generate-spm-xcodeproj.js – Surgical, in-place Swift Package Manager + * integration toolkit for an existing `.xcodeproj`. + * + * `injectSpmIntoExistingXcodeproj` adds the SPM package references, React build + * settings, the "Sync SPM Autolinking" build phase, and a scheme pre-action to + * a user's existing project — purely additively, recording every edit in a + * `.spm-injected.json` marker. `removeSpmInjection` is the exact inverse (used + * by `spm deinit`). Consumed as a library by setup-apple-spm.js; not a CLI. + */ + +const {readFlavoredFrameworksManifest} = require('./flavored-frameworks'); +const { + addArrayMembers, + addArrayStringValues, + ensureScalarField, + findApplicationTargets, + findField, + findObjectByUuid, + findProjectObject, + insertObjectsIntoSection, + namespacedUUID, + quoteIfNeeded, + removeArrayMembersByUuid, + removeArrayStringValues, + removeDanglingJavaScriptCoreRef, + removeEmptyPodsGroup, + removeField, + removeObjectByUuid, + serializeEntry, + setScalarField, +} = require('./spm-pbxproj'); +const {makeLogger, remotePackageConfig} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +/*:: import type { + FlavoredFrameworkManifestEntry, + XcframeworkSlice, +} from './spm-types'; */ + +const {log} = makeLogger('generate-spm-xcodeproj'); + +// Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in +// place. Records the host project's root UUID + every edit so `spm deinit` +// (removeSpmInjection) can surgically revert and re-runs stay idempotent. +const SPM_INJECTED_MARKER = '.spm-injected.json'; + +// Manifest of plugin-contributed sources that must COMPILE INTO THE APP TARGET +// (e.g. Expo's ExpoModulesProvider.swift — an `@objc` class only reaches the +// ObjC classlist, and so is discoverable via NSClassFromString, when it +// compiles into the app target, NOT the static Autolinked aggregate). Written +// by generate-spm-autolinking.js (the plugin merge) BEFORE setupXcodeproj runs +// on both `add` and `update`, so the injector reads it synchronously. Path is +// relative to the app root (== SRCROOT / the .xcodeproj's dir). +const SPM_GENERATED_SOURCES_MANIFEST = path.join( + 'build', + 'generated', + 'autolinking', + '.spm-plugin-generated-sources.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'; +const SPM_GENERATED_SOURCES_GROUP_NAME = 'SPM Generated Sources'; + +// pbxproj `lastKnownFileType` per source extension. v1 plugins emit Swift only; +// .m/.mm are mapped as future-proofing (the plugin contract permits ObjC/ObjC++ +// sources). An unmapped extension is skipped with a loud log. +const GENERATED_SOURCE_FILE_TYPES /*: {[string]: string} */ = { + '.swift': 'sourcecode.swift', + '.m': 'sourcecode.c.objc', + '.mm': 'sourcecode.cpp.objcpp', +}; + +// Maps each SPM product to its sub-package path (relative to app root). +// The xcodeproj must reference each sub-package directly so Xcode can +// 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', + packagePath: 'build/xcframeworks', + packageName: 'ReactNative', + }, + { + product: 'Autolinked', + packagePath: 'build/generated/autolinking', + packageName: 'Autolinked', + }, + { + product: 'ReactCodegen', + packagePath: 'build/generated/ios', + packageName: 'React-GeneratedCode', + }, + { + product: 'ReactAppDependencyProvider', + packagePath: 'build/generated/ios', + packageName: 'React-GeneratedCode', + }, + ]; + +/*:: +type RemoteCfg = {url: string, version: string, identity: string}; +// Precise record of the build-setting edits injection made to ONE build config, +// so deinit can reverse exactly those (and nothing the user already had). +type BuildSettingChange = { + configUuid: string, + createdArrayKeys: Array, + appendedArrayValues: {[string]: Array}, + createdScalars: Array, + // Scalars whose pre-injection value was replaced (key → original raw + // value), e.g. a ${PODS_ROOT}-anchored REACT_NATIVE_PATH that dangles once + // CocoaPods is deintegrated. Deinit restores the original. + replacedScalars?: {[string]: 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 '""'). +type GeneratedSource = {path: string, name: string, sourceTree: string, fileType: string}; +type SpmGraph = { + uniquePackages: Array<{packagePath: string, packageName: string}>, + localPkgRefs: Array<{uuid: string, packagePath: string, comment: string}>, + remotePkgRef: ?{uuid: string, url: string, version: string, identity: string, comment: string}, + products: Array<{product: string, depUuid: string, buildFileUuid: string, pkgRefUuid: string, refComment: string}>, +}; +*/ + +/** + * Resolve the SPM dependency graph (package references + product + * dependencies + their frameworks build files) from SPM_PRODUCT_PACKAGES. + * `mkUuid(section, id)` supplies UUIDs, seeded with the host project's root + * UUID so injected IDs are stable across re-runs and collision-safe. + */ +function buildSpmDependencyGraph( + mkUuid /*: (section: string, id: string) => string */, + remote /*: ?RemoteCfg */, +) /*: SpmGraph */ { + // Remote mode: ReactNative-family products move to the remote package. + const productPackages = SPM_PRODUCT_PACKAGES.map(e => + remote != null && e.packagePath === 'build/xcframeworks' + ? {...e, packagePath: 'REMOTE', packageName: remote.identity} + : e, + ); + const uniquePackages = Array.from( + new Map( + productPackages + .filter(e => e.packagePath !== 'REMOTE') + .map(e => [ + e.packagePath, + {packagePath: e.packagePath, packageName: e.packageName}, + ]), + ).values(), + ); + const localPkgRefs = uniquePackages.map(pkg => ({ + uuid: mkUuid('XCLocalSwiftPackageReference', pkg.packagePath), + packagePath: pkg.packagePath, + comment: `XCLocalSwiftPackageReference "${pkg.packagePath}"`, + })); + const remotePkgRef = + remote != null + ? { + uuid: mkUuid('XCRemoteSwiftPackageReference', remote.url), + url: remote.url, + version: remote.version, + identity: remote.identity, + comment: `XCRemoteSwiftPackageReference "${remote.identity}"`, + } + : null; + const localByPath = new Map(localPkgRefs.map(r => [r.packagePath, r])); + const products = productPackages.map(entry => { + const {product, packagePath} = entry; + const isRemote = packagePath === 'REMOTE' && remotePkgRef != null; + const pkgRefUuid = isRemote + ? // $FlowFixMe[incompatible-use] guarded by isRemote + remotePkgRef.uuid + : // $FlowFixMe[incompatible-use] every non-REMOTE path is in localByPath + localByPath.get(packagePath).uuid; + const refComment = isRemote + ? // $FlowFixMe[incompatible-use] guarded by isRemote + `XCRemoteSwiftPackageReference "${remotePkgRef.identity}"` + : `XCLocalSwiftPackageReference "${packagePath}"`; + return { + product, + depUuid: mkUuid('XCSwiftPackageProductDependency', product), + buildFileUuid: mkUuid('PBXBuildFile', `spm:${product}`), + pkgRefUuid, + refComment, + }; + }); + return {uniquePackages, localPkgRefs, remotePkgRef, products}; +} + +/** + * Render the SPM graph into pbxproj section entry objects the in-place injector + * splices into an existing project. + */ +/*:: type PbxEntryT = {uuid: string, comment: string, fields: {[string]: string}}; */ + +function spmGraphToEntries( + graph /*: SpmGraph */, +) /*: {localRefs: Array, remoteRef: ?PbxEntryT, productDeps: Array, buildFiles: Array} */ { + const localRefs /*: Array */ = graph.localPkgRefs.map(ref => ({ + uuid: ref.uuid, + comment: ref.comment, + fields: { + isa: 'XCLocalSwiftPackageReference', + relativePath: quoteIfNeeded(ref.packagePath), + }, + })); + const remote = graph.remotePkgRef; + const remoteRef /*: ?PbxEntryT */ = + remote != null + ? { + uuid: remote.uuid, + comment: remote.comment, + fields: { + isa: 'XCRemoteSwiftPackageReference', + repositoryURL: quoteIfNeeded(remote.url), + requirement: `{\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = "${remote.version}";\n\t\t\t}`, + }, + } + : null; + const productDeps /*: Array */ = graph.products.map(p => ({ + uuid: p.depUuid, + comment: p.product, + fields: { + isa: 'XCSwiftPackageProductDependency', + package: `${p.pkgRefUuid} /* ${p.refComment} */`, + productName: quoteIfNeeded(p.product), + }, + })); + const buildFiles /*: Array */ = graph.products.map(p => ({ + uuid: p.buildFileUuid, + comment: `${p.product} in Frameworks`, + fields: { + isa: 'PBXBuildFile', + productRef: `${p.depUuid} /* ${p.product} */`, + }, + })); + 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). +function shellScriptPhase( + phaseUUID /*: string */, + name /*: string */, + script /*: string */, + options /*: {inputPaths?: string, outputPaths?: string} */ = {}, +) /*: {uuid: string, comment: string, fields: {[string]: string}} */ { + const empty = '(\n\t\t\t)'; + return { + uuid: phaseUUID, + comment: name, + fields: { + isa: 'PBXShellScriptBuildPhase', + buildActionMask: '2147483647', + files: empty, + inputFileListPaths: empty, + inputPaths: options.inputPaths ?? empty, + name: quoteIfNeeded(name), + outputFileListPaths: empty, + outputPaths: options.outputPaths ?? empty, + runOnlyForDeploymentPostprocessing: '0', + shellPath: '/bin/sh', + shellScript: quoteIfNeeded(script), + }, + }; +} + +function frameworkSettingPrefix(id /*: string */) /*: string */ { + return `RN_SPM_${id.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`; +} + +function flavorForBuildConfiguration( + configurationName /*: string */, +) /*: 'debug' | 'release' */ { + const lower = configurationName.toLowerCase(); + return lower.includes('debug') || lower.includes('development') + ? 'debug' + : 'release'; +} + +function buildConfigurationName( + text /*: string */, + configUuid /*: string */, +) /*: string */ { + const config = findObjectByUuid(text, configUuid); + const name = config != null ? findField(text, config, 'name') : null; + if (name == null) { + throw new Error(`pbxproj: build configuration ${configUuid} has no name`); + } + return name.value.replace(/^"|"$/g, ''); +} + +function frameworkConditionalSettings( + frameworks /*: ReadonlyArray */, +) /*: Array<{key: string, value: string}> */ { + const settings /*: Array<{key: string, value: string}> */ = []; + for (const framework of frameworks) { + const prefix = frameworkSettingPrefix(framework.id); + const bySdk /*: Map> */ = new Map(); + // The injected target is an Apple mobile/Catalyst application. Native + // macOS slices share `sdk=macosx*` with Catalyst and cannot be + // distinguished by an XCBuildConfiguration condition, so use the Catalyst + // slice and leave native-mac packaging out of this iOS integration. + for (const slice of framework.slices.filter( + candidate => candidate.platform !== 'macos', + )) { + const existing = bySdk.get(slice.sdk) ?? []; + existing.push(slice); + bySdk.set(slice.sdk, existing); + } + for (const [sdk, slices] of bySdk) { + const emit = (slice /*: XcframeworkSlice */, condition /*: string */) => { + const root = + `$(SRCROOT)/build/xcframeworks/$(RN_SPM_FLAVOR)/` + + `${framework.artifactRelativePath}/${slice.libraryIdentifier}`; + settings.push( + { + key: quoteIfNeeded(`${prefix}_FRAMEWORK${condition}`), + value: quoteIfNeeded(`${root}/${slice.libraryPath}`), + }, + { + key: quoteIfNeeded(`${prefix}_BINARY${condition}`), + value: quoteIfNeeded(`${root}/${slice.binaryPath}`), + }, + { + key: quoteIfNeeded(`${prefix}_SEARCH_PATH${condition}`), + value: quoteIfNeeded(root), + }, + ); + }; + if (slices.length === 1) { + emit(slices[0], `[sdk=${sdk}]`); + continue; + } + const seenArchitectures /*: Set */ = new Set(); + for (const slice of slices) { + for (const architecture of slice.architectures) { + if (seenArchitectures.has(architecture)) { + throw new Error( + `${framework.frameworkName} has ambiguous ${sdk}/${architecture} slices`, + ); + } + seenArchitectures.add(architecture); + emit(slice, `[sdk=${sdk}][arch=${architecture}]`); + } + } + } + } + return settings; +} + +function frameworkArrayBuildSettings( + frameworks /*: ReadonlyArray */, +) /*: Array<{key: string, values: Array}> */ { + return [ + { + key: 'OTHER_LDFLAGS', + values: [ + '"-ObjC"', + ...frameworks.map( + framework => `"$(${frameworkSettingPrefix(framework.id)}_BINARY)"`, + ), + ], + }, + { + key: 'FRAMEWORK_SEARCH_PATHS', + values: frameworks.map( + framework => `"$(${frameworkSettingPrefix(framework.id)}_SEARCH_PATH)"`, + ), + }, + { + key: 'LD_RUNPATH_SEARCH_PATHS', + values: ['"@executable_path/Frameworks"'], + }, + ]; +} + +function pbxPathList(paths /*: ReadonlyArray */) /*: string */ { + if (paths.length === 0) { + return '(\n\t\t\t)'; + } + return `(\n${paths + .map(value => `\t\t\t\t${quoteIfNeeded(value)},\n`) + .join('')}\t\t\t)`; +} + +function buildEmbedFrameworksScript( + frameworks /*: ReadonlyArray */, +) /*: string */ { + const validations = frameworks + .map(framework => { + const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`; + return `validate_framework "\${${variable}:-}" "${framework.frameworkName}.framework"`; + }) + .join('\n'); + const copies = frameworks + .map(framework => { + const variable = `${frameworkSettingPrefix(framework.id)}_FRAMEWORK`; + return `copy_and_sign "\${${variable}:-}" "${framework.frameworkName}.framework"`; + }) + .join('\n'); + return `set -euo pipefail + +destination="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH" +mkdir -p "$destination" + +validate_framework() { + source="$1" + name="$2" + if [ -z "$source" ] || [ ! -d "$source" ]; then + echo "error: React Native SwiftPM framework '$name' is unavailable for configuration '$CONFIGURATION' and SDK '$SDK_NAME': $source" + exit 1 + fi + binary="\${name%.framework}" + if [ ! -e "$source/$binary" ] && [ ! -e "$source/Versions/Current/$binary" ]; then + echo "error: React Native SwiftPM framework '$name' is invalid for configuration '$CONFIGURATION': expected $source/$binary or $source/Versions/Current/$binary" + exit 1 + fi +} + +copy_and_sign() { + source="$1" + name="$2" + /usr/bin/rsync -a --delete "$source/" "$destination/$name/" + if [ "\${CODE_SIGNING_ALLOWED:-YES}" != "NO" ]; then + identity="\${EXPANDED_CODE_SIGN_IDENTITY:--}" + if [ "$identity" = "-" ]; then + /usr/bin/codesign --force --sign - --timestamp=none --preserve-metadata=identifier,entitlements,flags "$destination/$name" + else + /usr/bin/codesign --force --sign "$identity" --preserve-metadata=identifier,entitlements,flags "$destination/$name" + fi + fi +} + +${validations} +${copies} +`; +} + +function addBuildPhaseAfter( + text /*: string */, + target /*: {bodyOpen: number, bodyClose: number, ...} */, + afterUuid /*: string */, + member /*: {uuid: string, comment: string} */, +) /*: string */ { + const field = findField(text, target, 'buildPhases'); + if (field == null || field.value.includes(member.uuid)) { + return text; + } + const after = new RegExp(`(^|\\n)([\\t ]*)${afterUuid}\\b[^\\n]*,`).exec( + field.value, + ); + if (after == null) { + return addArrayMembers(text, target, 'buildPhases', [member]); + } + 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} */,`; + return text.slice(0, lineEnd) + line + text.slice(lineEnd); +} + +// The node + react-native-dir resolution preamble shared by the sync build +// phase and scheme pre-action. Both dispatch DIRECTLY into react-native's +// scripts rather than through 'npx react-native' — that CLI requires +// @react-native-community/cli (absent in e.g. Expo apps), so it would exit +// non-zero and the failure would be silently swallowed. +function nodeAndRnDirPreamble(reactNativePath /*: string */) /*: string */ { + return `set -euo pipefail + +# --------------------------------------------------------------------------- +# Resolve a node binary and the react-native package dir at BUILD TIME. +# --------------------------------------------------------------------------- +NODE_BINARY="\${NODE_BINARY:-}" +if [ -z "$NODE_BINARY" ]; then + # Source RN's standard app-local node-path files. They reference vars that + # may be unset and may return non-zero, so relax nounset AND errexit while + # sourcing — a buggy user .xcode.env must degrade to PATH-based node + # resolution below, not silently abort every build. + set +eu + if [ -f "$SRCROOT/.xcode.env" ]; then + . "$SRCROOT/.xcode.env" + fi + if [ -f "$SRCROOT/.xcode.env.local" ]; then + . "$SRCROOT/.xcode.env.local" + fi + set -eu + NODE_BINARY="\${NODE_BINARY:-}" +fi +if [ -z "$NODE_BINARY" ]; then + NODE_BINARY="$(command -v node 2>/dev/null || true)" +fi + +# Resolve react-native's dir FROM THE APP (require.resolve), not a +# generation-time baked path — the baked path goes stale in pnpm / hoisted +# stores. Fall back to the baked path if resolution fails or the resolved dir +# has no setup-apple-spm.js. +RN_DIR="" +if [ -n "$NODE_BINARY" ]; then + RN_DIR="$(cd "$SRCROOT" && "$NODE_BINARY" --print "require('path').dirname(require.resolve('react-native/package.json'))" 2>/dev/null || true)" +fi +if [ -z "$RN_DIR" ] || [ ! -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then + RN_DIR="${reactNativePath}" +fi`; +} + +// Shared: the STALE-input check + conditional codegen/autolinking sync dispatch. +// Runtime framework slots are never touched here; add/update owns them. +function syncStaleCheckAndDispatch() /*: string */ { + return `STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp" +STALE=0 + +# Find project root (where package.json lives — may be an ancestor of SRCROOT) +PROJECT_ROOT="$SRCROOT" +while [ "$PROJECT_ROOT" != "/" ] && [ ! -f "$PROJECT_ROOT/package.json" ]; do + PROJECT_ROOT="$(dirname "$PROJECT_ROOT")" +done +if [ ! -f "$PROJECT_ROOT/package.json" ]; then + PROJECT_ROOT="$SRCROOT" +fi + +# Check 1: dependency inputs (covers app projects after any package manager install) +for INPUT in \\ + "$PROJECT_ROOT/package.json" \\ + "$PROJECT_ROOT/react-native.config.js"; do + if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then + STALE=1 + break + fi +done + +# Check workspace lockfiles and package-manager metadata. These cover package +# managers that do not reliably bump node_modules mtimes, and Yarn PnP projects +# that do not have node_modules at all. +if [ "$STALE" -eq 0 ]; then + DIR="$PROJECT_ROOT" + while [ "$DIR" != "/" ]; do + for INPUT in \\ + "$DIR/package-lock.json" \\ + "$DIR/npm-shrinkwrap.json" \\ + "$DIR/yarn.lock" \\ + "$DIR/pnpm-lock.yaml" \\ + "$DIR/bun.lock" \\ + "$DIR/bun.lockb" \\ + "$DIR/.pnp.cjs" \\ + "$DIR/.pnp.loader.mjs"; do + if [ -f "$INPUT" ] && [ "$INPUT" -nt "$STAMP" ]; then + STALE=1 + break + fi + done + if [ "$STALE" -eq 1 ]; then + break + fi + DIR="$(dirname "$DIR")" + done +fi + +# Check node_modules mtime. In monorepos, node_modules may be hoisted to any +# ancestor between the app package and the workspace root. +if [ "$STALE" -eq 0 ]; then + DIR="$PROJECT_ROOT" + while [ "$DIR" != "/" ]; do + NM_DIR="$DIR/node_modules" + if [ -d "$NM_DIR" ] && [ "$NM_DIR" -nt "$STAMP" ]; then + STALE=1 + break + fi + DIR="$(dirname "$DIR")" + done +fi + +# Also check the app root directly when SRCROOT is not the package root. +if [ "$STALE" -eq 0 ] && [ "$SRCROOT" != "$PROJECT_ROOT" ]; then + if [ -d "$SRCROOT/node_modules" ] && [ "$SRCROOT/node_modules" -nt "$STAMP" ]; then + STALE=1 + fi +fi + +# Check 1.5: watched paths (mixed dirs AND files). Dirs catch add/remove of +# source files in spm.modules and autolinked deps (dir mtime updates on both); +# files catch edits to a dep's checked-in Package.swift / plugin manifests that +# would not bump any parent dir mtime. A path that has VANISHED (renamed/moved +# module root) forces a re-sync so the autolinker surfaces the real, actionable +# config error rather than the build failing later on dangling-symlink noise. +WATCH_FILE="$SRCROOT/build/generated/autolinking/.spm-sync-watch-paths" +if [ "$STALE" -eq 0 ] && [ -f "$WATCH_FILE" ]; then + while IFS= read -r P; do + [ -z "$P" ] && continue + if [ -d "$P" ]; then + if [ -n "$(find "$P" -newer "$STAMP" -print -quit 2>/dev/null)" ]; then + STALE=1 + break + fi + elif [ -f "$P" ]; then + if [ "$P" -nt "$STAMP" ]; then + STALE=1 + break + fi + else + STALE=1 + break + fi + done < "$WATCH_FILE" +fi + +# Check 2: codegen spec files changed via git (covers monorepo after git pull) +if [ "$STALE" -eq 0 ] && [ -f "$STAMP" ]; then + STAMP_TIME=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) + LATEST_SPEC_COMMIT=$(git -C "$SRCROOT" log -1 --format=%ct -- '*.js' '*.ts' 2>/dev/null || echo 0) + if [ "$LATEST_SPEC_COMMIT" -gt "$STAMP_TIME" ]; then + STALE=1 + fi +fi + +if [ ! -f "$STAMP" ]; then + STALE=1 +fi + +# Re-sync codegen + autolinking when a dependency input changed. Runtime +# framework slots and Xcode linker settings are only changed by spm update. +if [ "$STALE" -eq 1 ]; then + echo "SPM sync inputs changed — re-syncing (codegen + autolinking)..." + + WITH_ENVIRONMENT="$RN_DIR/scripts/xcode/with-environment.sh" + + if [ -f "$WITH_ENVIRONMENT" ]; then + # with-environment.sh references PODS_ROOT and $1, which may be unset. + # Temporarily disable nounset to avoid failures when sourcing. + export PODS_ROOT="\${PODS_ROOT:-$SRCROOT}" + set +u + . "$WITH_ENVIRONMENT" + set -u + fi + + cd "$SRCROOT" + # \`|| RC=$?\` so a non-zero exit is CAPTURED rather than aborting the phase + # under \`set -e\` — the whole point is to branch on the code below (2 = fail + # the build with a scaffold hint; other non-zero = warn but don't break). + RC=0 + if [ -n "$NODE_BINARY" ] && [ -f "$RN_DIR/scripts/setup-apple-spm.js" ]; then + # Direct, dependency-free dispatch (no \`npx react-native\`, which needs + # @react-native-community/cli). + "$NODE_BINARY" "$RN_DIR/scripts/setup-apple-spm.js" sync || RC=$? + elif command -v npx >/dev/null 2>&1; then + npx react-native spm sync || RC=$? + else + echo "warning: node/npx not found — skipping SPM sync" + fi + if [ "$RC" -eq 2 ]; then + # Exit 2 = an autolinked community dependency has no Package.swift. The + # autolinker already printed an \`error:\` line per dep (so Xcode shows them + # and the fix). Fail the build — the developer must run + # \`npx react-native spm scaffold\` from a terminal to generate the manifest. + exit 1 + elif [ "$RC" -ne 0 ]; then + echo "warning: SPM sync failed — build may use stale codegen/autolinking" + fi +fi +`; +} + +// Scheme pre-action: re-run codegen + autolinking before package resolution. +function buildSchemePreActionScript( + reactNativePath /*: string */, +) /*: string */ { + return `${nodeAndRnDirPreamble(reactNativePath)} + +${syncStaleCheckAndDispatch()} +`; +} + +// The in-target phase is only an autolinking safety net. Runtime framework +// selection is expressed entirely through build settings and the independent +// Embed React Native Flavored Frameworks phase. +function buildSyncAutolinkingScript( + reactNativePath /*: string */, +) /*: string */ { + return `${nodeAndRnDirPreamble(reactNativePath)} + +${syncStaleCheckAndDispatch()} +`; +} +// XML-attribute escape (the five named entities). The sync script uses `>` +// and `&` for redirection and bg/and chains, plus `<` for heredocs and +// comparisons — all of which break Xcode's scheme parser if left raw. +function escapeXmlAttribute(s /*: string */) /*: string */ { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function generateXcscheme( + appName /*: string */, + targetUUID /*: string */, + projName /*: string */, + syncScript /*: string */, +) /*: string */ { + const escapedSync = escapeXmlAttribute(syncScript); + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +} + +// When the xcodeproj is generated, the referenced SPM package directories +// (build/xcframeworks, autolinked, build/generated/ios) may not exist yet. +// Xcode resolves packages before any build phase runs, so we write minimal +// stub Package.swift files to let resolution succeed. The real generators +// (sync-spm-autolinking.js) overwrite these during the first build. + +/*:: +type StubPackageDef = { + packageName: string, + products: Array, +}; +*/ + +function generateStubPackageSwift(def /*: StubPackageDef */) /*: string */ { + const {packageName, products} = def; + const stubTarget = `${packageName.replace(/[^a-zA-Z0-9]/g, '')}Stub`; + const productLines = products + .map(p => ` .library(name: "${p}", targets: ["${stubTarget}"]),`) + .join('\n'); + return `// swift-tools-version: 5.9 +// GENERATED STUB — will be overwritten by sync-spm-autolinking.js during build. +import PackageDescription + +let package = Package( + name: "${packageName}", + products: [ +${productLines} + ], + targets: [ + .target(name: "${stubTarget}", path: "_stub", sources: ["Stub.swift"]), + ] +) +`; +} + +/** + * Ensures each referenced SPM sub-package directory has a valid Package.swift + * so Xcode can resolve packages before any build phase runs. + * Skips directories that already contain a Package.swift (from a previous build). + */ +function ensureStubPackages(appRoot /*: string */) /*: void */ { + // Derive stub definitions from SPM_PRODUCT_PACKAGES + const byPath = new Map /*:: */(); + for (const entry of SPM_PRODUCT_PACKAGES) { + const existing = byPath.get(entry.packagePath); + if (existing != null) { + existing.products.push(entry.product); + } else { + byPath.set(entry.packagePath, { + packageName: entry.packageName, + products: [entry.product], + }); + } + } + + for (const [relPath, def] of byPath) { + const pkgDir = path.join(appRoot, relPath); + const pkgSwiftPath = path.join(pkgDir, 'Package.swift'); + + if (fs.existsSync(pkgSwiftPath)) { + continue; + } + + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(pkgSwiftPath, generateStubPackageSwift(def), 'utf8'); + + // Create minimal stub source file required by SPM + const stubDir = path.join(pkgDir, '_stub'); + fs.mkdirSync(stubDir, {recursive: true}); + const stubSwift = path.join(stubDir, 'Stub.swift'); + if (!fs.existsSync(stubSwift)) { + fs.writeFileSync( + stubSwift, + '// Placeholder — replaced during first build.\n', + 'utf8', + ); + } + + log(`Wrote stub Package.swift: ${relPath}/Package.swift`); + } +} + +// --------------------------------------------------------------------------- +// In-place injection: add SPM packages to a user's EXISTING xcodeproj. +// +// This never creates a target or scans sources — it splices the SPM dependency +// graph, the React build settings, and the sync build phase / scheme pre-action +// into the project the user already owns, leaving everything else +// byte-identical. The whole `spm add` / `spm update` xcodeproj strategy, so +// hand-tuned signing / capabilities / extra targets survive. Fails loud (the +// caller surfaces the error) when the project is CocoaPods-integrated or its +// shape can't be safely anchored. +// --------------------------------------------------------------------------- + +// The React build settings the app target needs to compile against the SPM +// products. +const INJECTED_ARRAY_SETTINGS = [ + { + key: 'HEADER_SEARCH_PATHS', + values: ['"$(SRCROOT)/build/generated/autolinking/headers"'], + }, +]; + +/** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ +function targetBuildConfigUuids( + text /*: string */, + targetObj /*: {bodyOpen: number, bodyClose: number, ...} */, +) /*: Array */ { + const listField = findField(text, targetObj, 'buildConfigurationList'); + if (listField == null) { + return []; + } + const listMatch = listField.value.match(/[0-9A-Fa-f]{24}/); + if (listMatch == null) { + return []; + } + const listObj = findObjectByUuid(text, listMatch[0]); + if (listObj == null) { + return []; + } + const configs = findField(text, listObj, 'buildConfigurations'); + if (configs == null) { + return []; + } + const matches = configs.value.match(/[0-9A-Fa-f]{24}/g); + return matches != null ? Array.from(matches) : []; +} + +/** True when a build config layers a CocoaPods `Pods-*.xcconfig`. */ +function configUsesPods( + text /*: string */, + configUuid /*: string */, +) /*: boolean */ { + const obj = findObjectByUuid(text, configUuid); + if (obj == null) { + return false; + } + const base = findField(text, obj, 'baseConfigurationReference'); + return base != null && /Pods[-/]/.test(base.value); +} + +/** + * Inspect an existing pbxproj and decide whether it can be injected. Returns + * the chosen app target + its config/frameworks anchors, or a refusal reason + * the caller surfaces (fail-loud). + */ +function planInjection(text /*: string */, opts /*: {appName?: ?string} */) /*: + | {ok: true, rootUuid: string, target: {uuid: string, name: string, bodyOpen: number, bodyClose: number}, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid: ?string} + | {ok: false, reason: string} */ { + const project = findProjectObject(text); + if (project == null) { + return {ok: false, reason: 'no PBXProject object found'}; + } + const apps = findApplicationTargets(text); + if (apps.length === 0) { + return {ok: false, reason: 'no application target found'}; + } + let target; + if (apps.length === 1) { + target = apps[0]; + } else { + const appName = opts.appName; + if (appName == null) { + return { + ok: false, + reason: `multiple application targets (${apps + .map(a => a.name) + .join(', ')}); pass --app-name to disambiguate`, + }; + } + target = apps.find(a => a.name === appName); + if (target == null) { + return { + ok: false, + reason: `no application target named "${appName}"`, + }; + } + } + const configUuids = targetBuildConfigUuids(text, target); + if (configUuids.length === 0) { + return {ok: false, reason: 'could not resolve target build configurations'}; + } + if (configUuids.some(c => configUsesPods(text, c))) { + return { + ok: false, + reason: + 'target uses CocoaPods (Pods-*.xcconfig) — in-place injection only ' + + 'supports SPM-only targets', + }; + } + // The target's own Frameworks build phase (where product build files link). + const buildPhases = findField(text, target, 'buildPhases'); + const phaseUuids = + buildPhases != null + ? (buildPhases.value.match(/[0-9A-Fa-f]{24}/g) ?? []) + : []; + let frameworksPhaseUuid = null; + // Also capture the Sources phase — plugin generated sources compile into it + // (see injectSpmIntoPbxproj step 8). Nullable: a target may legitimately + // lack one, in which case generated-source wiring is skipped (not fatal). + let sourcesPhaseUuid = null; + for (const pu of phaseUuids) { + const po = findObjectByUuid(text, pu); + if (po == null) { + continue; + } + const isa = findField(text, po, 'isa'); + if (isa == null) { + continue; + } + if ( + frameworksPhaseUuid == null && + /PBXFrameworksBuildPhase/.test(isa.value) + ) { + frameworksPhaseUuid = pu; + } else if ( + sourcesPhaseUuid == null && + /PBXSourcesBuildPhase/.test(isa.value) + ) { + sourcesPhaseUuid = pu; + } + } + if (frameworksPhaseUuid == null) { + return {ok: false, reason: 'target has no Frameworks build phase'}; + } + return { + ok: true, + rootUuid: project.uuid, + target, + configUuids, + frameworksPhaseUuid, + sourcesPhaseUuid, + }; +} + +/** + * Splice the SPM dependency graph + React build settings + sync build phase + * into `text` and return the modified pbxproj. Pure string transform (no I/O), + * idempotent: objects already present (by UUID) and array members / settings + * already applied are skipped, so a second run is a no-op. + */ +function injectSpmIntoPbxproj( + input /*: string */, + plan /*: {rootUuid: string, targetUuid: string, configUuids: Array, frameworksPhaseUuid: string, sourcesPhaseUuid?: ?string} */, + reactNativePath /*: string */, + remote /*: ?RemoteCfg */, + hermesCliPath /*: ?string */ = null, + generatedSources /*: ReadonlyArray */ = [], + flavoredFrameworks /*: ReadonlyArray */ = [], +) /*: {text: string, injectedUuids: Array, createdArrayFields: Array<{container: 'project' | 'target', key: string}>, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}} */ { + let text = input; + const mkUuid = (section /*: string */, id /*: string */) => + namespacedUUID(plan.rootUuid, section, id); + const graph = buildSpmDependencyGraph(mkUuid, remote); + const entries = spmGraphToEntries(graph); + const injectedUuids /*: Array */ = []; + + // 1. Insert the new objects (skip any UUID already present — idempotency). + const insertObjects = ( + sectionName /*: string */, + objs /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, readonly fields: {readonly [string]: string}, ...}> */, + ) => { + const fresh = objs.filter(o => !text.includes(o.uuid)); + for (const o of objs) { + injectedUuids.push(o.uuid); + } + if (fresh.length === 0) { + return; + } + text = insertObjectsIntoSection( + text, + sectionName, + fresh.map(serializeEntry).join('\n'), + ); + }; + insertObjects('XCLocalSwiftPackageReference', entries.localRefs); + if (entries.remoteRef != null) { + insertObjects('XCRemoteSwiftPackageReference', [entries.remoteRef]); + } + 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}> */ = + []; + + // 2. packageReferences on the PBXProject. + const pkgRefMembers = [ + ...(graph.remotePkgRef != null + ? [{uuid: graph.remotePkgRef.uuid, comment: graph.remotePkgRef.comment}] + : []), + ...graph.localPkgRefs.map(r => ({uuid: r.uuid, comment: r.comment})), + ]; + const project = findProjectObject(text); + if (project != null) { + if (findField(text, project, 'packageReferences') == null) { + createdArrayFields.push({container: 'project', key: 'packageReferences'}); + } + text = addArrayMembers(text, project, 'packageReferences', pkgRefMembers); + } + + // 3. packageProductDependencies on the app target. + const productMembers = graph.products.map(p => ({ + uuid: p.depUuid, + comment: p.product, + })); + if ( + findField( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'packageProductDependencies', + ) == null + ) { + createdArrayFields.push({ + container: 'target', + key: 'packageProductDependencies', + }); + } + text = addArrayMembers( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'packageProductDependencies', + productMembers, + ); + + // 4. product build files into the target's Frameworks phase. + const phase = findObjectByUuid(text, plan.frameworksPhaseUuid); + if (phase != null) { + text = addArrayMembers( + text, + phase, + 'files', + graph.products.map(p => ({ + uuid: p.buildFileUuid, + comment: `${p.product} in Frameworks`, + })), + ); + } + + // 5. React build settings into every build config (Debug + Release). + const buildSettingChanges /*: Array */ = []; + for (const configUuid of plan.configUuids) { + const merged = mergeReactBuildSettings( + text, + configUuid, + buildConfigurationName(text, configUuid), + reactNativePath, + hermesCliPath, + flavoredFrameworks, + ); + text = merged.text; + buildSettingChanges.push(merged.change); + } + + // 6. The Sync SPM Autolinking build phase (safety net; the scheme pre-action + // is what fires before SPM resolution). Prepended so it runs before + // Sources. We do NOT add a JS-bundle phase — an existing app already + // bundles JS via its own phase. + const syncScript = buildSyncAutolinkingScript(reactNativePath); + const syncPhaseUuid = mkUuid('PBXShellScriptBuildPhase', 'SyncAutolinking'); + if (!text.includes(syncPhaseUuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry( + shellScriptPhase(syncPhaseUuid, 'Sync SPM Autolinking', syncScript), + ), + ); + } else { + // Already injected on a prior run — the phase object owns its + // shellScript, so refresh it in place (same quoting used at creation) in + // case the generated script changed since. Byte-identical when it + // didn't; field order and every other byte of the phase are untouched. + const existingPhase = findObjectByUuid(text, syncPhaseUuid); + if (existingPhase != null) { + text = setScalarField( + text, + existingPhase, + 'shellScript', + quoteIfNeeded(syncScript), + ); + } + } + injectedUuids.push(syncPhaseUuid); + text = addArrayMembers( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + 'buildPhases', + [{uuid: syncPhaseUuid, comment: 'Sync SPM Autolinking'}], + {prepend: true}, + ); + + // 7. The sole writer of flavored frameworks under the final app bundle. + // SwiftPM owns only invariant header/source products, so no implicit SPM + // embed task competes with this phase. + const embedPhaseUuid = mkUuid( + 'PBXShellScriptBuildPhase', + 'EmbedFlavoredFrameworks', + ); + const embedScript = buildEmbedFrameworksScript(flavoredFrameworks); + const embedInputs = [ + '$(SRCROOT)/build/xcframeworks/.artifact-stamp', + ...flavoredFrameworks.map( + framework => `$(${frameworkSettingPrefix(framework.id)}_FRAMEWORK)`, + ), + ]; + const embedOutputs = flavoredFrameworks.map( + framework => + `$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/${framework.frameworkName}.framework`, + ); + const embedEntry = shellScriptPhase( + embedPhaseUuid, + 'Embed React Native Flavored Frameworks', + embedScript, + { + inputPaths: pbxPathList(embedInputs), + outputPaths: pbxPathList(embedOutputs), + }, + ); + if (!text.includes(embedPhaseUuid)) { + text = insertObjectsIntoSection( + text, + 'PBXShellScriptBuildPhase', + serializeEntry(embedEntry), + ); + } else { + const existingPhase = findObjectByUuid(text, embedPhaseUuid); + if (existingPhase != null) { + for (const key of ['shellScript', 'inputPaths', 'outputPaths']) { + const current = findObjectByUuid(text, embedPhaseUuid); + if (current != null) { + text = setScalarField(text, current, key, embedEntry.fields[key]); + } + } + } + } + injectedUuids.push(embedPhaseUuid); + text = addBuildPhaseAfter( + text, + findApplicationTargetByUuid(text, plan.targetUuid), + plan.frameworksPhaseUuid, + { + uuid: embedPhaseUuid, + comment: 'Embed React Native Flavored Frameworks', + }, + ); + + // 8. Plugin generated sources compiled INTO THE APP TARGET (e.g. Expo's + // ExpoModulesProvider.swift). An `@objc` class only reaches the ObjC + // classlist — required for NSClassFromString discovery — when it compiles + // into the app target, not the static Autolinked aggregate. Each source + // gets a PBXFileReference + PBXBuildFile + a Sources-phase entry, parented + // under a single "SPM Generated Sources" group. Every UUID is keyed on the + // normalized path (deterministic → idempotent) and recorded so `deinit` + // reverts it and `update` reconciles it (removal is done by the caller, + // which owns the prior marker; emission here is purely additive). + const generatedSourceUuids /*: {[string]: Array} */ = {}; + const sourcesPhaseUuid = plan.sourcesPhaseUuid; + if (generatedSources.length > 0) { + if (sourcesPhaseUuid == null) { + log( + 'warning: the app target has no Sources build phase — cannot compile ' + + `${generatedSources.length} SPM plugin generated source(s) into the ` + + 'app target; skipping. Any @objc classes they define will not be ' + + 'discoverable via NSClassFromString.', + ); + } else { + const fileRefs = []; + const buildFiles = []; + const sourcesMembers = []; + const groupChildren = []; + for (const src of generatedSources) { + const fileRefUuid = mkUuid('PBXFileReference', `gensrc:${src.path}`); + const buildFileUuid = mkUuid('PBXBuildFile', `gensrc:${src.path}`); + generatedSourceUuids[src.path] = [fileRefUuid, buildFileUuid]; + fileRefs.push({ + uuid: fileRefUuid, + comment: src.name, + fields: { + isa: 'PBXFileReference', + lastKnownFileType: src.fileType, + name: quoteIfNeeded(src.name), + path: quoteIfNeeded(src.path), + sourceTree: src.sourceTree, + }, + }); + buildFiles.push({ + uuid: buildFileUuid, + comment: `${src.name} in Sources`, + fields: { + isa: 'PBXBuildFile', + fileRef: `${fileRefUuid} /* ${src.name} */`, + }, + }); + sourcesMembers.push({ + uuid: buildFileUuid, + comment: `${src.name} in Sources`, + }); + groupChildren.push({uuid: fileRefUuid, comment: src.name}); + } + insertObjects('PBXFileReference', fileRefs); + insertObjects('PBXBuildFile', buildFiles); + + // Compile membership — the actual reason these are wired into the app. + const sourcesPhase = findObjectByUuid(text, sourcesPhaseUuid); + if (sourcesPhase != null) { + text = addArrayMembers(text, sourcesPhase, 'files', sourcesMembers); + } + + // The "SPM Generated Sources" group (created on first use, then reused). + // Insert with empty children so the ONE population path (addArrayMembers) + // handles both create and reconcile, keeping formatting identical. + const groupUuid = mkUuid('PBXGroup', SPM_GENERATED_SOURCES_GROUP_ID); + if (!text.includes(groupUuid)) { + text = insertObjectsIntoSection( + text, + 'PBXGroup', + serializeEntry({ + uuid: groupUuid, + comment: SPM_GENERATED_SOURCES_GROUP_NAME, + fields: { + isa: 'PBXGroup', + children: '(\n\t\t\t)', + name: quoteIfNeeded(SPM_GENERATED_SOURCES_GROUP_NAME), + sourceTree: '""', + }, + }), + ); + } + injectedUuids.push(groupUuid); + const groupObj = findObjectByUuid(text, groupUuid); + if (groupObj != null) { + text = addArrayMembers(text, groupObj, 'children', groupChildren); + } + + // Parent the group under the project's main group (idempotent). Appends + // to a pre-existing children array, so no createdArrayField is recorded — + // deinit removes the group's membership via removeArrayMembersByUuid and + // the group object itself via removeObjectByUuid (groupUuid is injected). + const proj = findProjectObject(text); + const mainGroupField = + proj != null ? findField(text, proj, 'mainGroup') : null; + const mainGroupMatch = + mainGroupField != null + ? mainGroupField.value.match(/[0-9A-Fa-f]{24}/) + : null; + const mainGroupObj = + mainGroupMatch != null + ? findObjectByUuid(text, mainGroupMatch[0]) + : null; + if (mainGroupObj != null) { + text = addArrayMembers(text, mainGroupObj, 'children', [ + {uuid: groupUuid, comment: SPM_GENERATED_SOURCES_GROUP_NAME}, + ]); + } + } + } + + return { + text, + injectedUuids, + createdArrayFields, + buildSettingChanges, + generatedSourceUuids, + }; +} + +/** Re-locate an application target by UUID against the current text. */ +function findApplicationTargetByUuid( + text /*: string */, + targetUuid /*: string */, +) /*: {uuid: string, bodyOpen: number, bodyClose: number} */ { + const obj = findObjectByUuid(text, targetUuid); + if (obj == null) { + throw new Error(`pbxproj: app target ${targetUuid} disappeared mid-edit`); + } + return obj; +} + +/** + * Merge the React build settings into one XCBuildConfiguration's dict. Returns + * the modified text plus a precise record of what was actually added — so + * `deinit` (removeSpmInjection) can reverse exactly these edits, never touching + * a value the user already had (key insight: ensureScalarField/ + * addArrayStringValues are no-ops / dedupe when a value is already present). + */ +/** + * Resolves the host `hermesc` from the `hermes-compiler` npm package and returns + * its ABSOLUTE path as the HERMES_CLI_PATH value, or null when it can't be found + * (e.g. USE_HERMES=false apps without the package). require.resolve (anchored at + * reactNativeRoot) follows Node's lookup, so a hoisted monorepo layout — where + * hermes-compiler sits in the workspace-root node_modules, NOT next to + * react-native — resolves correctly. + * + * The value is intentionally ABSOLUTE, not `$(REACT_NATIVE_PATH)/../...`: when + * react-native is a symlink (the monorepo default, and common in real apps), a + * `..` after it resolves — kernel-side — to the symlink TARGET's parent, not the + * node_modules dir, so the relative form points at a non-existent + * `/../hermes-compiler`. An absolute path sidesteps that entirely + * (and matches how the CocoaPods hermes-engine pod sets HERMES_CLI_PATH). It is + * regenerated on every `spm add`, so machine-specificity is a non-issue. + */ +function resolveHermesCliPathSetting( + reactNativeRoot /*: string */, +) /*: ?string */ { + try { + const pkg = require.resolve('hermes-compiler/package.json', { + paths: [reactNativeRoot], + }); + const hermesc = path.join( + path.dirname(pkg), + 'hermesc', + 'osx-bin', + 'hermesc', + ); + return fs.existsSync(hermesc) ? hermesc : null; + } catch { + return null; + } +} + +function mergeReactBuildSettings( + input /*: string */, + configUuid /*: string */, + configurationName /*: string */, + reactNativePath /*: string */, + hermesCliPath /*: ?string */ = null, + flavoredFrameworks /*: ReadonlyArray */ = [], +) /*: {text: string, change: BuildSettingChange} */ { + let text = input; + const scalars = [ + {key: 'CLANG_CXX_LANGUAGE_STANDARD', value: '"c++20"'}, + {key: 'REACT_NATIVE_PATH', value: quoteIfNeeded(reactNativePath)}, + // Under SwiftPM there is no hermes-engine pod, so react-native-xcode.sh's + // fallback ($PODS_ROOT/hermes-engine/destroot/bin/hermesc) resolves to a + // non-existent "/hermes-engine/..." and the Release JS→Hermes bundling + // fails. Point HERMES_CLI_PATH at the hermes-compiler npm package's host + // hermesc (an ABSOLUTE path resolved by the caller — see + // resolveHermesCliPathSetting). react-native-xcode.sh honors an already-set + // HERMES_CLI_PATH before its pod fallback; ensureScalarField leaves any + // user-provided value untouched. + ...(hermesCliPath != null + ? [{key: 'HERMES_CLI_PATH', value: quoteIfNeeded(hermesCliPath)}] + : []), + ]; + // Re-locate the buildSettings dict before each edit (offsets shift). + const dict = () => { + const cfg = findObjectByUuid(text, configUuid); + if (cfg == null) { + return null; + } + const bs = findField(text, cfg, 'buildSettings'); + if (bs == null) { + return null; + } + return { + uuid: configUuid, + bodyOpen: bs.valueStart, + bodyClose: bs.tokenEnd - 1, + }; + }; + const createdArrayKeys /*: Array */ = []; + const appendedArrayValues /*: {[string]: Array} */ = {}; + const createdScalars /*: Array */ = []; + const arraySettings = [ + ...INJECTED_ARRAY_SETTINGS, + ...frameworkArrayBuildSettings(flavoredFrameworks), + ]; + for (const {key, values} of arraySettings) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + if (existing == null) { + createdArrayKeys.push(key); + } else { + const fresh = values.filter(v => !existing.value.includes(v)); + if (fresh.length > 0) { + appendedArrayValues[key] = fresh; + } + } + text = addArrayStringValues(text, d, key, values); + } + const replacedScalars /*: {[string]: string} */ = {}; + for (const {key, value} of scalars) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + if (existing == null) { + createdScalars.push(key); + } else if ( + key === 'REACT_NATIVE_PATH' && + existing.value.includes('PODS_ROOT') + ) { + // A ${PODS_ROOT}-anchored REACT_NATIVE_PATH (the CocoaPods template + // default) dangles once CocoaPods is deintegrated: PODS_ROOT resolves + // empty at build time, so the Bundle React Native code and images + // phase looks for "/../…/scripts/xcode/with-environment.sh". Replace + // it with the SPM-computed path, recording the original for deinit. + replacedScalars[key] = existing.value; + text = removeField(text, d, key); + const d2 = dict(); + if (d2 == null) { + continue; + } + text = ensureScalarField(text, d2, key, value); + continue; + } + text = ensureScalarField(text, d, key, value); + } + const ownedScalars = [ + { + key: 'RN_SPM_FLAVOR', + value: flavorForBuildConfiguration(configurationName), + }, + ...frameworkConditionalSettings(flavoredFrameworks), + ]; + for (const {key, value} of ownedScalars) { + const d = dict(); + if (d == null) { + continue; + } + const existing = findField(text, d, key); + if (existing == null) { + createdScalars.push(key); + } else if (existing.value !== value) { + replacedScalars[key] = existing.value; + } + text = setScalarField(text, d, key, value); + } + return { + text, + change: { + configUuid, + createdArrayKeys, + appendedArrayValues, + createdScalars, + replacedScalars, + }, + }; +} + +// Write only when content changed (avoids spurious Xcode reloads / git churn). +function writeIfChanged( + filePath /*: string */, + content /*: string */, +) /*: boolean */ { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + try { + if (fs.readFileSync(filePath, 'utf8') === content) { + return false; + } + } catch { + /* file doesn't exist yet */ + } + fs.writeFileSync(filePath, content, 'utf8'); + return true; +} + +/** + * Add the "Sync SPM Autolinking" pre-action to an existing scheme's + * BuildAction, reusing the scheme's own primary BuildableReference. Returns + * the XML unchanged when the pre-action is already present. + */ +function addPreActionToScheme( + xml /*: string */, + targetUuid /*: string */, + syncScript /*: string */, +) /*: string */ { + const titleIdx = xml.indexOf('title = "Sync SPM Autolinking"'); + if (titleIdx >= 0) { + // Already injected on a prior run — refresh a possibly-stale scriptText + // in place (same escaping used at creation) rather than leaving it + // forever. Splice by index (not a regex/string replace) since the script + // itself may contain `$`-sequences that String.replace's replacement- + // pattern syntax would otherwise misinterpret. Byte-identical when the + // script is unchanged; every other byte of the scheme is untouched. + const scriptTextMarker = 'scriptText = "'; + const stIdx = xml.indexOf(scriptTextMarker, titleIdx); + if (stIdx < 0) { + return xml; // malformed — leave untouched rather than guess + } + const valueStart = stIdx + scriptTextMarker.length; + // escapeXmlAttribute maps a literal `"` to `"`, so the attribute + // value itself never contains one — the next `"` is always the closing + // delimiter. + const valueEnd = xml.indexOf('"', valueStart); + return ( + xml.slice(0, valueStart) + + escapeXmlAttribute(syncScript) + + xml.slice(valueEnd) + ); + } + const refMatch = xml.match( + new RegExp( + `]*BlueprintIdentifier = "${targetUuid}"[^>]*>`, + ), + ); + const attr = (name /*: string */) => { + const m = + refMatch != null + ? refMatch[0].match(new RegExp(`${name} = "([^"]*)"`)) + : null; + return m != null ? m[1] : ''; + }; + const cleanRef = + `\n` + + ` `; + const executionAction = + ` \n` + + ` \n` + + ` \n` + + ` ${cleanRef}\n` + + ` \n` + + ` \n` + + ` `; + + if (//.test(xml)) { + return xml.replace( + '', + `${executionAction}\n `, + ); + } + const openEnd = xml.indexOf('>', xml.indexOf('\n${executionAction}\n `; + return xml.slice(0, openEnd + 1) + block + xml.slice(openEnd + 1); +} + +/** + * Ensure the app target's shared scheme runs the sync pre-action before SPM + * resolution. Updates the scheme that builds the target if one exists, + * otherwise creates a fresh shared scheme. Returns 'updated' | 'created' | + * 'unchanged'. + */ +function injectOrCreateScheme( + xcodeprojDir /*: string */, + opts /*: {appName: string, targetUuid: string, projName: string, syncScript: string} */, +) /*: {status: 'updated' | 'unchanged' | 'created', file: string} */ { + const schemesDir = path.join(xcodeprojDir, 'xcshareddata', 'xcschemes'); + let schemeFiles /*: Array */ = []; + try { + schemeFiles = fs + .readdirSync(schemesDir) + .filter(f => f.endsWith('.xcscheme')); + } catch { + /* no shared schemes dir yet */ + } + for (const f of schemeFiles) { + const p = path.join(schemesDir, f); + const xml = fs.readFileSync(p, 'utf8'); + if (xml.includes(`BlueprintIdentifier = "${opts.targetUuid}"`)) { + const updated = addPreActionToScheme( + xml, + opts.targetUuid, + opts.syncScript, + ); + return { + status: writeIfChanged(p, updated) ? 'updated' : 'unchanged', + file: f, + }; + } + } + const file = `${opts.appName}.xcscheme`; + const xml = generateXcscheme( + opts.appName, + opts.targetUuid, + opts.projName, + opts.syncScript, + ); + writeIfChanged(path.join(schemesDir, file), xml); + return {status: 'created', file}; +} + +/** + * Strip the empty `Pods` group `pod deintegrate` leaves in the navigator. + * Called by `add --deintegrate` after deintegration so the converted project is + * visually clean. No-op when absent or when the group still has children. + */ +function cleanupLeftoverPodsGroup(xcodeprojPath /*: string */) /*: boolean */ { + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return false; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const cleaned = removeEmptyPodsGroup(original); + return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false; +} + +/** + * Strip the dangling `JavaScriptCore.framework` file reference the community + * template has carried since RN 0.60 (navigator-only, meaningless under + * Hermes) — see `removeDanglingJavaScriptCoreRef` for the full rationale and + * the safety gate that leaves a still-linked reference untouched. No-op when + * absent or when the pbxproj is missing. + */ +function cleanupDanglingJavaScriptCoreRef( + xcodeprojPath /*: string */, +) /*: boolean */ { + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return false; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const cleaned = removeDanglingJavaScriptCoreRef(original); + return cleaned !== original ? writeIfChanged(pbxprojPath, cleaned) : false; +} + +/** + * Normalize one plugin generated-source path into the fields a PBXFileReference + * needs. Stores an SRCROOT-relative path (`sourceTree = SOURCE_ROOT`) when the + * source lives under the app root — the typical case (build/generated/…) — and + * an absolute path (`sourceTree = ""`) otherwise. Returns null (with a + * loud log) for an extension the pbxproj can't compile. + */ +function normalizeGeneratedSource( + appRoot /*: string */, + srcPath /*: string */, +) /*: ?GeneratedSource */ { + const ext = path.extname(srcPath).toLowerCase(); + const fileType = GENERATED_SOURCE_FILE_TYPES[ext]; + if (fileType == null) { + log( + `warning: unsupported generated-source extension "${ext}" for ` + + `${srcPath}; skipping (SPM plugin sources must be .swift/.m/.mm).`, + ); + return null; + } + const abs = path.isAbsolute(srcPath) + ? srcPath + : path.resolve(appRoot, srcPath); + const rel = path.relative(appRoot, abs); + const underAppRoot = + rel !== '' && + rel !== '..' && + !rel.startsWith('..' + path.sep) && + !path.isAbsolute(rel); + return { + path: underAppRoot ? rel : abs, + name: path.basename(abs), + sourceTree: underAppRoot ? 'SOURCE_ROOT' : '""', + fileType, + }; +} + +/** + * Read + normalize the plugin generated-sources manifest at + * `/build/generated/autolinking/.spm-plugin-generated-sources.json`. + * Absent, empty, or malformed → `[]` (the feature stays inert for non-plugin + * apps and never breaks injection). The file need not exist yet at inject time: + * the build-time sync regenerates it before compile, and a PBXFileReference to a + * not-yet-created path is valid. + */ +function readGeneratedSourcesManifest( + appRoot /*: string */, +) /*: Array */ { + const manifestPath = path.join(appRoot, SPM_GENERATED_SOURCES_MANIFEST); + let raw: string; + try { + raw = fs.readFileSync(manifestPath, 'utf8'); + } catch { + return []; + } + let entries: unknown; + try { + entries = JSON.parse(raw); + } catch { + log( + `warning: could not parse ${SPM_GENERATED_SOURCES_MANIFEST}; ` + + 'skipping generated sources.', + ); + return []; + } + if (!Array.isArray(entries)) { + return []; + } + const out /*: Array */ = []; + for (const entry of entries) { + if ( + entry == null || + typeof entry !== 'object' || + typeof entry.path !== 'string' + ) { + continue; + } + const normalized = normalizeGeneratedSource(appRoot, entry.path); + // Dedupe by normalized path — a duplicate manifest entry would otherwise + // double-insert identical-UUID pbxproj objects. + if (normalized != null && !out.some(s => s.path === normalized.path)) { + out.push(normalized); + } + } + 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` + * and to read back a pinned `artifactsVersionOverride` (see below). + */ +function readMarker( + xcodeprojPath /*: string */, +) /*: ?{generatedSources?: {[string]: Array}, artifactsVersionOverride?: ?string, buildSettingChanges?: Array, ...} */ { + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + try { + // $FlowFixMe[incompatible-return] JSON.parse returns any + return JSON.parse(fs.readFileSync(markerPath, 'utf8')); + } catch { + return null; + } +} + +// 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. +function findInjectedXcodeproj(appRoot /*: string */) /*: string | null */ { + let entries: Array<{name: string, isDirectory(): boolean}>; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(appRoot, {withFileTypes: true}); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (!name.endsWith('.xcodeproj')) continue; + if (fs.existsSync(path.join(appRoot, name, SPM_INJECTED_MARKER))) { + return path.join(appRoot, name); + } + } + return null; +} + +/** + * Read the `artifactsVersionOverride` a previous `spm add --version` / `spm + * 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. + */ +function readArtifactsVersionOverride(appRoot /*: string */) /*: ?string */ { + const xcodeprojPath = findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return null; + } + const override = readMarker(xcodeprojPath)?.artifactsVersionOverride; + return typeof override === 'string' && override.length > 0 ? override : 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} */, +) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { + const {appRoot, reactNativeRoot, xcodeprojPath} = opts; + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return { + status: 'refused', + reason: `no project.pbxproj at ${xcodeprojPath}`, + }; + } + const original = fs.readFileSync(pbxprojPath, 'utf8'); + const plan = planInjection(original, {appName: opts.appName}); + if (!plan.ok) { + return {status: 'refused', reason: plan.reason}; + } + const reactNativePath = path.relative(appRoot, reactNativeRoot); + const remote = remotePackageConfig(appRoot); + const hermesCliPath = resolveHermesCliPathSetting(reactNativeRoot); + const generatedSources = readGeneratedSourcesManifest(appRoot); + const flavoredFrameworks = readFlavoredFrameworksManifest(appRoot).frameworks; + + const prevMarker = readMarker(xcodeprojPath); + + // Reconcile generated sources injected on a PRIOR run that are no longer in + // the manifest (a plugin's entry was dropped, or the plugin was removed). + // Diff the marker's `generatedSources` map against the current manifest and + // delete only the stale UUIDs — the additive injection below re-emits (and + // idempotently skips) everything that remains, so an unchanged run stays + // byte-identical. deinit needs none of this: the removed objects live in + // `injectedUuids`. + const prevGeneratedSources /*: {[string]: Array} */ = + prevMarker?.generatedSources ?? {}; + const currentPaths = new Set(generatedSources.map(s => s.path)); + const staleUuids /*: Array */ = []; + for (const p of Object.keys(prevGeneratedSources)) { + if (!currentPaths.has(p)) { + staleUuids.push(...prevGeneratedSources[p]); + } + } + // When the last generated source is gone, retire the now-empty group too. + if ( + generatedSources.length === 0 && + Object.keys(prevGeneratedSources).length > 0 + ) { + staleUuids.push( + namespacedUUID(plan.rootUuid, 'PBXGroup', SPM_GENERATED_SOURCES_GROUP_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. + let base = removeRecordedBuildSettings( + original, + prevMarker?.buildSettingChanges ?? [], + ); + if (staleUuids.length > 0) { + base = removeArrayMembersByUuid(base, staleUuids); + for (const u of staleUuids) { + base = removeObjectByUuid(base, u); + } + } + + const { + text, + injectedUuids, + createdArrayFields, + buildSettingChanges, + generatedSourceUuids, + } = injectSpmIntoPbxproj( + base, + { + rootUuid: plan.rootUuid, + targetUuid: plan.target.uuid, + configUuids: plan.configUuids, + frameworksPhaseUuid: plan.frameworksPhaseUuid, + sourcesPhaseUuid: plan.sourcesPhaseUuid, + }, + reactNativePath, + remote, + hermesCliPath, + generatedSources, + flavoredFrameworks, + ); + + const changed = writeIfChanged(pbxprojPath, text); + log( + changed + ? `Injected SPM packages into ${path.relative(appRoot, pbxprojPath)}` + : `${path.relative(appRoot, pbxprojPath)} already up to date`, + ); + + const projName = path.basename(xcodeprojPath, '.xcodeproj'); + const schemeResult = injectOrCreateScheme(xcodeprojPath, { + appName: plan.target.name, + targetUuid: plan.target.uuid, + projName, + // The scheme pre-action is SYNC-ONLY (no flavor swap). A pre-action swap + // could win its race and mask a mismatch from the in-target detector. + syncScript: buildSchemePreActionScript(reactNativePath), + }); + log(`Scheme sync pre-action: ${schemeResult.status}`); + + // The RN version this app's xcframework artifact-cache slot should be + // pinned to, when `add`/`update` was given an EXPLICIT `--version` — SETS + // the pin. Omitting `--version` (opts.artifactsVersionOverride is null) + // PRESERVES whatever was recorded on a prior run, since it's an + // 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. + const artifactsVersionOverride = + opts.artifactsVersionOverride ?? + prevMarker?.artifactsVersionOverride ?? + null; + + // Marker: idempotency signal + the exact, reversible record of every edit so + // `deinit` (removeSpmInjection) can undo precisely what was added. + writeIfChanged( + path.join(xcodeprojPath, SPM_INJECTED_MARKER), + JSON.stringify( + { + rootUuid: plan.rootUuid, + target: plan.target.name, + targetUuid: plan.target.uuid, + injectedUuids: Array.from(new Set(injectedUuids)).sort(), + createdArrayFields, + buildSettingChanges, + // Normalized path → [fileRefUuid, buildFileUuid]. Read back on the next + // `update` to reconcile away entries that left the manifest. + generatedSources: generatedSourceUuids, + artifactsVersionOverride, + scheme: { + file: schemeResult.file, + created: schemeResult.status === 'created', + }, + }, + null, + 2, + ) + '\n', + ); + + ensureStubPackages(appRoot); + return {status: 'injected', target: plan.target.name}; +} + +/** + * Remove the "Sync SPM Autolinking" pre-action that addPreActionToScheme added + * to a scheme, and drop the `` wrapper if it is left empty (the + * byte-identical inverse for the common case where injection created it). + */ +function removePreActionFromScheme(xml /*: string */) /*: string */ { + const withoutAction = xml.replace( + /[ \t]*)[\s\S])*?title = "Sync SPM Autolinking"(?:(?!<\/ExecutionAction>)[\s\S])*?<\/ExecutionAction>\n?/, + '', + ); + return withoutAction.replace(/\n[ \t]*\s*<\/PreActions>/, ''); +} + +function removeRecordedBuildSettings( + input /*: string */, + changes /*: ReadonlyArray */, +) /*: string */ { + let text = input; + for (const change of changes) { + const dict = () => { + const config = findObjectByUuid(text, change.configUuid); + if (config == null) { + return null; + } + const buildSettings = findField(text, config, 'buildSettings'); + if (buildSettings == null) { + return null; + } + return { + uuid: change.configUuid, + bodyOpen: buildSettings.valueStart, + bodyClose: buildSettings.tokenEnd - 1, + }; + }; + for (const key of Object.keys(change.appendedArrayValues ?? {})) { + const current = dict(); + if (current != null) { + text = removeArrayStringValues( + text, + current, + key, + change.appendedArrayValues[key], + ); + } + } + for (const key of change.createdArrayKeys ?? []) { + const current = dict(); + if (current != null) { + text = removeField(text, current, key); + } + } + for (const key of change.createdScalars ?? []) { + const current = dict(); + if (current != null) { + text = removeField(text, current, key); + } + } + const replacedScalars /*: {[string]: string} */ = + change.replacedScalars ?? {}; + for (const key of Object.keys(replacedScalars)) { + const current = dict(); + if (current != null) { + text = removeField(text, current, key); + const replacement = dict(); + if (replacement != null) { + const originalValue = replacedScalars[key]; + if (typeof originalValue === 'string') { + text = ensureScalarField(text, replacement, key, originalValue); + } + } + } + } + } + return text; +} + +/** + * The exact inverse of `add` (injectSpmIntoExistingXcodeproj): using the + * `.spm-injected.json` marker's precise record of every edit, remove only what + * injection added — leaving any other (user) edits made afterwards intact. No + * `git checkout`, no prompt. Returns {status:'absent'} when the project was + * never injected. + */ +function removeSpmInjection( + opts /*: {appRoot: string, xcodeprojPath: string} */, +) /*: {status: 'removed', target: string} | {status: 'absent'} */ { + const {appRoot, xcodeprojPath} = opts; + const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); + if (!fs.existsSync(markerPath)) { + return {status: 'absent'}; + } + // $FlowFixMe[incompatible-type] JSON.parse returns any + const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')); + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); + let text = fs.readFileSync(pbxprojPath, 'utf8'); + + const injectedUuids /*: Array */ = marker.injectedUuids ?? []; + + // 1. Drop our array members, then the array fields we created (now empty), + // then the injected object definitions. + text = removeArrayMembersByUuid(text, injectedUuids); + for (const f of marker.createdArrayFields ?? []) { + const obj = + f.container === 'project' + ? findProjectObject(text) + : findObjectByUuid(text, marker.targetUuid); + if (obj != null) { + text = removeField(text, obj, f.key); + } + } + for (const uuid of injectedUuids) { + text = removeObjectByUuid(text, uuid); + } + // Drop any section that injection created and we just emptied (e.g. + // XCLocalSwiftPackageReference) — a well-formed pbxproj never carries an + // empty `/* Begin X *​/ /* End X *​/` section, so this lands byte-identical. + text = text.replace( + /\/\* Begin (\w+) section \*\/\n\/\* End \1 section \*\/\n\n/g, + '', + ); + + // 2. Reverse the per-config build-setting edits (only what we added). + text = removeRecordedBuildSettings(text, marker.buildSettingChanges ?? []); + 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. + const scheme = marker.scheme; + if (scheme != null && scheme.file != null) { + const schemePath = path.join( + xcodeprojPath, + 'xcshareddata', + 'xcschemes', + scheme.file, + ); + if (scheme.created === true) { + fs.rmSync(schemePath, {force: true}); + } else if (fs.existsSync(schemePath)) { + const xml = fs.readFileSync(schemePath, 'utf8'); + writeIfChanged(schemePath, removePreActionFromScheme(xml)); + } + } + + // 4. Drop the marker — the project is no longer SPM-injected. + fs.rmSync(markerPath, {force: true}); + return {status: 'removed', target: marker.target}; +} + +module.exports = { + generateXcscheme, + buildSyncAutolinkingScript, + buildSchemePreActionScript, + buildEmbedFrameworksScript, + flavorForBuildConfiguration, + frameworkConditionalSettings, + ensureStubPackages, + buildSpmDependencyGraph, + spmGraphToEntries, + planInjection, + injectSpmIntoPbxproj, + injectSpmIntoExistingXcodeproj, + removeSpmInjection, + cleanupLeftoverPodsGroup, + cleanupDanglingJavaScriptCoreRef, + addPreActionToScheme, + removePreActionFromScheme, + findInjectedXcodeproj, + readArtifactsVersionOverride, + SPM_INJECTED_MARKER, +}; diff --git a/packages/react-native/scripts/spm/read-podspec.js b/packages/react-native/scripts/spm/read-podspec.js new file mode 100644 index 000000000000..cb79aec7a03e --- /dev/null +++ b/packages/react-native/scripts/spm/read-podspec.js @@ -0,0 +1,695 @@ +/** + * 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'; + +/*:: import type {PodspecModel, PreprocessorDefine} from './spm-types'; */ + +/** + * read-podspec.js — produces a flattened, SPM-friendly view of an iOS + * podspec for the scaffolder. + * + * Strategy: prefer `pod ipc spec ` (CocoaPods evaluates the Ruby DSL, + * including `install_modules_dependencies(s)` and `$config[:...]` + * interpolation, so the JSON output reflects the real spec). Fall back to a + * best-effort regex parser when CocoaPods isn't available — handles simple + * RN libs but skips subspec blocks and Ruby helpers with a warning. + * + * The output `PodspecModel` collapses the spec's default subspecs into a + * single logical target view, since that's how RN consumers compile these + * libraries in practice. + */ + +const {spawnSync} = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/*:: +type RawSpec = {[string]: unknown}; +*/ + +// --------------------------------------------------------------------------- +// Pod-IPC primary path +// --------------------------------------------------------------------------- + +/** + * Runs `pod ipc spec ` and parses the JSON it prints. Returns null + * on any failure: CocoaPods missing, command threw, output not JSON, etc. + * Callers should treat null as a signal to fall back to the regex parser. + */ +/** + * Strips RN-specific Ruby helpers from a podspec source so `pod ipc spec` + * can evaluate it without RN's Podfile-side helpers loaded. The stripped + * helpers (`install_modules_dependencies(s)`) typically inject the React-Core + * / React-Fabric family — all of which the scaffolder collapses into the + * single `ReactNative` product anyway, so dropping them is safe as long as + * the podspec also has an explicit `s.dependency "React-Core"` (almost all + * RN libs do). Writes the patched content to a temp file and returns its + * path; caller deletes when done. + */ +function patchPodspecForPodIpc(podspecPath /*: string */) /*: string */ { + const content = fs.readFileSync(podspecPath, 'utf8'); + // Comment out RN-Podfile-only helpers. We do NOT remove the lines so + // line numbers in any pod ipc errors still match the original file. + const patched = content + .replace( + /^(\s*)install_modules_dependencies\(([^)]*)\)/gm, + '$1# install_modules_dependencies($2) # stripped for pod ipc', + ) + .replace( + /^(\s*)use_react_native!\(([^)]*)\)/gm, + '$1# use_react_native!($2) # stripped for pod ipc', + ); + // Write the patched copy NEXT TO the original podspec, not in os.tmpdir. + // Podspecs commonly do `File.read(File.join(__dir__, 'package.json'))` or + // similar, expecting the package.json sibling. Keeping the patched file + // in the same directory makes those reads continue to work. + const depDir = path.dirname(podspecPath); + // pod ipc only accepts files ending in `.podspec` or `.podspec.json`. + // Use a dotfile prefix so the patched copy is invisible to normal listing + // but the suffix CocoaPods requires is preserved. + const tmpFile = path.join( + depDir, + `.spm-scaffold-${process.pid}-${path.basename(podspecPath)}`, + ); + fs.writeFileSync(tmpFile, patched, 'utf8'); + return tmpFile; +} + +function runPodIpcSpec(podspecPath /*: string */) /*: RawSpec | null */ { + // RN podspecs often call `install_modules_dependencies(s)`, a helper + // defined by RN's Podfile-side scripts. `pod ipc spec` doesn't load + // those, so the helper is undefined and the whole spec fails to parse. + // Pre-process to strip those calls before invoking pod ipc. + let patchedPath /*: ?string */ = null; + try { + patchedPath = patchPodspecForPodIpc(podspecPath); + } catch { + return null; + } + let result; + try { + result = spawnSync('pod', ['ipc', 'spec', patchedPath], { + encoding: 'utf8', + timeout: 30000, + maxBuffer: 8 * 1024 * 1024, + // Many RN podspecs gate subspec definitions on `RCT_NEW_ARCH_ENABLED` + // (e.g. safe-area-context wraps its Fabric `common` + `fabric` + // subspecs in `if fabric_enabled`). RN 0.76+ defaults to the new + // architecture, so unless the caller has explicitly opted out, set + // the env var here so `pod ipc spec` evaluates the full podspec. + env: { + ...process.env, + RCT_NEW_ARCH_ENABLED: process.env.RCT_NEW_ARCH_ENABLED ?? '1', + }, + }); + } catch { + cleanupPatchedPodspec(patchedPath); + return null; + } + cleanupPatchedPodspec(patchedPath); + if (result == null || result.error != null) { + return null; + } + if (typeof result.status !== 'number' || result.status !== 0) { + return null; + } + if (typeof result.stdout !== 'string' || result.stdout.length === 0) { + return null; + } + // Some podspecs print diagnostics to stdout during evaluation (e.g. skia: + // `-- SK_GRAPHITE: OFF ...`) before `pod ipc` emits the JSON, and a + // diagnostic can itself contain braces (`Building {module}`). `pod ipc` + // pretty-prints the spec as a JSON object whose outermost braces sit at + // column 0, so ANCHOR on a line-boundary `{`…`}` rather than the first/last + // brace anywhere — otherwise a brace inside a diagnostic shifts the slice and + // JSON.parse throws. When the authoritative parse is abandoned we log (not + // silently drop to the much weaker, subspec-blind regex parser). + const stdout = result.stdout; + const startMatch = stdout.match(/^\{/m); + const start = startMatch != null ? startMatch.index : -1; + const lastLineBrace = stdout.lastIndexOf('\n}'); + const end = + lastLineBrace >= 0 ? lastLineBrace + 2 : stdout.lastIndexOf('}') + 1; + if (start < 0 || end <= start) { + console.warn( + '[read-podspec] pod ipc spec produced no line-anchored JSON object; ' + + 'falling back to the regex parser (subspecs/helpers may be missed).', + ); + return null; + } + try { + return JSON.parse(stdout.slice(start, end)); + } catch (e) { + console.warn( + `[read-podspec] pod ipc spec JSON parse failed (${e.message}); ` + + 'falling back to the regex parser (subspecs/helpers may be missed).', + ); + return null; + } +} + +function cleanupPatchedPodspec(patchedPath /*: ?string */) /*: void */ { + if (patchedPath == null) return; + try { + fs.unlinkSync(patchedPath); + } catch { + // best-effort cleanup; the file is named with `.spm-scaffold-...tmp` + // so a leftover is identifiable. + } +} + +// --------------------------------------------------------------------------- +// Regex fallback +// --------------------------------------------------------------------------- + +/** + * Best-effort Ruby podspec parser. Extracts the literal-string and + * literal-array fields most RN libs use. Skips subspec blocks, Ruby helper + * calls (install_modules_dependencies, ENV[], $config[:...]), and + * interpolation — appends a warning so the caller can surface it to the + * user. Always returns a RawSpec; pure-JS, no Ruby dep required. + */ +function regexPodspec(podspecPath /*: string */) /*: RawSpec */ { + const content = fs.readFileSync(podspecPath, 'utf8'); + const warnings /*: Array */ = []; + + // Matches: s. = "value" or s. = 'value' + function getStringField(name /*: string */) /*: string | null */ { + const re = new RegExp(`(?:s|spec)\\.${name}\\s*=\\s*["']([^"']+)["']`); + const m = content.match(re); + return m ? m[1] : null; + } + + // Matches: + // s. = ["a", "b"] (array) + // s. = "single" (single value treated as 1-element array) + function getArrayField(name /*: string */) /*: Array */ { + const reArr = new RegExp(`(?:s|spec)\\.${name}\\s*=\\s*\\[([^\\]]*)\\]`); + const mArr = content.match(reArr); + if (mArr != null) { + const inner = mArr[1]; + const out /*: Array */ = []; + const itemRe = /["']([^"']+)["']/g; + while (true) { + const m = itemRe.exec(inner); + if (m == null) break; + out.push(m[1]); + } + return out; + } + const single = getStringField(name); + return single != null ? [single] : []; + } + + // Matches: s.dependency "Name" (with optional version constraint) + function getDependencies() /*: Array */ { + const out /*: Array */ = []; + const re = /(?:s|spec)\.dependency\s+["']([^"']+)["']/g; + while (true) { + const m = re.exec(content); + if (m == null) break; + out.push(m[1]); + } + return out; + } + + // Matches: + // s.framework "X" → ["X"] + // s.framework = "X" → ["X"] + // s.frameworks = ["X","Y"] → ["X","Y"] + function getFrameworks(weak /*: boolean */) /*: Array */ { + const prefix = weak ? 'weak_framework' : 'framework'; + const out /*: Array */ = []; + // Plural form: s.frameworks = [...] or s.frameworks = "Foo" + out.push(...getArrayField(`${prefix}s`)); + // Singular form: s.framework = "Foo" — single value treated as a 1-elem list + out.push(...getArrayField(prefix)); + // Method-call form: s.framework "Foo", "Bar" — no `=`. `getArrayField` + // only matches assignment, so handle this case separately. + const callRe = new RegExp( + `(?:s|spec)\\.${prefix}s?\\s+((?:["'][^"']+["']\\s*,?\\s*)+)`, + 'g', + ); + while (true) { + const m = callRe.exec(content); + if (m == null) break; + const itemRe = /["']([^"']+)["']/g; + while (true) { + const im = itemRe.exec(m[1]); + if (im == null) break; + out.push(im[1]); + } + } + return Array.from(new Set(out)); + } + + // Matches: 'HEADER_SEARCH_PATHS' => '...' or => ["...","..."] + // Returns the raw token strings (still containing $(PODS_TARGET_SRCROOT) + // etc.); substitution happens at translation time. + function getHeaderSearchPaths() /*: Array */ { + const out /*: Array */ = []; + // Array form + const arrRe = /["']HEADER_SEARCH_PATHS["']\s*=>\s*\[([\s\S]*?)\]/g; + while (true) { + const m = arrRe.exec(content); + if (m == null) break; + const itemRe = /["']((?:[^"'\\]|\\.)+)["']/g; + while (true) { + const im = itemRe.exec(m[1]); + if (im == null) break; + // Each entry can be a single path or a space-separated list of paths. + for (const token of im[1].split(/\s+/)) { + if (token.length > 0) { + out.push(stripWrappingQuotes(token)); + } + } + } + } + // String form + const strRe = + /["']HEADER_SEARCH_PATHS["']\s*=>\s*["']((?:[^"'\\]|\\.)+)["']/g; + while (true) { + const m = strRe.exec(content); + if (m == null) break; + for (const token of m[1].split(/\s+/)) { + if (token.length > 0) { + out.push(stripWrappingQuotes(token)); + } + } + } + return Array.from(new Set(out)); + } + + // Surface known unparseable constructs so the caller can warn the user. + if (/(?:s|spec)\.subspec\s+["']/.test(content)) { + warnings.push( + 'Subspecs detected — regex parser only extracts top-level fields. Install CocoaPods (`gem install cocoapods`) to enable full `pod ipc spec` parsing.', + ); + } + if (/install_modules_dependencies/.test(content)) { + warnings.push( + '`install_modules_dependencies(s)` detected — dependency wiring may be incomplete without CocoaPods.', + ); + } + if (/\bENV\[/.test(content) || /\$config\[/.test(content)) { + warnings.push( + 'Env-var or Ruby-config interpolation detected — values may not translate cleanly without CocoaPods.', + ); + } + + return { + name: getStringField('name'), + version: getStringField('version'), + source_files: getArrayField('source_files'), + public_header_files: getArrayField('public_header_files'), + private_header_files: getArrayField('private_header_files'), + exclude_files: getArrayField('exclude_files'), + header_mappings_dir: getStringField('header_mappings_dir'), + header_dir: getStringField('header_dir'), + frameworks: getFrameworks(false), + weak_frameworks: getFrameworks(true), + libraries: getArrayField('libraries'), + dependencies: getDependencies(), + compiler_flags: tokenizeFlags(getStringField('compiler_flags')), + pod_target_xcconfig: {HEADER_SEARCH_PATHS: getHeaderSearchPaths()}, + resources: getArrayField('resources'), + requires_arc: /(?:s|spec)\.requires_arc\s*=\s*true/.test(content), + __regex_partial__: true, + __warnings__: warnings, + }; +} + +// Strips outer quote-like characters from a captured token. Handles bare +// quotes (`"foo"`, `'foo'`) AND Ruby-escaped quotes (`\"foo\"`) which show +// up verbatim when the regex captures something like +// `"HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/x\""`. +function stripWrappingQuotes(s /*: string */) /*: string */ { + return s.replace(/^\\?["']/, '').replace(/\\?["']$/, ''); +} + +function tokenizeFlags(value /*: string | null */) /*: Array */ { + if (value == null) return []; + return value.split(/\s+/).filter(Boolean); +} + +/** + * Split a flag string on whitespace while keeping quoted spans intact, so a + * define like `-DWORKLETS_FEATURE_FLAGS="[A:false][B:true]"` stays one token + * (the quotes are part of the macro value). A naive whitespace split would + * shred any define whose value contains spaces. + */ +function shellTokenize(value /*: string */) /*: Array */ { + const tokens /*: Array */ = []; + let cur = ''; + let quote /*: string | null */ = null; + let has = false; + for (let i = 0; i < value.length; i++) { + const c = value[i]; + if (quote != null) { + cur += c; + if (c === quote) quote = null; + } else if (c === '"' || c === "'") { + cur += c; + quote = c; + has = true; + } else if (/\s/.test(c)) { + if (has) tokens.push(cur); + cur = ''; + has = false; + } else { + cur += c; + has = true; + } + } + if (has) tokens.push(cur); + return tokens; +} + +// --------------------------------------------------------------------------- +// Subspec flattening +// --------------------------------------------------------------------------- + +/** + * Merges a podspec's default subspecs (or all subspecs when no defaults are + * declared) into a single logical view. RN libraries use subspecs for + * platform/feature gating (`apple` vs `common`); consumers compile the + * union anyway, so the flat view matches the actual build. + * + * Merge rules: + * - Array fields → concat + dedup + * - String fields → top-level wins; if absent, take the first subspec's value + * - pod_target_xcconfig HEADER_SEARCH_PATHS → array-union across all selected subspecs + * - dependencies → array-union of name strings (version constraints dropped) + */ +function flattenSubspecs(rawSpec /*: RawSpec */) /*: PodspecModel */ { + const warnings /*: Array */ = []; + // $FlowFixMe[prop-missing] dynamic shape + const partial /*: boolean */ = rawSpec.__regex_partial__ === true; + if (Array.isArray(rawSpec.__warnings__)) { + // $FlowFixMe[incompatible-type] runtime-validated dynamic shape + const rawWarnings /*: ReadonlyArray */ = rawSpec.__warnings__; + for (const w of rawWarnings) { + warnings.push(w); + } + } + + // Determine which subspecs to merge. pod ipc returns: + // - `subspecs`: array of nested specs (or undefined) + // - `default_subspecs`: array of names (or undefined → use all subspecs) + const subspecs = Array.isArray(rawSpec.subspecs) ? rawSpec.subspecs : []; + let selected = subspecs; + if (Array.isArray(rawSpec.default_subspecs)) { + const wanted = new Set(rawSpec.default_subspecs); + selected = subspecs.filter(s => { + // $FlowFixMe[prop-missing] dynamic shape + const name = s != null && typeof s.name === 'string' ? s.name : null; + return name != null && wanted.has(name); + }); + } + const layers = [rawSpec, ...selected]; + + function mergeArrayField(key /*: string */) /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer[key] : null; + if (Array.isArray(value)) { + for (const v of value) { + if (typeof v === 'string') out.push(v); + } + } else if (typeof value === 'string') { + out.push(value); + } + } + return Array.from(new Set(out)); + } + + function mergeStringField(key /*: string */) /*: string | null */ { + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer[key] : null; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return null; + } + + function mergeHeaderSearchPaths() /*: Array */ { + const out /*: Array */ = []; + // HSP can live in any of the xcconfig blocks, and a single value often + // PACKS multiple space-separated paths, each individually quoted, plus a + // CocoaPods `/**` recursive-glob suffix (e.g. skia: + // `"$(SRCROOT)/cpp/"/** "$(SRCROOT)/cpp/skia" ...`). Shell-tokenize to keep + // each path intact, then strip ALL quotes (not just wrapping) per token. + const XCCONFIG_KEYS = [ + 'pod_target_xcconfig', + 'xcconfig', + 'user_target_xcconfig', + ]; + for (const layer of layers) { + if (layer == null || typeof layer !== 'object') continue; + for (const xcKey of XCCONFIG_KEYS) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed` + const xc = layer[xcKey]; + if (xc == null || typeof xc !== 'object') continue; + const hsp = xc.HEADER_SEARCH_PATHS; + const values = + typeof hsp === 'string' + ? [hsp] + : Array.isArray(hsp) + ? hsp.filter(v => typeof v === 'string') + : []; + for (const v of values) { + for (const tok of shellTokenize(v)) { + const cleaned = tok.replace(/['"]/g, ''); + if (cleaned.length > 0) out.push(cleaned); + } + } + } + } + return Array.from(new Set(out)); + } + + // Lift preprocessor defines from pod_target_xcconfig across all layers: + // `-D` tokens in OTHER_CFLAGS, and NAME[=VALUE] entries in + // GCC_PREPROCESSOR_DEFINITIONS (incl. per-config `[config=*Debug*]` keys). + // Non-define compiler flags in OTHER_CFLAGS are intentionally dropped — only + // `-D`s are safe to forward; arbitrary flags may be machine- or + // example-app-specific. $(inherited), unresolved $(...) tokens, and invalid + // C identifiers are skipped. + function mergePreprocessorDefines() /*: Array */ { + const out /*: Array */ = []; + const seen /*: Set */ = new Set(); + const validName = /^[A-Za-z_]\w*$/; + const add = ( + name /*: string */, + value /*: ?string */, + config /*: ?('debug' | 'release') */, + ) => { + if (!validName.test(name)) return; + if (/\$[({]/.test(name) || (value != null && /\$[({]/.test(value))) { + return; // unresolved Xcode/Ruby token — don't emit a broken define + } + const key = `${name}|${config ?? ''}`; + if (seen.has(key)) return; + seen.add(key); + out.push({name, value, config}); + }; + // Defines can live in the target xcconfig (pod_target_xcconfig) OR the + // aggregate/user xcconfig (`s.xcconfig` / user_target_xcconfig). worklets + // puts its version define in pod_target_xcconfig; reanimated puts its in + // `s.xcconfig` — scan all three. + const XCCONFIG_KEYS = [ + 'pod_target_xcconfig', + 'xcconfig', + 'user_target_xcconfig', + ]; + for (const layer of layers) { + if (layer == null || typeof layer !== 'object') continue; + for (const xcKey of XCCONFIG_KEYS) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed` + const xc = layer[xcKey]; + if (xc == null || typeof xc !== 'object') continue; + for (const rawKey of Object.keys(xc)) { + const cflags = /^OTHER_CFLAGS(?:\[config=\*(\w+)\*\])?$/i.exec( + rawKey, + ); + const ppDefs = + /^GCC_PREPROCESSOR_DEFINITIONS(?:\[config=\*(\w+)\*\])?$/i.exec( + rawKey, + ); + if (cflags == null && ppDefs == null) continue; + const cfgRaw = ( + (cflags?.[1] ?? ppDefs?.[1] ?? '') + '' + ).toLowerCase(); + const config = + cfgRaw === 'debug' + ? 'debug' + : cfgRaw === 'release' + ? 'release' + : null; + // $FlowFixMe[incompatible-use] xc value access is intentional + const val = xc[rawKey]; + const strs = + typeof val === 'string' + ? [val] + : Array.isArray(val) + ? val.filter(v => typeof v === 'string') + : []; + for (const s of strs) { + for (const tok of shellTokenize(s)) { + if (tok === '$(inherited)') continue; + // OTHER_CFLAGS: only `-D` tokens are defines; others are flags. + // GCC_PREPROCESSOR_DEFINITIONS: every token is `NAME[=VALUE]`. + let body /*: ?string */ = null; + if (cflags != null) { + if (tok.startsWith('-D')) body = tok.slice(2); + } else { + body = tok; + } + if (body == null || body.length === 0) continue; + const eq = body.indexOf('='); + const name = eq >= 0 ? body.slice(0, eq) : body; + const value = eq >= 0 ? body.slice(eq + 1) : null; + add(name, value, config); + } + } + } + } + } + return out; + } + + function mergeDependencies() /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const deps = layer != null ? layer.dependencies : null; + if (deps == null) continue; + // pod ipc returns deps as: {name: [versionConstraint, ...], ...} + // regex returns deps as: [name, name, ...] + if (Array.isArray(deps)) { + for (const d of deps) { + if (typeof d === 'string') out.push(d); + } + } else if (typeof deps === 'object') { + for (const name of Object.keys(deps)) { + out.push(name); + } + } + } + return Array.from(new Set(out)); + } + + function mergeCompilerFlags() /*: Array */ { + const out /*: Array */ = []; + for (const layer of layers) { + // $FlowFixMe[incompatible-use] layer narrowed from `mixed`; runtime-validated below + const value = layer != null ? layer.compiler_flags : null; + if (typeof value === 'string') { + for (const tok of value.split(/\s+/)) { + if (tok.length > 0) out.push(tok); + } + } else if (Array.isArray(value)) { + for (const v of value) { + if (typeof v === 'string') { + for (const tok of v.split(/\s+/)) { + if (tok.length > 0) out.push(tok); + } + } + } + } + } + return out; + } + + // $FlowFixMe[prop-missing] dynamic shape + const reqArc /*: unknown */ = rawSpec.requires_arc; + const requiresArc = + reqArc === false || (Array.isArray(reqArc) && reqArc.length === 0) + ? false + : true; // RN ecosystem default + + return { + name: mergeStringField('name') ?? '', + version: mergeStringField('version') ?? '', + sourceFiles: mergeArrayField('source_files'), + publicHeaderFiles: mergeArrayField('public_header_files'), + privateHeaderFiles: mergeArrayField('private_header_files'), + excludeFiles: mergeArrayField('exclude_files'), + headerMappingsDir: mergeStringField('header_mappings_dir'), + // ALL subspecs' header_mappings_dir values (not just the merged one). Each + // implies a header search path of its parent dir so namespaced includes + // (`` for a header physically at + // `apple/reanimated/apple/sensor/X.h` with mappings dir `apple/reanimated`) + // resolve from the physical tree — CocoaPods does this via the + // header_mappings_dir copy step, which SPM has no equivalent for. + headerMappingsDirs: mergeArrayField('header_mappings_dir'), + headerDir: mergeStringField('header_dir'), + frameworks: mergeArrayField('frameworks'), + weakFrameworks: mergeArrayField('weak_frameworks'), + libraries: mergeArrayField('libraries'), + dependencies: mergeDependencies(), + preprocessorDefines: mergePreprocessorDefines(), + compilerFlags: mergeCompilerFlags(), + headerSearchPaths: mergeHeaderSearchPaths(), + resources: mergeArrayField('resources'), + requiresArc, + warnings, + partial, + // Default: overridden in readPodspec, which can see the podspec source. + usesInstallModulesDependencies: false, + }; +} + +// --------------------------------------------------------------------------- +// Top-level entry +// --------------------------------------------------------------------------- + +/** + * Reads and flattens a podspec at `podspecPath`. Tries `pod ipc spec` first + * (full Ruby DSL evaluation); falls back to a regex parser when CocoaPods + * isn't available. + * + * Throws when the file doesn't exist. Otherwise always returns a + * PodspecModel — warnings on the model surface partial parses to the + * caller, who can decide whether to proceed or abort scaffolding for that + * dep. + */ +function readPodspec(podspecPath /*: string */) /*: PodspecModel */ { + if (!fs.existsSync(podspecPath)) { + throw new Error(`readPodspec: file does not exist: ${podspecPath}`); + } + const podIpc = runPodIpcSpec(podspecPath); + const raw = podIpc != null ? podIpc : regexPodspec(podspecPath); + const model = flattenSubspecs(raw); + // Record whether the podspec calls `install_modules_dependencies(s)`. We + // strip that helper before `pod ipc` and the regex parser can't expand it, + // so the React-Core family it injects never lands in `model.dependencies`. + // The scaffolder uses this as an implicit React-core marker (plain ObjC + // modules using the helper have no `codegenConfig` to detect instead). + try { + const src = fs.readFileSync(podspecPath, 'utf8'); + model.usesInstallModulesDependencies = + /\binstall_modules_dependencies\b/.test(src); + } catch { + // best-effort; leave the flattenSubspecs default (false) + } + return model; +} + +module.exports = { + readPodspec, + runPodIpcSpec, + regexPodspec, + flattenSubspecs, +}; diff --git a/packages/react-native/scripts/spm/scaffold-package-swift.js b/packages/react-native/scripts/spm/scaffold-package-swift.js new file mode 100644 index 000000000000..0e396052b256 --- /dev/null +++ b/packages/react-native/scripts/spm/scaffold-package-swift.js @@ -0,0 +1,1206 @@ +/** + * 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'; + +/*:: +import type { + AutolinkedDep, + AutolinkingIosPlatform, + PodspecModel, + ScaffoldResult, + SpmScaffoldSpec, +} from './spm-types'; +*/ + +/** + * scaffold-package-swift.js — generates a `Package.swift` into + * `node_modules//` for community RN libraries that don't ship SPM + * support. The autolinker's existing `isSelfManagedPackage` check + * (generate-spm-autolinking.js, AUTOGEN_MARKER) treats these scaffolded + * files as self-managed and references them directly from the aggregator — + * no autolinker changes required. + * + * Three layers: + * - translatePodspecToSpmTarget(model, dep): pure translation + * - emitScaffoldedPackageSwift(spec, ctx): pure Swift emission + * - scaffoldPackageSwiftForDep(dep, ctx): I/O + skip-rule + write + * - scaffoldAll(opts): orchestrator over autolinking.json + */ + +const { + defaultReadConfig, + defaultResolveDep, + expandSpmDependencies, +} = require('./expand-spm-dependencies'); +const {expandSpmSourceGlobs} = require('./generate-spm-autolinking'); +const {readPodspec} = require('./read-podspec'); +const { + SCAFFOLDER_MARKER, + makeLogger, + remotePackageConfig, + toSwiftName, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); + +const {log} = makeLogger('scaffold-package-swift'); + +// SCAFFOLDER_MARKER lives in spm-utils.js (shared, no import cycle). It must NOT +// contain the autolinker's AUTOGEN_MARKER ('// AUTO-GENERATED by +// scripts/generate-spm-autolinking.js') — that marker is what the autolinker +// uses to tell self-managed deps from its own synth output. Our marker is +// recognized by the scaffolder itself (skip-rule), but invisible to the +// autolinker (treated as self-managed). + +// Bump when the emitter's output format changes in a way that requires +// re-scaffolding existing files (rather than just being a content drift the +// cache-slot label already handles). v2: swift-tools-version moved to +// line 1 — pre-v2 scaffolds emit it on a later line, which recent Xcode +// rejects with "Swift tools version 3.1.0 ... no longer supported". v4: the +// single `let rnHeaders = appRoot + "/.../ReactHeadersAll"` split into the two +// `rnCoreHeaders` / `appHeaders` lets (merged tree replaced by two trees), so +// pre-v4 scaffolds reference a tree that is no longer materialized. v5: +// header resolution moved to product dependencies (ReactNativeHeaders + +// ReactAppHeaders binary/headers targets) — the rnCoreHeaders/appHeaders +// trees no longer exist, so pre-v5 scaffolds carry dead lets and would break +// if anything still referenced them. v7: the runtime appRoot walker and the +// siblingPath helper are gone — package paths are plain relative strings +// computed at scaffold time (the walker anchored on +// build/xcframeworks/Package.swift, which remote mode no longer writes). +// +// Skip-rule contract: when an existing file's version is < this constant, +// the scaffolder regenerates regardless of --force (the bump implies the +// existing file is broken under current tooling). A file with the marker +// but no version line is treated as v1. +// v8: relative app paths (codegen / xcframeworks) are now computed from the +// autolinker's libs/ symlink location instead of the real dep.root, +// fixing a doubled-path resolution failure on fresh SwiftPM resolves. +// v9: header search paths derived from each subspec's header_mappings_dir +// (dirname) so namespaced includes (``) resolve. v10: +// publicHeadersPath derived from the header_mappings_dir namespace root so a +// package exposes `` to dependents; pod-style sibling deps +// (reanimated's `s.dependency "RNWorklets"`) wired to their npm package. v11: +// sibling .package path uses the libs/ symlink name, not the npm +// name (fixes "package ... doesn't exist" on resolve). v12: preprocessor +// defines from pod_target_xcconfig emitted as `.define(...)`. v13: ObjC(++) +// targets get an ambient-import prefix header (Foundation/UIKit) `-include`d, +// replacing CocoaPods' generated prefix.pch. v18: React-core deps also get +// the ReactNativeDependenciesHeaders product (ReactNativeHeaders went +// pure-RN; folly/glog/boost/... now come from the deps sidecar); +// publicHeadersPath falls back to "." for root-level-source podspecs +// (`s.source_files = "*.{h,m,mm}"`) instead of SPM's nonexistent `include/`. +// v19: scaffolded C++ targets carry DEBUG/NDEBUG config defines so their Fabric +// ABI matches the prebuilt React.framework (Release strips DebugStringConvertible +// under NDEBUG). Bumped so existing scaffolds regenerate with the defines. +const SCAFFOLDER_VERSION = 19; +const SCAFFOLDER_VERSION_LINE_RE = /^\/\/ AUTO-SCAFFOLDED-VERSION: (\d+)$/m; + +const AUTOGEN_MARKER = + '// AUTO-GENERATED by scripts/generate-spm-autolinking.js'; + +// CocoaPods auto-generates a `-prefix.pch` that imports Foundation + +// UIKit into every ObjC translation unit, so pod sources can use NSThread / +// dispatch / UIKit symbols without an explicit import. SPM has no prefix-header +// mechanism, so we emit this file at the dep root and `-include` it on the +// target (cSettings + cxxSettings) to reproduce that ambient import. The +// `__OBJC__` guard makes it inert for plain C/C++ sources, and UIKit is +// `__has_include`-guarded for platforms that lack it. +// The config-gated C++ defines the prebuilt React.framework is built with (see +// packages/react-native/Package.swift). Fabric C++ in autolinked deps must +// compile with the SAME NDEBUG state or its DebugStringConvertible / ShadowNode +// ABI diverges from the Release React.framework and the link fails. +const REACT_CXX_CONFIG_DEFINES = [ + '.define("DEBUG", .when(configuration: .debug))', + '.define("NDEBUG", .when(configuration: .release))', +]; + +const SCAFFOLD_PREFIX_HEADER = 'react-native-spm-prefix.h'; +const SCAFFOLD_PREFIX_HEADER_CONTENTS = `// AUTO-SCAFFOLDED by react-native spm scaffold — mirrors CocoaPods' default +// prefix header so ObjC sources that rely on an implicit Foundation/UIKit +// import compile under SPM (which has no prefix-header mechanism). Safe to +// delete + regenerate via \`npx react-native spm scaffold\`. +#ifdef __OBJC__ +#import +#if __has_include() +#import +#endif +#endif +`; + +// Names of deps the scaffolder always refuses to touch — `react-native` +// itself is handled by the xcframework subpackage, never as an autolinked +// target. +const NEVER_SCAFFOLD /*: ReadonlySet */ = new Set(['react-native']); + +// React-core / React-Fabric / etc. dependency names collapse to a single +// invariant React header-product set. The app target links the prebuilt +// runtime frameworks outside SwiftPM. This +// list matches what `install_modules_dependencies(s)` materializes plus +// common podspec hand-rolled additions. +const REACT_CORE_DEP_PREFIXES = [ + 'React-', + 'React_', + 'ReactCommon', + 'RCT-Folly', + 'RCT', + 'glog', + 'boost', + 'fmt', + 'DoubleConversion', + 'Yoga', + 'hermes-engine', +]; + +function isReactCoreDep(name /*: string */) /*: boolean */ { + return REACT_CORE_DEP_PREFIXES.some(p => name.startsWith(p)); +} + +// True when the dep ships a `codegenConfig` in package.json — RN's standard +// marker that the library participates in the New Architecture / codegen. Such +// a lib's Fabric sources include the app-generated component headers +// (`/ShadowNodes.h>` etc.), which are vended by +// the per-app React-GeneratedCode package — so it implicitly depends on React +// core even when its podspec only wires that via `install_modules_dependencies` +// (which we strip). Safe/quiet: a missing or unparseable package.json → false. +function depHasCodegenConfig(depRoot /*: string */) /*: boolean */ { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(depRoot, 'package.json'), 'utf8'), + ); + return pkg != null && pkg.codegenConfig != null; + } catch { + return false; + } +} + +// Every subdirectory (relative to depRoot) under depRoot/base, recursively — +// used to expand a CocoaPods `path/**` recursive header search path into the +// concrete dirs SPM needs (SPM has no recursive search-path syntax). Skips +// VCS / build / dependency noise. +function collectSubdirs( + depRoot /*: string */, + base /*: string */, +) /*: Array */ { + const SKIP /*: Set */ = new Set([ + 'node_modules', + 'Pods', + 'build', + '.git', + ]); + const out /*: Array */ = []; + const walk = (absDir /*: string */, relDir /*: string */) => { + let entries: Array<{name: string, isDirectory(): boolean}>; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(absDir, {withFileTypes: true}); + } catch { + return; + } + for (const e of entries) { + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in stubs + const name /*: string */ = e.name; + if (!e.isDirectory() || name.startsWith('.') || SKIP.has(name)) continue; + const rel = relDir === '.' ? name : `${relDir}/${name}`; + out.push(rel); + walk(path.join(absDir, name), rel); + } + }; + walk(path.join(depRoot, base), base === '.' ? '.' : base); + return out; +} + +// --------------------------------------------------------------------------- +// Translation +// --------------------------------------------------------------------------- + +/** + * Pure: turns a flattened PodspecModel into an SpmScaffoldSpec the emitter + * can consume. Substitutes Xcode build-setting tokens (`$(PODS_TARGET_SRCROOT)`) + * against the dep root, buckets dependencies, drops unknown tokens with a + * warning. + */ +function translatePodspecToSpmTarget( + model /*: PodspecModel */, + dep /*: AutolinkedDep */, + // Maps a podspec name (e.g. "RNWorklets") to the npm package name of an + // autolinked sibling (e.g. "react-native-worklets"). Lets us wire a + // `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(), +) /*: 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")`. + // + // The podspec's `header_dir` is captured separately: when it changes the + // include surface (e.g. `` instead of ``), + // header resolution already works via the -I flags from headerSearchPaths + // (path-style includes like safe-area-context's + // `` 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); + + // Header search paths — substitute Xcode build-setting tokens against the + // dep root. Anything we can't substitute is dropped + warned (avoids + // emitting `$(SOMETHING)` literally into the Swift file). + const headerSearchPaths /*: Array */ = []; + const addSearchPath = (p /*: string */) => { + if (p.length > 0 && !headerSearchPaths.includes(p)) { + headerSearchPaths.push(p); + } + }; + for (const raw of model.headerSearchPaths) { + let substituted = raw + .replace(/\$\(PODS_TARGET_SRCROOT\)/g, '.') + .replace(/\$\{PODS_TARGET_SRCROOT\}/g, '.'); + if (/\$[({]/.test(substituted)) { + warnings.push( + `Dropped HEADER_SEARCH_PATHS entry "${raw}" — contains unresolved Xcode token. ` + + `Add it manually if needed.`, + ); + continue; + } + // CocoaPods `path/**` (or `/*`) = recursive search. SPM has no recursive + // search-path syntax, so add the base dir + every subdirectory under it. + const recursive = /\/\*\*?$/.test(substituted); + substituted = substituted + .replace(/\/\*\*?$/, '') // strip the glob marker + .replace(/\/{2,}/g, '/'); // collapse `cpp//` → `cpp/` + // Strip leading "./" (emitter prefixes with the target path) + trailing "/". + const base = substituted + .replace(/^\.\//, '') + .replace(/^\//, '') + .replace(/\/$/, ''); + addSearchPath(base === '' ? '.' : base); + if (recursive) { + for (const sub of collectSubdirs(dep.root, base === '' ? '.' : base)) { + addSearchPath(sub); + } + } + } + + // header_mappings_dir → search path. CocoaPods exposes a subspec's headers + // under `/...` by copying them into Pods/Headers + // preserving structure relative to the mappings dir. SPM has no such copy + // step, so namespaced includes like `` (header + // physically at `apple/reanimated/apple/sensor/X.h`, mappings dir + // `apple/reanimated`) only resolve if the mappings dir's PARENT (`apple`) is + // on the search path. Add dirname() of every subspec's mappings dir. + for (const mappingsDir of model.headerMappingsDirs) { + const parent = path.posix.dirname(mappingsDir.replace(/^\.\//, '')); + // dirname of a single-segment dir is "." (root) — already implicitly + // searched; skip it and anything that doesn't exist on disk. + if ( + parent.length > 0 && + parent !== '.' && + !headerSearchPaths.includes(parent) && + fs.existsSync(path.join(dep.root, parent)) + ) { + headerSearchPaths.push(parent); + } + } + + // Bucket dependencies. React-Core / React-jsi / RCT-Folly / glog etc. ALL + // collapse to the invariant React header products + // reference because they're bundled in the prebuilt React.xcframework. + // Sibling RN libs (autolinked or self-managed deps) flow through the + // existing spm-deps mechanism. Unknown names are dropped with a warning. + let coreReactNative = false; + const siblingNames /*: Array */ = []; + const selfPodspecName = model.name; + for (const depName of model.dependencies) { + // Cross-subspec refs like "react-native-safe-area-context/common" are a + // CocoaPods construct for one subspec depending on another from the + // SAME podspec. After flattenSubspecs has merged everything into one + // target the subspec ref is meaningless — drop silently. + if (depName.includes('/') && depName.startsWith(`${selfPodspecName}/`)) { + continue; + } + const podSibling = podToNpm.get(depName.split('/')[0]); + if (isReactCoreDep(depName)) { + coreReactNative = true; + } else if (depName.startsWith('react-native-')) { + // Generic cross-subspec ref guard for any "package/subspec" form. + const baseName = depName.split('/')[0]; + if (!siblingNames.includes(baseName)) { + siblingNames.push(baseName); + } + } else if (podSibling != null && podSibling !== dep.name) { + // A pod-style dependency name (e.g. reanimated's `s.dependency + // "RNWorklets"`) that resolves to an autolinked sibling's npm package + // (react-native-worklets). Wire it as a sibling .package/.product so + // the dep's cross-package includes (``) resolve. + if (!siblingNames.includes(podSibling)) { + siblingNames.push(podSibling); + } + } else { + // Could be a non-RN dep ("MMKV", "AFNetworking"). The scaffolder + // doesn't know how to wire those — surface a warning so user can + // edit the generated Package.swift. + warnings.push( + `Unknown dependency "${depName}" — not wired into the scaffolded Package.swift. Edit manually if needed.`, + ); + } + } + + // New-Architecture libraries declare their React-core dependency via the + // `install_modules_dependencies(s)` podspec helper (which auto-adds + // React-Core / React-RCTFabric / React-Codegen), NOT an explicit + // `s.dependency "React-Core"`. We strip that helper when evaluating the + // podspec, so the React-core dependency never surfaces in model.dependencies + // and `coreReactNative` would stay false. The authoritative, RN-standard + // marker for "this lib participates in the New Architecture / codegen" is a + // `codegenConfig` block in package.json — when present, the app's codegen has + // generated `react/renderer/components//{ShadowNodes,Props,…}.h` that + // the lib's Fabric sources include via angle brackets. Those generated + // headers live in the per-app React-GeneratedCode package, so the lib must + // depend on it (and, transitively, on React core). Treat codegenConfig as + // an implicit React-core dependency. + // `codegenConfig` only marks Fabric/New-Arch libraries. A plain ObjC module + // (e.g. rn-tester's TestLibrary*) that wires React core solely through + // `install_modules_dependencies(s)` has no codegenConfig — but the helper's + // presence is itself an authoritative "depends on React core" signal, so + // treat it the same way. + if ( + !coreReactNative && + (depHasCodegenConfig(dep.root) || + model.usesInstallModulesDependencies === true) + ) { + coreReactNative = true; + } + + // SPM's `sources:` field does NOT accept CocoaPods-style globs — it wants + // a list of explicit file paths (relative to the target's `path:`). + // Expand the podspec globs against the actual filesystem so the emitted + // Package.swift parses cleanly. + let expandedSources /*: Array */ = []; + try { + expandedSources = expandSpmSourceGlobs(dep.root, model.sourceFiles); + } catch (e) { + warnings.push( + `Source glob expansion failed (${e.message}); emitting raw globs — SPM will likely reject them.`, + ); + expandedSources = model.sourceFiles; + } + // Strip files matching exclude_files (podspec's negation list). + if (model.excludeFiles.length > 0) { + try { + // $FlowFixMe[untyped-import] micromatch has no types + const micromatch = require('micromatch'); + const isExcluded = micromatch.matcher(model.excludeFiles); + expandedSources = expandedSources.filter(f => !isExcluded(f)); + } catch (e) { + warnings.push( + `exclude_files filtering failed (${e.message}); keeping all sources.`, + ); + } + } + // Never self-ingest scaffold artifacts: a re-scaffold runs with the + // previous run's output on disk, and root-level podspec globs sweep it + // into sources — `*.{h,...,swift}` matches Package.swift itself, whose + // `.swift` extension then trips the mixed-language gate into DELETING the + // manifest it just wrote (alternating scaffold/self-destruct runs); the + // prefix header and the include/ shim mirror would similarly feed back. + expandedSources = expandedSources.filter( + f => + f !== SCAFFOLD_PREFIX_HEADER && + f !== 'Package.swift' && + !f.startsWith('include/'), + ); + + // Header-map emulation: CocoaPods builds a header map (USE_HEADERMAP=YES by + // default) so a source can `#import "Foo.h"` by bare name regardless of which + // subdirectory Foo.h lives in. SPM has no header map, so add every directory + // that contains a header to the search path. Covers libs (e.g. svg) that + // spread flat-named headers across many subdirs. + const headerFiles = expandedSources.filter(f => /\.(h|hh|hpp)$/i.test(f)); + try { + for (const f of expandSpmSourceGlobs(dep.root, model.publicHeaderFiles)) { + if (/\.(h|hh|hpp)$/i.test(f)) headerFiles.push(f); + } + } catch { + // public_header_files globbing is best-effort — source_files usually + // already covers the headers. + } + for (const f of headerFiles) { + const d = path.posix.dirname(f); + addSearchPath(d === '' ? '.' : d); + } + + // ObjC(++) sources may rely on CocoaPods' implicit prefix-header import of + // Foundation/UIKit. When present, emit + `-include` a prefix header (below). + // The prefix is `-include`d by bare name, so the dep root (".") must be on + // the header search path for clang to find it. + const needsObjCPrefix = expandedSources.some(f => /\.mm?$/.test(f)); + if (needsObjCPrefix && !headerSearchPaths.includes('.')) { + headerSearchPaths.push('.'); + } + + // SPM REQUIRES publicHeadersPath to be a real directory inside the target + // when the target compiles C-family sources (default is "include" which + // typically doesn't exist in a podspec-shaped dep). Pick the first + // existing prefix dir from public_header_files / source_files. The + // chosen dir doesn't need to be the include surface — path-style + // includes (``) resolve via headerSearchPaths + // instead. publicHeadersPath just has to point at a real dir containing + // some .h files so SPM accepts the target definition. + let publicHeadersPath /*: ?string */ = null; + // Prefer the namespace root (parent of a header_mappings_dir). SPM propagates + // a target's publicHeadersPath to DEPENDENT packages as a search path, so + // setting it to e.g. `Common/cpp` (parent of `Common/cpp/worklets`) is what + // lets a sibling package resolve ``. Prefer a cross-platform + // (Common) dir — that holds the C++ API siblings consume — over a + // platform-specific (apple/) one. + const mappingsParents = model.headerMappingsDirs + .map(d => path.posix.dirname(d.replace(/^\.\//, ''))) + .filter( + p => p.length > 0 && p !== '.' && fs.existsSync(path.join(dep.root, p)), + ); + if (mappingsParents.length > 0) { + publicHeadersPath = + mappingsParents.find(p => /(?:^|\/)common(?:\/|$)/i.test(p)) ?? + mappingsParents[0]; + } + if (publicHeadersPath == null) { + for (const glob of [...model.publicHeaderFiles, ...model.sourceFiles]) { + const prefix = glob.split('/')[0]; + if ( + prefix.length > 0 && + !prefix.includes('*') && + fs.existsSync(path.join(dep.root, prefix)) + ) { + publicHeadersPath = prefix; + break; + } + } + } + let namespacedShimHeaders /*: Array */ = []; + if (publicHeadersPath == null) { + // Root-level podspecs (`s.source_files = "*.{h,m,mm}"`) keep their + // headers in the package root. CocoaPods exposes them to dependents as + // `#import ` via header maps; SPM has no equivalent, so + // the scaffold writer mirrors them into a generated include// + // shim dir and publicHeadersPath points there (also fixing SPM's + // rejection over the nonexistent default `include/`). + const rootHeaders = expandedSources.filter( + f => f.endsWith('.h') && !f.includes('/'), + ); + if (rootHeaders.length > 0) { + namespacedShimHeaders = rootHeaders; + publicHeadersPath = 'include'; + } + } + if (publicHeadersPath == null) { + warnings.push( + `Could not infer publicHeadersPath from source globs. SPM may reject the target — add a publicHeadersPath manually to the scaffolded Package.swift.`, + ); + } + + return { + swiftName, + sources: expandedSources, + headerSearchPaths, + preprocessorDefines: model.preprocessorDefines, + needsObjCPrefix, + coreReactNative, + siblingNames, + extraFrameworks: model.frameworks, + weakFrameworks: model.weakFrameworks, + compilerFlags: model.compilerFlags, + publicHeadersPath, + namespacedShimHeaders, + resources: model.resources, + warnings, + }; +} + +// --------------------------------------------------------------------------- +// Swift emission +// --------------------------------------------------------------------------- + +/*:: +type EmitContext = { + // Identifier embedded as a comment so SPM's manifest hash bumps when the + // active xcframework slot changes (otherwise cached evaluations would + // keep pointing at the prior slot's headers). + cacheSlotLabel: ?string, + // Remote SPM package mode (url/version/identity) — see remotePackageConfig. + remote?: ?{url: string, version: string, identity: string}, + // Relative path (posix, from the dep's package dir) to the app's codegen + // package (/build/generated/ios). Computed at scaffold time — + // safe because the file is re-scaffolded per app/cache slot, and any + // node_modules relayout implies a reinstall that drops the file anyway. + codegenPackageDir?: ?string, + // Relative path to the app's local xcframeworks package + // (/build/xcframeworks). Only referenced when remote == null. + localXcfwPackageDir?: ?string, +}; +*/ + +/** + * Renders the SpmScaffoldSpec to a complete Package.swift string suitable + * for writing into `node_modules//`. Fully declarative: all package + * references are plain relative paths computed at scaffold time (no runtime + * discovery — the scaffolder knows both the dep dir and the app root, and + * re-scaffolds whenever either could have moved). + */ +function emitScaffoldedPackageSwift( + spec /*: SpmScaffoldSpec */, + ctx /*:: ?: EmitContext */ = {cacheSlotLabel: null, remote: null}, +) /*: string */ { + const slotComment = + ctx.cacheSlotLabel != null ? `\n// Cache slot: ${ctx.cacheSlotLabel}` : ''; + + // React headers need NO search paths — they come from the React / + // ReactNativeHeaders binaryTargets and the ReactAppHeaders product (see + // the dependencies block below). Per-dep HEADER_SEARCH_PATHS from the + // podspec are emitted as `.headerSearchPath("")` so they resolve + // relative to `path:` ("." below). Custom podspec compiler flags keep + // using unsafeFlags (rare; surfaced verbatim). + const customFlagsCxx = spec.compilerFlags.map(f => `"${f}"`); + + const headerSearchPathDirectives = spec.headerSearchPaths + .map(p => `.headerSearchPath("${p}")`) + .join(', '); + + // Preprocessor defines → `.define("NAME", to: "VALUE", .when(...))`. The + // value is escaped as a Swift string literal because it may itself contain + // quotes (e.g. a string-literal macro `-DFOO="[A:false]"`). + const swiftStr = (s /*: string */) => + `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + const defineDirectives = (spec.preprocessorDefines ?? []) + .map(d => { + const toPart = d.value != null ? `, to: ${swiftStr(d.value)}` : ''; + const condPart = + d.config === 'debug' + ? ', .when(configuration: .debug)' + : d.config === 'release' + ? ', .when(configuration: .release)' + : ''; + return `.define(${swiftStr(d.name)}${toPart}${condPart})`; + }) + .join(', '); + + const settingsEntries = (extra /*: Array */) => { + const parts = [ + defineDirectives, + headerSearchPathDirectives, + ...extra, + ].filter(e => e.length > 0); + return `[${parts.join(', ')}]`; + }; + // Force-include the ambient-import prefix header on ObjC(++) sources (the + // `__OBJC__` guard makes it a no-op for plain C/C++). `-include` resolves the + // bare name via the dep-root header search path (".", ensured in translate). + const prefixFlag = spec.needsObjCPrefix + ? `.unsafeFlags(["-include", "${SCAFFOLD_PREFIX_HEADER}"])` + : ''; + const cSettings = settingsEntries([prefixFlag].filter(e => e.length > 0)); + const cxxSettings = settingsEntries( + [ + prefixFlag, + ...(customFlagsCxx.length > 0 + ? [`.unsafeFlags([${customFlagsCxx.join(', ')}])`] + : []), + // Match the prebuilt React.framework's config-gated C++ ABI. NDEBUG in + // Release strips DebugStringConvertible's vtable (and shifts the + // ShadowNode layout that inherits it), so a Fabric C++ target compiled + // WITHOUT NDEBUG fails to link against a Release React.framework. Mirrors + // packages/react-native/Package.swift's own C++ targets. + ...REACT_CXX_CONFIG_DEFINES, + ].filter(e => e.length > 0), + ); + + // Linker frameworks: defaults + podspec-declared extras + weak frameworks. + // Dedup on render so the user doesn't see duplicate UIKit lines. + const defaults = ['UIKit', 'Foundation', 'CoreGraphics']; + const linkedFrameworks = Array.from( + new Set([...defaults, ...spec.extraFrameworks]), + ); + const linkerEntries = [ + ...linkedFrameworks.map(f => `.linkedFramework("${f}")`), + ...spec.weakFrameworks.map( + f => `.linkedFramework("${f}", .when(platforms: [.iOS]))`, + ), + ]; + + // Dependencies block. Always declares ReactNative if any React-core dep + // was in the podspec; sibling RN deps come from autolinking's existing + // .package(path: ...) graph — we reference them by toSwiftName(npmName) + // since that's what the autolinker registers them under. + const packageDeps /*: Array */ = []; + const targetDeps /*: Array */ = []; + if (spec.coreReactNative) { + // Remote mode: React Native comes from the remote package identity (no + // app-layout knowledge). The per-app codegen package is generated INTO + // 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 codegenDir = ctx.codegenPackageDir; + if (codegenDir == null) { + throw new Error( + 'emitScaffoldedPackageSwift: codegenPackageDir is required when the dep depends on React core.', + ); + } + if (remote != null) { + packageDeps.push( + `.package(url: "${remote.url}", exact: "${remote.version}")`, + ); + } else { + const xcfwDir = ctx.localXcfwPackageDir; + if (xcfwDir == null) { + throw new Error( + 'emitScaffoldedPackageSwift: localXcfwPackageDir is required when no remote package is configured.', + ); + } + packageDeps.push(`.package(name: "ReactNative", 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")', + ); + } + for (const siblingName of spec.siblingNames) { + const swiftSibling = 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 + // `../` (NOT `../`, which would be `libs/` + // and not exist). + packageDeps.push( + `.package(name: "${swiftSibling}", path: "../${swiftSibling}")`, + ); + targetDeps.push( + `.product(name: "${swiftSibling}", package: "${swiftSibling}")`, + ); + } + + // sources: emit only when the podspec declared them. If empty (regex-fallback + // podspec), no `sources:`/`exclude:` is emitted and SPM auto-scans the target + // dir — which for `path: "."` is the entire dep root. That is a known + // limitation of the regex-fallback path (the pod-ipc parse yields explicit + // sources); a future improvement could emit an `exclude:` list for the dep's + // example/test/build dirs. + const sourcesLine = + spec.sources.length > 0 + ? `\n sources: [\n${spec.sources.map(s => ` "${s}",`).join('\n')}\n ],` + : ''; + + const publicHeadersLine = + spec.publicHeadersPath != null + ? `\n publicHeadersPath: "${spec.publicHeadersPath}",` + : ''; + + const resourcesLine = + spec.resources.length > 0 + ? `\n resources: [${spec.resources.map(r => `.copy("${r}")`).join(', ')}],` + : ''; + + const packageDepsBlock = + packageDeps.length > 0 + ? ` dependencies: [\n ${packageDeps.join(',\n ')},\n ],\n` + : ''; + + const targetDepsLine = + targetDeps.length > 0 + ? `\n dependencies: [${targetDeps.join(', ')}],` + : ''; + + return `// swift-tools-version: 6.0 +${SCAFFOLDER_MARKER} +// AUTO-SCAFFOLDED-VERSION: ${SCAFFOLDER_VERSION}${slotComment} +// Edit the contents below if needed and re-run \`npx patch-package \` +// to persist across \`npm install\`. To regenerate from the podspec, remove +// this file (or just this marker) and re-run \`npx react-native spm scaffold\`. +// +// Package references are plain relative paths, computed when this file was +// scaffolded. They stay correct because the file is re-scaffolded per app +// and cache slot, and any node_modules relayout reinstalls this package +// (dropping the file) anyway. + +import PackageDescription + +let package = Package( + name: "${spec.swiftName}", + platforms: [.iOS(.v15)], + products: [ + .library(name: "${spec.swiftName}", targets: ["${spec.swiftName}"]), + ], +${packageDepsBlock} targets: [ + .target( + name: "${spec.swiftName}",${targetDepsLine} + path: ".",${sourcesLine}${publicHeadersLine}${resourcesLine} + cSettings: ${cSettings}, + cxxSettings: ${cxxSettings}, + linkerSettings: [${linkerEntries.join(', ')}] + ), + ], + cxxLanguageStandard: .cxx20 +) +`; +} + +// --------------------------------------------------------------------------- +// Per-dep orchestrator +// --------------------------------------------------------------------------- + +/*:: +type ScaffoldContext = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + // Forces overwrite of files carrying the scaffolder marker. Files + // WITHOUT the marker (user-edited, upstream-shipped) are never touched. + force: boolean, + // When true, no file is written — caller gets the would-be content and + // can preview. + dryRun: boolean, + // Slot label (e.g. "0.87.0-nightly-20260513-6e262624f/debug") embedded as + // a comment so SPM's manifest hash bumps on slot changes. + cacheSlotLabel: ?string, + // 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, +}; +*/ + +/** + * Decides whether to scaffold one dep, runs the translation + emission, + * writes the file (unless dryRun). Pure path/skip logic — all I/O via fs. + */ +function scaffoldPackageSwiftForDep( + dep /*: AutolinkedDep */, + ctx /*: ScaffoldContext */, +) /*: ScaffoldResult */ { + const depName = dep.name; + + if (NEVER_SCAFFOLD.has(depName)) { + return { + depName, + status: 'skipped-is-react-native', + reason: 'react-native itself is handled by the xcframework package.', + }; + } + + // ios platform missing → no native code to wrap. + if (dep.platforms.ios == null) { + return { + depName, + status: 'skipped-no-ios', + reason: 'autolinking.json has no ios platform entry.', + }; + } + + // Dep can opt out via its own react-native.config.js. + const cfg = defaultReadConfig(dep.root); + // $FlowFixMe[prop-missing] config has dynamic shape + if (cfg != null && cfg.spm != null && cfg.spm.scaffold === false) { + return { + depName, + status: 'skipped-opt-out', + reason: 'react-native.config.js sets spm.scaffold = false.', + }; + } + + const pkgSwiftPath = path.join(dep.root, 'Package.swift'); + + // A self-managed Package.swift may live either at the dep root (legacy + // convention) or inside ios/ (preferred for community RN libs that want + // their npm-package root free of SPM artifacts). If a nested manifest + // exists and is user-authored, skip — writing a stray root manifest would + // cause the autolinker to prefer the wrong file. + const nestedPkgSwiftPath = path.join(dep.root, 'ios', 'Package.swift'); + if (!fs.existsSync(pkgSwiftPath) && fs.existsSync(nestedPkgSwiftPath)) { + const nested = fs.readFileSync(nestedPkgSwiftPath, 'utf8'); + if ( + !nested.includes(AUTOGEN_MARKER) && + !nested.includes(SCAFFOLDER_MARKER) + ) { + return { + depName, + status: 'skipped-self-managed', + reason: + 'Existing ios/Package.swift was not produced by this scaffolder. ' + + 'Leaving it alone.', + }; + } + } + + // Skip rules around existing files. + if (fs.existsSync(pkgSwiftPath)) { + const existing = fs.readFileSync(pkgSwiftPath, 'utf8'); + if (existing.includes(AUTOGEN_MARKER)) { + return { + depName, + status: 'skipped-autogen', + reason: + 'Existing Package.swift carries the autolinker AUTOGEN marker. ' + + 'spm update will regenerate the synth wrapper.', + }; + } + if (existing.includes(SCAFFOLDER_MARKER)) { + // Version bump → unconditional regen. A bumped SCAFFOLDER_VERSION + // implies the emitter's output format changed in a way that requires + // regenerating existing files; --force is not needed here. + const vMatch = existing.match(SCAFFOLDER_VERSION_LINE_RE); + const existingVersion = vMatch != null ? parseInt(vMatch[1], 10) : 1; + const versionStale = existingVersion < SCAFFOLDER_VERSION; + if (!ctx.force && !versionStale) { + // Slot label change is the other auto-regen trigger — bumps SPM's + // manifest hash so the cache-slot path gets re-resolved. + const m = existing.match(/^\/\/ Cache slot: (.+)$/m); + const existingSlot = m != null ? m[1] : null; + if (ctx.cacheSlotLabel == null || existingSlot === ctx.cacheSlotLabel) { + return { + depName, + status: 'skipped-scaffolder-marker', + reason: + 'Already scaffolded for this cache slot. Pass --force to regenerate.', + }; + } + } + if (versionStale) { + log( + `Re-scaffolding ${depName} (format v${existingVersion} → v${SCAFFOLDER_VERSION})`, + ); + } + // Fall through: regenerate (force, slot changed, or version bumped). + } else { + // No marker at all → user-managed or upstream-shipped. Never touch. + return { + depName, + status: 'skipped-self-managed', + reason: + 'Existing Package.swift was not produced by this scaffolder. ' + + 'Leaving it alone.', + }; + } + } + + // Find a podspec to read. autolinking.json may have provided podspecPath; + // otherwise glob for *.podspec in dep root. + // $FlowFixMe[prop-missing] dynamic shape from autolinking.json + let podspecPath /*: ?string */ = dep.platforms.ios.podspecPath ?? null; + if (podspecPath == null) { + try { + const entries = fs.readdirSync(dep.root); + // Skip a crashed run's leftover patched copy (read-podspec.js stages it + // as `.spm-scaffold--.podspec` next to the original). + const candidate = entries.find( + e => e.endsWith('.podspec') && !e.startsWith('.spm-scaffold-'), + ); + if (candidate != null) { + podspecPath = path.join(dep.root, candidate); + } + } catch { + // dep.root may not exist; treat as no-podspec + } + } + if (podspecPath == null || !fs.existsSync(podspecPath)) { + return { + depName, + status: 'skipped-no-podspec', + reason: 'No .podspec found in dep root.', + }; + } + + let model: PodspecModel; + try { + model = readPodspec(podspecPath); + } catch (e) { + return { + depName, + status: 'error', + reason: `Podspec read failed: ${e.message}`, + }; + } + + const spec = translatePodspecToSpmTarget( + model, + dep, + ctx.podToNpm ?? new Map(), + ); + + // Mixed-language fail-closed: SPM can't compile Swift + C-family in one + // target. Don't emit a manifest that would fail with a cryptic "mixed + // language source files" resolve error — skip with a clear reason (and remove + // any stale scaffolded manifest so the autolinker reports it distinctly and + // points the user at opting it out / a binary distribution). + const hasSwift = spec.sources.some(f => /\.swift$/i.test(f)); + const hasClang = spec.sources.some(f => /\.(mm?|c|cc|cpp|cxx)$/i.test(f)); + if (hasSwift && hasClang) { + if (!ctx.dryRun && fs.existsSync(pkgSwiftPath)) { + const existing = fs.readFileSync(pkgSwiftPath, 'utf8'); + if (existing.includes(SCAFFOLDER_MARKER)) { + fs.rmSync(pkgSwiftPath, {force: true}); + } + } + return { + depName, + status: 'skipped-mixed-language', + reason: + 'has mixed Swift + Objective-C/C++ sources, which SPM cannot compile ' + + 'in one target (and the bidirectional ObjC↔Swift interop typical of ' + + 'such libs cannot be split without a circular dependency). Opt it out ' + + 'via react-native.config.js (platforms.ios = null) or use a prebuilt ' + + 'xcframework.', + }; + } + + // Relative paths into the app, embedded in the scaffolded Package.swift. + // + // The manifest is written to /Package.swift, but the autolinker + // references the dep through a symlink at + // /build/generated/autolinking/libs/. On a fresh resolve + // SwiftPM interprets a manifest's relative `.package(path:)` entries against + // that SYMLINK location (it does not canonicalize the symlink first), so the + // paths must be relative to the symlink dir — NOT the real dep.root. + // Computing from dep.root produced a doubled path + // (…/autolinking/ios/build/generated/ios) → opaque "package manifest cannot + // be accessed" resolution failure. posix separators, as SPM expects. + const symlinkDir = path.join( + ctx.appRoot, + 'build', + 'generated', + 'autolinking', + 'libs', + spec.swiftName, + ); + const relFromManifest = (...segments /*: Array */) => + path + .relative(symlinkDir, path.join(ctx.appRoot, ...segments)) + .split(path.sep) + .join('/'); + const content = emitScaffoldedPackageSwift(spec, { + cacheSlotLabel: ctx.cacheSlotLabel, + remote: remotePackageConfig(ctx.appRoot), + codegenPackageDir: relFromManifest('build', 'generated', 'ios'), + localXcfwPackageDir: relFromManifest('build', 'xcframeworks'), + }); + + // Distinguish "first-time scaffold" (no file at all) from "regenerate" + // (file with the scaffolder marker, e.g. slot changed). Used by the + // CLI orchestrator to decide whether to prompt for confirmation: + // first-time scaffolds touch a node_modules dir the user may not expect, + // regens are silent because we're maintaining a file we already own. + const previouslyExisted = fs.existsSync(pkgSwiftPath); + + if (!ctx.dryRun) { + fs.writeFileSync(pkgSwiftPath, content, 'utf8'); + // Emit the ambient-import prefix header next to the manifest when the + // target has ObjC(++) sources (the manifest `-include`s it by bare name). + if (spec.needsObjCPrefix) { + fs.writeFileSync( + path.join(dep.root, SCAFFOLD_PREFIX_HEADER), + SCAFFOLD_PREFIX_HEADER_CONTENTS, + 'utf8', + ); + } + // Mirror root-level headers into the generated include// shim + // dir (publicHeadersPath) so dependents can use the CocoaPods header-map + // spelling `#import `. + if (spec.namespacedShimHeaders.length > 0) { + const shimDir = path.join(dep.root, 'include', spec.swiftName); + fs.mkdirSync(shimDir, {recursive: true}); + for (const header of spec.namespacedShimHeaders) { + fs.writeFileSync( + path.join(shimDir, header), + `// AUTO-SCAFFOLDED by react-native spm scaffold — the namespaced\n` + + `// spelling (<${spec.swiftName}/${header}>) of the package-root header,\n` + + `// matching how CocoaPods header maps expose it.\n` + + `#import "../../${header}"\n`, + 'utf8', + ); + } + } + } + + return { + depName, + status: 'written', + packageSwiftPath: pkgSwiftPath, + warnings: spec.warnings, + previouslyExisted, + }; +} + +// --------------------------------------------------------------------------- +// Multi-dep orchestrator +// --------------------------------------------------------------------------- + +/*:: +type ScaffoldAllOptions = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + force?: boolean, + dryRun?: boolean, + cacheSlotLabel?: ?string, + autolinkingJsonPath?: string, + // npm dep names to skip entirely — used when the user declined the + // confirmation prompt for first-time scaffolds. Skipped deps still + // appear in the returned results array with status='skipped-opt-out'. + skipDeps?: ReadonlyArray, +}; +*/ + +/** + * Walks autolinking.json, scaffolds each iOS-native dep, returns one + * ScaffoldResult per dep. Caller (setup-apple-spm.js) prints a structured + * summary. + */ +function scaffoldAll( + opts /*: ScaffoldAllOptions */, +) /*: Array */ { + const {appRoot, projectRoot, reactNativeRoot} = opts; + const autolinkingJsonPath = + opts.autolinkingJsonPath ?? + path.join(appRoot, 'build', 'generated', 'autolinking', 'autolinking.json'); + + if (!fs.existsSync(autolinkingJsonPath)) { + log( + `autolinking.json not found at ${path.relative(appRoot, autolinkingJsonPath)}; nothing to scaffold.`, + ); + return []; + } + + /*:: type AutolinkingJson = {dependencies?: ?{[string]: {root?: string, platforms?: {ios?: ?{...}, ...}, ...}}, ...}; */ + // $FlowFixMe[incompatible-type] JSON.parse returns any + const data /*: AutolinkingJson */ = JSON.parse( + fs.readFileSync(autolinkingJsonPath, 'utf8'), + ); + const deps = data.dependencies; + if (deps == null) { + return []; + } + + // Narrow the direct autolinking.json entries with an iOS platform, then + // expand transitive `spm.dependencies` so the scaffolder covers EXACTLY the + // set the autolinker considers. Without this, a transitive native dep that + // ships no Package.swift would be flagged by the autolinker but never + // scaffolded here — leaving `react-native spm scaffold` unable to clear the + // autolinker's missing-manifest error. + const results /*: Array */ = []; + const directDeps /*: Array */ = []; + for (const name of Object.keys(deps)) { + const raw = deps[name]; + if (raw == null) continue; + const root = raw.root; + const ios = raw.platforms?.ios; + if (typeof root !== 'string' || ios == null) { + results.push({ + depName: name, + status: 'skipped-no-ios', + reason: 'no iOS platform in autolinking.json', + }); + continue; + } + // $FlowFixMe[incompatible-type] `ios` shape is runtime-validated above + const iosPlatform /*: AutolinkingIosPlatform */ = ios; + directDeps.push({name, root, platforms: {ios: iosPlatform}}); + } + + let allDeps: Array; + try { + allDeps = expandSpmDependencies(directDeps, { + readConfig: defaultReadConfig, + resolveDep: defaultResolveDep, + }); + } catch (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}`); + allDeps = directDeps; + } + + // Index every autolinked dep's podspec name → its npm name, so a dep that + // depends on a sibling by its pod name (reanimated's `s.dependency + // "RNWorklets"`) can be wired to the sibling's package (react-native-worklets). + // The podspec file basename is the pod name for RN-ecosystem libs (and is + // cheap — no `pod ipc` pre-pass). + const podToNpm /*: Map */ = new Map(); + for (const dep of allDeps) { + const podspecPath = dep.platforms?.ios?.podspecPath; + if (typeof podspecPath === 'string' && podspecPath.length > 0) { + podToNpm.set(path.basename(podspecPath, '.podspec'), dep.name); + } else { + // Transitive entries synthesized by expandSpmDependencies (declared via + // `spm.dependencies` in react-native.config.js) carry no podspecPath — + // discover the podspec at the dep root so podspec-name sibling deps + // (e.g. `s.dependency "TestLibraryCommon"`) still wire to their npm + // package. + try { + for (const f of fs.readdirSync(dep.root)) { + if (f.endsWith('.podspec') && !f.startsWith('.spm-scaffold-')) { + podToNpm.set(path.basename(f, '.podspec'), dep.name); + } + } + } catch { + // unreadable dep root — sibling wiring falls back to the warning path + } + } + } + + const ctx /*: ScaffoldContext */ = { + appRoot, + projectRoot, + reactNativeRoot, + force: opts.force === true, + dryRun: opts.dryRun === true, + cacheSlotLabel: opts.cacheSlotLabel ?? null, + podToNpm, + }; + const skipSet /*: Set */ = new Set(opts.skipDeps ?? []); + + for (const dep of allDeps) { + if (skipSet.has(dep.name)) { + results.push({ + depName: dep.name, + status: 'skipped-opt-out', + reason: 'User declined scaffolding for this dep.', + }); + continue; + } + try { + results.push(scaffoldPackageSwiftForDep(dep, ctx)); + } catch (e) { + results.push({depName: dep.name, status: 'error', reason: e.message}); + } + } + return results; +} + +module.exports = { + scaffoldAll, + scaffoldPackageSwiftForDep, + translatePodspecToSpmTarget, + emitScaffoldedPackageSwift, + SCAFFOLDER_MARKER, + SCAFFOLDER_VERSION, +}; diff --git a/packages/react-native/scripts/spm/spm-pbxproj.js b/packages/react-native/scripts/spm/spm-pbxproj.js new file mode 100644 index 000000000000..37db184129f4 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-pbxproj.js @@ -0,0 +1,654 @@ +/** + * 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 crypto = require('crypto'); + +/** + * Generate a deterministic 24-hex-character UUID from a seed string. + * SHA-256 truncated to 24 chars (standard Xcode pbxproj UUID length). Not a + * security use — the hash only provides stable, collision-unlikely IDs — but + * sha256 keeps static analysis (CodeQL weak-crypto) quiet. + */ +function generateUUID(seed /*: string */) /*: string */ { + return crypto + .createHash('sha256') + .update(seed) + .digest('hex') + .substring(0, 24) + .toUpperCase(); +} + +/** + * Escapes a string for OpenStep plist format if needed. + */ +function quoteIfNeeded(s /*: string */) /*: string */ { + if (/^[a-zA-Z0-9._/]+$/.test(s)) { + return s; + } + return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; +} + +/** + * Serialize a single pbxproj object entry to its OpenStep text form, + * including the leading `\t\t` and trailing `};` but NO trailing + * newline. Short entries (≤3 scalar fields) collapse to one line, matching + * Xcode's own formatting. Used by the in-place injector to splice single + * entries into an existing project. + */ +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} = {`; + const fieldKeys = Object.keys(entry.fields); + if ( + fieldKeys.length <= 3 && + !fieldKeys.some(k => entry.fields[k].includes('\n')) + ) { + // Single-line format for short entries + out += fieldKeys.map(k => `${k} = ${entry.fields[k]};`).join(' '); + out += '};'; + } else { + out += '\n'; + for (const key of fieldKeys) { + out += `\t\t\t${key} = ${entry.fields[key]};\n`; + } + out += '\t\t};'; + } + return out; +} + +// --------------------------------------------------------------------------- +// Surgical in-place pbxproj editing. +// +// To ADD SPM packages to a user's EXISTING project.pbxproj we splice new +// objects and array members into the existing text by string anchors, leaving +// every untouched byte identical (so the git diff is just the added lines). +// These helpers operate on the raw OpenStep text — there is no AST. Quote-aware +// delimiter matching lets them skip over field values (e.g. a shellScript +// containing braces/parens) without miscounting. +// --------------------------------------------------------------------------- + +/** + * Derive a deterministic UUID for an injected object, namespaced by the host + * project's root-object UUID so it is (a) stable across re-runs (idempotency) + * and (b) astronomically unlikely to collide with the user's existing + * randomly-assigned 24-hex IDs. `salt` lets the caller re-derive on the + * ~1-in-2^96 collision. + */ +function namespacedUUID( + rootUUID /*: string */, + section /*: string */, + id /*: string */, + salt /*: string */ = '', +) /*: string */ { + return generateUUID(`${rootUUID}:spm${salt}:${section}:${id}`); +} + +function escapeRegExp(s /*: string */) /*: string */ { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Given an index pointing at an opening `"`, return the index of the matching + * closing `"` (honoring backslash escapes). + */ +function scanString(text /*: string */, openIdx /*: number */) /*: number */ { + for (let i = openIdx + 1; i < text.length; i++) { + const c = text[i]; + if (c === '\\') { + i++; + continue; + } + if (c === '"') { + return i; + } + } + throw new Error('pbxproj: unterminated string literal'); +} + +/** + * Given an index pointing at an opening `{` or `(`, return the index of the + * matching close delimiter. Nesting counts both brace and paren forms; quoted + * strings are skipped. Well-formed OpenStep never mismatches the two forms. + */ +function scanToClose(text /*: string */, openIdx /*: number */) /*: number */ { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + i = scanString(text, i); + continue; + } + if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + if (depth === 0) { + return i; + } + } + } + throw new Error('pbxproj: unbalanced delimiters'); +} + +/*:: +type ObjectRange = {uuid: string, bodyOpen: number, bodyClose: number}; +// Any object whose body range is known — field accessors only need the body +// bounds, so they accept the richer shapes callers carry (e.g. app targets +// with a name, or buildSettings dicts) inexactly. +type BodyRange = {bodyOpen: number, bodyClose: number, ...}; +type FieldRange = {matchStart: number, valueStart: number, value: string, tokenEnd: number}; +*/ + +/** + * Locate the object with the given 24-hex UUID. Returns the index of the body + * `{` and its matching `}`. Matches both single-line and multi-line entries. + */ +function findObjectByUuid( + text /*: string */, + uuid /*: string */, +) /*: ObjectRange | null */ { + const m = new RegExp(`\\n\\t*${uuid}\\b[^\\n]*?= \\{`).exec(text); + if (m == null) { + return null; + } + const bodyOpen = text.indexOf('{', m.index); + const bodyClose = scanToClose(text, bodyOpen); + return {uuid, bodyOpen, bodyClose}; +} + +/** + * Find a field within a multi-line object body (`\n\t+key = value;`). Returns + * the value token range (value excludes the trailing `;`; `tokenEnd` points AT + * the `;`). Containers (`( … )` / `{ … }`) and quoted strings are matched as a + * whole. Returns null when the key is absent. + */ +function findField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, +) /*: FieldRange | null */ { + const body = text.slice(obj.bodyOpen, obj.bodyClose); + const m = new RegExp(`\\n\\t+${escapeRegExp(key)} = `).exec(body); + if (m == null) { + return null; + } + const matchStart = obj.bodyOpen + m.index; + const valueStart = matchStart + m[0].length; + const fc = text[valueStart]; + let tokenEnd; + if (fc === '(' || fc === '{') { + tokenEnd = scanToClose(text, valueStart) + 1; + } else if (fc === '"') { + tokenEnd = scanString(text, valueStart) + 1; + } else { + tokenEnd = text.indexOf(';', valueStart); + } + return { + matchStart, + valueStart, + value: text.slice(valueStart, tokenEnd), + tokenEnd, + }; +} + +/** Locate the `/* Begin X section *​/ … /* End X section *​/` byte range. */ +function findSection( + text /*: string */, + name /*: string */, +) /*: {begin: number, contentStart: number, end: number} | null */ { + const beginTag = `/* Begin ${name} section */`; + const endTag = `/* End ${name} section */`; + const begin = text.indexOf(beginTag); + const end = text.indexOf(endTag); + if (begin < 0 || end < 0) { + return null; + } + return {begin, contentStart: begin + beginTag.length, end}; +} + +/** The PBXProject root object (via the trailing `rootObject = ;`). */ +function findProjectObject(text /*: string */) /*: ObjectRange | null */ { + const m = /\n\trootObject = ([0-9A-Fa-f]{24})/.exec(text); + if (m == null) { + return null; + } + return findObjectByUuid(text, m[1]); +} + +/** + * Every PBXNativeTarget whose productType is an application. Returns uuid + + * name + body range for each. Used to pick the app target to inject into + * (and to refuse on ambiguity). + */ +function findApplicationTargets( + text /*: string */, +) /*: Array<{uuid: string, name: string, bodyOpen: number, bodyClose: number}> */ { + const section = findSection(text, 'PBXNativeTarget'); + if (section == null) { + return []; + } + const out = []; + const re = /\n\t\t([0-9A-Fa-f]{24})(?: \/\* (.*?) \*\/)? = \{/g; + re.lastIndex = section.contentStart; + for (;;) { + const m = re.exec(text); + if (m == null || m.index >= section.end) { + break; + } + const uuid = m[1]; + const comment = m[2]; + const bodyOpen = text.indexOf('{', m.index); + const bodyClose = scanToClose(text, bodyOpen); + const obj = {uuid, bodyOpen, bodyClose}; + const productType = findField(text, obj, 'productType'); + if ( + productType != null && + /com\.apple\.product-type\.application/.test(productType.value) + ) { + const nameField = findField(text, obj, 'name'); + const name = + nameField != null + ? nameField.value.replace(/^"|"$/g, '') + : (comment ?? uuid); + out.push({uuid, name, bodyOpen, bodyClose}); + } + re.lastIndex = bodyClose; + } + return out; +} + +/** UUIDs already referenced inside a `( … )` array field value. */ +function uuidsInArray(value /*: string */) /*: Set */ { + const found = new Set /*:: */(); + const re = /\b([0-9A-Fa-f]{24})\b/g; + for (;;) { + const m = re.exec(value); + if (m == null) { + break; + } + found.add(m[1]); + } + return found; +} + +/** + * The leading-tab indent of fields inside an object body (e.g. `\t\t\t` for a + * top-level object, `\t\t\t\t` for a nested dict like buildSettings). Used so + * inserted fields/members match the surrounding depth at any nesting level. + */ +function detectFieldIndent( + text /*: string */, + obj /*: BodyRange */, +) /*: string */ { + const m = /\n(\t+)\S/.exec(text.slice(obj.bodyOpen, obj.bodyClose)); + return m != null ? m[1] : '\t\t\t'; +} + +/** + * Insert one or more already-serialized object entries (text produced by + * serializeEntry, no surrounding newlines) into the named section — created + * just before the close of the `objects` dict if the section is absent. + */ +function insertObjectsIntoSection( + text /*: string */, + sectionName /*: string */, + entriesText /*: string */, +) /*: string */ { + const section = findSection(text, sectionName); + if (section != null) { + return ( + text.slice(0, section.end) + entriesText + '\n' + text.slice(section.end) + ); + } + // No such section yet — create it just before the `objects` dict closes. + const anchor = '\n\t};\n\trootObject = '; + const at = text.indexOf(anchor); + if (at < 0) { + throw new Error('pbxproj: could not find end of objects dict'); + } + const block = + `/* Begin ${sectionName} section */\n${entriesText}\n` + + `/* End ${sectionName} section */\n\n`; + return text.slice(0, at + 1) + block + text.slice(at + 1); +} + +/** + * Append members to a `( … )` array field, deduping by UUID. Creates the field + * (with a `$(inherited)`-free literal list) after the object's opening `{` when + * absent. `members` are `{uuid, comment}`. Indentation is derived from the + * object so it works for top-level fields and nested dicts alike. + */ +function addArrayMembers( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + members /*: ReadonlyArray<{readonly uuid: string, readonly comment?: ?string, ...}> */, + options /*: {prepend?: boolean} */ = {}, +) /*: string */ { + const fieldIndent = detectFieldIndent(text, obj); + const memberIndent = fieldIndent + '\t'; + const line = ( + m /*: {readonly uuid: string, readonly comment?: ?string, ...} */, + ) => + `${memberIndent}${m.uuid}${m.comment != null && m.comment !== '' ? ` /* ${m.comment} */` : ''},\n`; + + const field = findField(text, obj, key); + if (field != null) { + const existing = uuidsInArray(field.value); + const fresh = members.filter(m => !existing.has(m.uuid)); + if (fresh.length === 0) { + return text; + } + // Prepend: insert right after the array's opening `(\n` so the new members + // run first (used for the sync phase, which must precede Sources). + const insertAt = + options.prepend === true + ? text.indexOf('\n', field.valueStart) + 1 + : text.lastIndexOf('\n', field.tokenEnd - 1) + 1; + return ( + text.slice(0, insertAt) + fresh.map(line).join('') + text.slice(insertAt) + ); + } + const block = `\n${fieldIndent}${key} = (\n${members.map(line).join('')}${fieldIndent});`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Append raw string values to a `( … )` array build-setting (e.g. + * OTHER_LDFLAGS), deduping by exact token. Creates the setting seeded with + * `"$(inherited)"` when absent. Values must already be plist-quoted by caller. + */ +function addArrayStringValues( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + values /*: Array */, +) /*: string */ { + const fieldIndent = detectFieldIndent(text, obj); + const memberIndent = fieldIndent + '\t'; + const arrayBlock = (members /*: Array */) => + `(\n${members.map(v => `${memberIndent}${v},\n`).join('')}${fieldIndent})`; + + const field = findField(text, obj, key); + if (field != null) { + // 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), + ); + 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); + } + // Existing scalar — promote to an array preserving the prior value. + const replacement = arrayBlock([ + '"$(inherited)"', + field.value.trim(), + ...fresh, + ]); + return ( + text.slice(0, field.valueStart) + replacement + text.slice(field.tokenEnd) + ); + } + const block = `\n${fieldIndent}${key} = ${arrayBlock(['"$(inherited)"', ...values])};`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Add a scalar field after the object's `{` only when ABSENT (never clobbers a + * value the user already set). Returns text unchanged if the key exists. + */ +function ensureScalarField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + value /*: string */, +) /*: string */ { + if (findField(text, obj, key) != null) { + return text; + } + const fieldIndent = detectFieldIndent(text, obj); + const block = `\n${fieldIndent}${key} = ${value};`; + return text.slice(0, obj.bodyOpen + 1) + block + text.slice(obj.bodyOpen + 1); +} + +/** + * Set a scalar field's value, UNLIKE ensureScalarField this overwrites an + * existing value in place rather than leaving it alone — used by fields the + * injector itself owns (e.g. the generated `shellScript`) that must be kept + * in sync on re-injection. When the field is present, only the value token + * (`findField`'s `valueStart..tokenEnd` range — the trailing `;` is NOT part + * of that range and is preserved untouched) is replaced in place, so field + * order never shifts and passing the same `value` again yields + * byte-identical output. Falls back to `ensureScalarField`'s append-after-`{` + * behavior when the field is absent. + */ +function setScalarField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + value /*: string */, +) /*: string */ { + const field = findField(text, obj, key); + if (field != null) { + return text.slice(0, field.valueStart) + value + text.slice(field.tokenEnd); + } + return ensureScalarField(text, obj, key, value); +} +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- + +/** + * Remove the object whose UUID is `uuid` (its whole `\t\t … = { … };` + * entry, single- or multi-line). No-op when the object is absent. + */ +function removeObjectByUuid( + text /*: string */, + uuid /*: string */, +) /*: string */ { + const obj = findObjectByUuid(text, uuid); + if (obj == null) { + return text; + } + // Start at the newline preceding the entry's line; end just past its `;`. + // Leaving the trailing newline in place preserves it as the next entry's + // separator (byte-identical to never having inserted the line). + const start = text.lastIndexOf('\n', obj.bodyOpen); + let end = obj.bodyClose + 1; // past `}` + if (text[end] === ';') { + end++; + } + return text.slice(0, start) + text.slice(end); +} + +/** + * Remove array-member lines (`\n\t+ /* … *​/,`) referencing any of + * `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. + */ +function removeArrayMembersByUuid( + text /*: string */, + uuids /*: ReadonlyArray */, +) /*: string */ { + let out = text; + for (const uuid of uuids) { + out = out.replace( + new RegExp(`\\n[\\t ]*${escapeRegExp(uuid)}\\b[^\\n]*,`, 'g'), + '', + ); + } + return out; +} + +/** Remove a whole `\n\t+key = value;` field from `obj`. No-op when absent. */ +function removeField( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, +) /*: string */ { + const f = findField(text, obj, key); + if (f == null) { + return text; + } + // f.matchStart points at the leading `\n`; f.tokenEnd points AT the `;`. + return text.slice(0, f.matchStart) + text.slice(f.tokenEnd + 1); +} + +/** + * Remove specific raw string members from an existing `( … )` array field + * (inverse of addArrayStringValues' append branch). Leaves the field and any + * other members in place. No-op when the field or a value is absent. + */ +function removeArrayStringValues( + text /*: string */, + obj /*: BodyRange */, + key /*: string */, + values /*: ReadonlyArray */, +) /*: string */ { + const f = findField(text, obj, key); + if (f == null) { + return text; + } + let region = text.slice(f.valueStart, f.tokenEnd); + for (const val of values) { + region = region.replace(new RegExp(`\\n[\\t ]*${escapeRegExp(val)},`), ''); + } + return text.slice(0, f.valueStart) + region + text.slice(f.tokenEnd); +} + +/** + * Remove the empty `Pods` PBXGroup that `pod deintegrate` can leave behind in + * the navigator (build integration is already gone — xcconfigs/[CP] phases/ + * linking — but the group lingers). Removes the group object AND its membership + * in any parent group. Only acts when the group is EMPTY (`children = ()`), so a + * still-integrated project (non-empty Pods group) is never touched. No-op when + * absent. PBXGroup bodies contain no nested braces, so `[^{}]` body matching is + * safe. + */ +function removeEmptyPodsGroup(text /*: string */) /*: string */ { + const m = /\n[\t ]*([0-9A-Fa-f]{24}) \/\* Pods \*\/ = \{[^{}]*?\};/.exec( + text, + ); + if (m == null) { + return text; + } + const block = m[0]; + if ( + !/isa = PBXGroup;/.test(block) || + !/children = \(\s*\);/.test(block) || + !/\b(?:path|name) = Pods;/.test(block) + ) { + return text; + } + const uuid = m[1]; + // Drop the parent group's child reference first, then the group object. + return removeObjectByUuid(removeArrayMembersByUuid(text, [uuid]), uuid); +} + +/** + * Remove the dangling `JavaScriptCore.framework` PBXFileReference the + * community template has carried since RN 0.60 (an SDK framework reference + * left over from the pre-Hermes JSC-via-CocoaPods era). It's navigator-only — + * never wired into any PBXBuildFile/build phase — so it's meaningless (and + * confusing) now that React Native uses Hermes. Only removes references that + * are truly unlinked: if a PBXBuildFile still references the UUID (e.g. an + * app that deliberately links JSC, such as via react-native-javascriptcore), + * that reference is left completely untouched. Removes the file-reference + * object AND its membership in any parent group. No-op when absent. Like + * `removeEmptyPodsGroup`, `deinit` does not restore this removal — git is the + * safety net. + */ +function removeDanglingJavaScriptCoreRef(text /*: string */) /*: string */ { + const re = + /\n[\t ]*([0-9A-Fa-f]{24}) \/\* JavaScriptCore\.framework \*\/ = \{[^{}]*?\};/g; + const uuidsToRemove = []; + for (const m of text.matchAll(re)) { + const block = m[0]; + if ( + !/isa = PBXFileReference;/.test(block) || + !/path = System\/Library\/Frameworks\/JavaScriptCore\.framework;/.test( + block, + ) || + !/sourceTree = SDKROOT;/.test(block) + ) { + continue; + } + const uuid = m[1]; + const linked = new RegExp(`\\bfileRef = ${escapeRegExp(uuid)}\\b`).test( + text, + ); + if (!linked) { + uuidsToRemove.push(uuid); + } + } + if (uuidsToRemove.length === 0) { + return text; + } + // Drop the parent group's child reference(s) first, then the file objects. + let out = removeArrayMembersByUuid(text, uuidsToRemove); + for (const uuid of uuidsToRemove) { + out = removeObjectByUuid(out, uuid); + } + return out; +} + +module.exports = { + generateUUID, + namespacedUUID, + serializeEntry, + quoteIfNeeded, + // Surgical-edit toolkit (in-place injection): + scanString, + scanToClose, + findObjectByUuid, + findField, + findSection, + findProjectObject, + findApplicationTargets, + uuidsInArray, + detectFieldIndent, + insertObjectsIntoSection, + addArrayMembers, + addArrayStringValues, + ensureScalarField, + setScalarField, + escapeRegExp, + // Surgical removal (deinit): + removeObjectByUuid, + removeArrayMembersByUuid, + removeField, + removeArrayStringValues, + removeEmptyPodsGroup, + removeDanglingJavaScriptCoreRef, +}; diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js new file mode 100644 index 000000000000..cc30c8823b65 --- /dev/null +++ b/packages/react-native/scripts/spm/spm-types.js @@ -0,0 +1,517 @@ +/** + * 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 + */ + +/*:: +export type SetupArgs = { + action: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' | null, + version: string | null, + // Local two-flavor artifact source (advanced). The directory contains + // `debug/` and `release/` cache slots, each with artifacts.json. + artifacts: string | null, + skipCodegen: boolean, + // Artifact download policy: 'auto' fetches when missing, 'skip' never + // fetches, 'force' clears the cache slot and re-downloads. + downloadPolicy: 'auto' | 'skip' | 'force', + // `add` target selection: which app target (when several) and which project. + productName: string | null, + xcodeprojPath: string | null, + // `add`: run `pod deintegrate` + strip RN from the Podfile before injecting. + // Also implied on the zero-arg path when resolveAction's safe-gate detects a + // freshly-scaffolded CocoaPods project (see setup-apple-spm.js). + deintegrate: boolean, + // Skip the single remaining confirmation (the add/update dirty-pbxproj + // warning). Non-TTY auto-proceeds regardless (git is the safety net). + yes: boolean, +}; + +export type DownloadArgs = { + version: string | null, + flavor: string, + output: string | null, + coreTarball: string | null, + headersTarball: string | null, + depsTarball: string | null, + depsHeadersTarball: string | null, +}; + +export type ResolvedArtifact = { + url: string, + version: string, +}; + +export type ProcessResult = { + label: string, + version: string, + xcframeworkPath: string, + url: string, +}; + +export type ArtifactResultEntry = + | {name: string, error: void, label: string, version: string, xcframeworkPath: string, url: string} + | {name: string, error: string}; + +export type AutolinkingArgs = { + appRoot: string, + reactNativeRoot: string | null, + autolinkingJson: string | null, + output: string | null, + xcframeworksPath: string | null, +}; + +export type SpmTarget = { + name: string, + path: string, + exclude: Array, + publicHeadersPath: string | null, + resources?: Array, + // Swift target names this target depends on (already toSwiftName()'d). + // Emitted into the target's SPM `dependencies:` array so the compiler sees + // the dependent target's headers / module map. + spmTargetDependencies?: Array, + // Explicit allowlist of source files (paths relative to target.path). + // Mirrors CocoaPods' `s.source_files`. When non-empty, the SPM target + // declares `sources: [...]` and only these files are compiled — test dirs, + // .js/.podspec/.md siblings, etc. can never sneak in. Null/empty means + // fall back to SPM's default scan of target.path. + sources?: ?Array, + // Header search paths declared by the dep's podspec + // (pod_target_xcconfig HEADER_SEARCH_PATHS), already substituted relative + // to the dep's source dir. Path-style includes like + // `` resolve through these. + // Emitted as `.headerSearchPath(...)` directives in the target's + // cSettings / cxxSettings. + headerSearchPaths?: ?Array, +}; + +// Routing metadata kept alongside an SpmTarget in main(). Lives in a wrapper +// (not on SpmTarget) so the target type stays describable from the outside. +export type TargetEntry = { + target: SpmTarget, + origin: 'npm' | 'spmModule', + // The dep's npm package name (origin 'npm' only). Used in the + // missing-manifest error so the message names the package the developer + // installed, not its derived Swift target name. + npmName?: string, + // The dep's checked-in package root (origin 'npm' only) — the directory that + // holds its root `Package.swift` and `.react-native/` metadata. Threaded from + // the autolinking model (`dep.root`) so the watch file can flag manifest edits + // without walking up from the (possibly nested) target source dir. + root?: string, + // Filled in for npm-origin entries during the mirror step; consumed by the + // synth-package emission step further down. + mirrorReady?: ?{ + synthPkgDir: string, + mirroredResources: ?Array, + }, +}; + +// --------------------------------------------------------------------------- +// autolinking.json shape (output of @react-native-community/cli config). +// All fields are optional because the JSON is user-influenced; the consumer +// checks at runtime. +// --------------------------------------------------------------------------- +export type AutolinkingIosPlatform = { + sourceDir?: ?string, + podspecPath?: ?string, + ... +}; +// As parsed from autolinking.json — all fields optional because the JSON is +// user-influenced. main() validates and narrows to AutolinkedDep before use. +export type AutolinkingDepJson = { + root?: ?string, + platforms?: ?{ios?: ?AutolinkingIosPlatform, ...}, + ... +}; +export type RawAutolinkingJson = { + dependencies?: ?{[string]: AutolinkingDepJson}, + ... +}; +// Validated/normalized dep — name and ios platform are guaranteed present. +// Produced from AutolinkingDepJson in main() and passed through +// expandSpmDependencies to autolinkingDepToSpmTarget. +export type AutolinkedDep = { + name: string, + root: string, + platforms: {ios: AutolinkingIosPlatform, ...}, + // Resolved Swift target / module / headers-subdir name. Defaults to + // toSwiftName(name) and is overridden by the dep's react-native.config.js + // `spm.name`. Populated by expandSpmDependencies — always present after + // expansion; optional in the type so caller-side construction stays simple. + swiftName?: string, + // Populated by expandSpmDependencies from each dep's + // react-native.config.js `spm.dependencies` array. + spmDependencies?: Array, + ... +}; + +// CLI `config` output minimally typed for the bits we read in +// generate-spm-autolinking-config.js. +export type CliConfigJson = { + root?: ?string, + reactNativePath?: ?string, + project?: ?{ios?: ?{sourceDir?: ?string, ...}, ...}, + ... +}; + +// Entry shape for an spmModule declared in react-native.config.js. +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:`. + sources?: Array, +}; + +// --------------------------------------------------------------------------- +// Inputs to the Swift emitters in generate-spm-autolinking.js. +// --------------------------------------------------------------------------- +export type NpmDepRef = { + swiftName: string, + // Path passed to .package(path:). Relative to autolinked/ (the aggregator's + // dir). For in-place synth this is the dep's real source dir. + packagePath?: string, + // The npm package name (e.g. react-native-safe-area-context). Used by the + // aggregator's eval-time missing-manifest guard to name the library a + // developer installed (not its Swift target name). + npmName?: string, +}; + +export type AggregatorInput = { + npmDeps?: ReadonlyArray, + inlineTargets?: ReadonlyArray, + hasReactDep?: boolean, + // Relative path from the aggregator's dir (autolinking/) to + // build/xcframeworks. Used for the inline-target ReactNative dep. + xcframeworksRelPath?: ?string, + // Autolinking-plugin contributions (Expo & other frameworks). Merged into + // the aggregator's package deps + the AutolinkedAggregate target deps. + pluginPackageDeps?: ReadonlyArray, + pluginProductDeps?: ReadonlyArray, +}; + +// --- Autolinking plugins (PREVIEW / unstable contract) --- + +// A framework's plugin package dependency: local (path) or remote (url+version). +export type PluginPackageDep = { + name: string, + path?: string, + url?: string, + version?: string, +}; + +// A product the AutolinkedAggregate target should link. +export type PluginProductDep = {name: string, package: string}; + +// A generated source file (e.g. Expo's ExpoModulesProvider.swift) to register +// in the codegen package so it compiles. +export type PluginGeneratedSource = {path: string}; + +// A plugin-declared dynamic XCFramework pair. RN validates both paths, stages +// immutable app-local slots, and links/embeds the selected framework outside +// SwiftPM. +export type PluginFlavoredFramework = { + id: string, + frameworkName: string, + linkage: 'dynamic', + flavors: {debug: string, release: string}, +}; + +export type XcframeworkSlice = { + sdk: string, + platform: string, + variant: ?string, + architectures: Array, + libraryIdentifier: string, + libraryPath: string, + binaryPath: string, +}; + +export type ParsedXcframework = { + path: string, + frameworkName: string, + executableName: string, + slices: Array, +}; + +export type FlavoredFrameworkManifestEntry = { + id: string, + frameworkName: string, + executableName: string, + linkage: 'dynamic', + artifactRelativePath: string, + slices: Array, +}; + +export type FlavoredFrameworksManifest = { + version: 1, + frameworks: Array, +}; + +// How a plugin depends on React — the single source of truth so a plugin's +// own Package.swift doesn't re-derive RN's package path/identity/products. +// Local vs remote is distinguished by which keys are present: +// - local: {name, path (absolute), relPath (relative to outputDir)} +// - remote: {name, url, version} +// `path` is absolute so it's correct no matter which subdir of outputDir the +// plugin writes its manifest into (the manifests are gitignored + regenerated). +export type ReactPackageRef = + | {name: string, path: string, relPath?: string} + | {name: string, url: string, version: string}; + +export type ReactDescriptor = { + packageRef: ReactPackageRef, + // The products a React-consuming target may depend on — the same set RN wires + // into its own autolinked targets (parity), filtered to those resolvable this + // run. Every listed product is safe to reference without guarding. Note + // ReactAppHeaders lives in the separate, per-app React-GeneratedCode package. + products: Array<{name: string, package: string}>, +}; + +// Context handed to every plugin. Includes the JS root (projectRoot) and the +// parsed autolinking data so a framework can scan the discovered deps. +export type PluginContext = { + appRoot: string, + projectRoot: string, + reactNativeRoot: string, + autolinking: {readonly [string]: unknown}, + outputDir: string, + // How to depend on React (package ref + product set); null only when there is + // no resolvable React dependency at all. + react: ?ReactDescriptor, +}; + +export type PluginResult = { + packageDependencies: Array, + productDependencies: Array, + generatedSources: Array, + flavoredFrameworks: Array, + // Absolute paths (dirs or files) the Xcode auto-sync build phase should watch + // 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, +}; + +export type SpmAutolinkingPlugin = (context: PluginContext) => ?{ + packageDependencies?: Array, + productDependencies?: Array, + generatedSources?: Array, + flavoredFrameworks?: Array, + watchPaths?: Array, +}; + +export type DiscoveredPlugin = { + depName: string, + pluginPath: string, + plugin: SpmAutolinkingPlugin, +}; + +export type SynthPackageSpec = { + swiftName: string, + exclude?: Array, + publicHeadersPath?: ?string, + // Explicit allowlist of source paths (relative to `targetPath`). When + // present and non-empty, the synth Package.swift emits `sources: [...]` + // — SPM will only compile these files. + sources?: ?Array, + spmDependencies?: Array<{swiftName: string}>, + hasReactDep?: boolean, + // Relative path (posix, from the synth dir /packages/) to + // the app's React xcframeworks package and codegen package. Computed by the + // caller at generation time — the synth holds no runtime discovery. + reactNativePackagePath?: string, + codegenPackagePath?: string, + resources?: ?Array, + isDynamic?: boolean, + targetPath?: string, + // Fallback relative base for sibling synth packages when the caller does not + // supply absolute paths (tests). Production uses siblingSynthAbsolutePaths. + siblingPackageBaseRelative?: string, + siblingSynthAbsolutePaths?: {[swiftName: string]: string}, + // Header search paths from the dep's podspec `pod_target_xcconfig` + // HEADER_SEARCH_PATHS, with `$(PODS_TARGET_SRCROOT)` substituted and the + // synth wrapper's `root/` prefix applied. Emitted as `.headerSearchPath()` + // directives on cSettings / cxxSettings so path-style includes like + // `` resolve through the + // dep's own `common/cpp/` subtree. + headerSearchPaths?: ?Array, +}; + + +export type GeneratePackageArgs = { + appRoot: string, + reactNativeRoot: string | null, + version: string | null, + debugArtifactsDir: string | null, + releaseArtifactsDir: string | null, + appName: string | null, + targetName: string | null, + sourcePath: string | null, + iosVersion: string, +}; + +// A preprocessor define lifted from a podspec's pod_target_xcconfig +// (OTHER_CFLAGS `-D...` tokens + GCC_PREPROCESSOR_DEFINITIONS entries). `value` +// is null for a bare `-DNAME` (define with no value); `config` scopes the +// define to a build configuration (from `[config=*Debug*]`/`[config=*Release*]` +// xcconfig keys), null = unconditional. Emitted as SPM `.define(...)`. +export type PreprocessorDefine = { + name: string, + value: ?string, + config: ?('debug' | 'release'), +}; + +// --------------------------------------------------------------------------- +// Scaffold types — for the `npx react-native spm scaffold` command that +// generates a `Package.swift` into `node_modules//` for community RN +// libraries that don't ship SPM support. Inputs come from the dep's podspec +// (read via `pod ipc spec --format=json` when CocoaPods is available, or a +// regex fallback). The output Package.swift is treated as "self-managed" by +// the autolinker — see isSelfManagedPackage in generate-spm-autolinking.js. +// --------------------------------------------------------------------------- + +// Flattened, subspec-merged view of a podspec — what the translation layer +// consumes. HEADER_SEARCH_PATHS / source globs are kept as raw strings; +// substitution of `$(PODS_TARGET_SRCROOT)` etc. happens during translation +// so the raw model stays portable. +export type PodspecModel = { + name: string, + version: string, + sourceFiles: Array, + publicHeaderFiles: Array, + privateHeaderFiles: Array, + excludeFiles: Array, + headerMappingsDir: ?string, + // Every subspec's header_mappings_dir (union). dirname() of each is added as + // a header search path so `` includes resolve from the + // physical source tree (SPM has no header_mappings_dir copy step). + headerMappingsDirs: Array, + headerDir: ?string, + frameworks: Array, + weakFrameworks: Array, + libraries: Array, + // Merged dependency name list. With pod-ipc, includes the deps materialized + // by `install_modules_dependencies(s)` (React-Core, React-Fabric, ...). + // Version constraints stripped — we only need the name to bucket. + dependencies: Array, + compilerFlags: Array, + // Raw header-search-path entries from `pod_target_xcconfig['HEADER_SEARCH_PATHS']`. + // May contain Xcode build setting placeholders like `$(PODS_TARGET_SRCROOT)`. + headerSearchPaths: Array, + // Preprocessor defines lifted from pod_target_xcconfig OTHER_CFLAGS (`-D` + // tokens) + GCC_PREPROCESSOR_DEFINITIONS (incl. per-config variants). Already + // resolved by `pod ipc` (e.g. `-DWORKLETS_VERSION=#{package['version']}` → + // `WORKLETS_VERSION=0.9.2`). Emitted as `.define(...)` on the SPM target. + preprocessorDefines: Array, + // File paths or glob patterns the dep declares as bundled resources. + resources: Array, + requiresArc: boolean, + // Warnings collected during parsing — surfaced in the scaffold summary so + // a user can spot fields that didn't translate cleanly (unknown env vars, + // unrecognized $(...) tokens, etc.). Never throws on parse errors. + warnings: Array, + // True when produced by the regex fallback rather than pod-ipc. Used to + // emit a louder banner in the scaffold summary explaining that dependency + // wiring may be incomplete. + partial: boolean, + // True when the podspec source calls `install_modules_dependencies(s)` — + // RN's Podfile-side helper that injects the React-Core family. We strip that + // helper before evaluating the spec (and the regex parser can't expand it), + // so React-Core never surfaces in `dependencies`. The scaffolder treats this + // flag as an implicit React-core dependency (a plain ObjC module using the + // helper has no `codegenConfig` to key off). + usesInstallModulesDependencies: boolean, +}; + +// Intermediate translation result — concrete data the Swift emitter consumes. +// Decouples podspec reading from SPM-specific shaping so each side can be +// tested in isolation. +export type SpmScaffoldSpec = { + // Swift target / module name. Default: toSwiftName(podspec.name); overridden + // by `header_dir` when present. + swiftName: string, + // Source file paths relative to the dep root, ready for `sources: [...]` + // emission after the `root/` wrapper-dir prefix is applied at emit time. + sources: Array, + // Header search paths resolved to dep-root-relative form. Each entry + // becomes `.headerSearchPath("")` in cSettings + cxxSettings. + headerSearchPaths: Array, + // Preprocessor defines (resolved by pod ipc) — emitted as `.define(...)` in + // cSettings + cxxSettings, honoring any per-config scope. + preprocessorDefines: Array, + // True when the target has ObjC(++) sources (.m/.mm). Drives emitting + + // `-include`ing a prefix header that ambient-imports Foundation/UIKit (which + // CocoaPods provides via a generated prefix.pch and SPM does not). + needsObjCPrefix: boolean, + // 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. + coreReactNative: boolean, + siblingNames: Array, + // Extra frameworks beyond the autolinker's default UIKit/Foundation/CoreGraphics + // set. Merged with the defaults at emit time. + extraFrameworks: Array, + weakFrameworks: Array, + // Compiler flags lifted from `s.compiler_flags`. Tokenized, ready for + // `cxxSettings: [.unsafeFlags([...])]`. + compilerFlags: Array, + // Public-headers strategy. `mappingsDir` (when set) drives publicHeadersPath + // so the autolinker's centralized headers tree exposes `#import `. + publicHeadersPath: ?string, + // Root-level headers (podspecs like `s.source_files = "*.{h,m,mm}"`) that the + // scaffold writer mirrors into a generated include// shim dir so + // the CocoaPods header-map spelling `#import ` resolves + // for dependents (publicHeadersPath is then "include"). + namespacedShimHeaders: Array, + // File paths (relative to dep root) the emitter should declare as `.copy(...)` + // resources on the target. + resources: Array, + // Carried through from the PodspecModel; surfaced in the scaffold summary. + warnings: Array, +}; + +// Per-dep outcome from one scaffold run. The orchestrator returns an array +// of these so the CLI summary can print a structured table. +export type ScaffoldResult = + | { + depName: string, + status: 'written', + packageSwiftPath: string, + warnings: Array, + // True when the dep's Package.swift already existed (a regen — slot + // changed, --force, etc.); false on first-time scaffolds. The CLI + // orchestrator prompts only for first-time scaffolds. + previouslyExisted: boolean, + } + | { + depName: string, + status: + | 'skipped-self-managed' + | 'skipped-autogen' + | 'skipped-scaffolder-marker' + | 'skipped-no-ios' + | 'skipped-no-podspec' + | 'skipped-opt-out' + | 'skipped-mixed-language' + | 'skipped-is-react-native', + reason: string, + } + | { + depName: string, + status: 'error', + reason: string, + }; +*/ + +module.exports = {}; diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js new file mode 100644 index 000000000000..23a90e00102e --- /dev/null +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -0,0 +1,645 @@ +/** + * 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 fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** + * Creates a logger trio {log, warn, die} that prefixes messages with [name]. + * log – green prefix, writes to stdout + * warn – yellow prefix, writes to stderr + * die – red prefix, writes to stderr, sets exitCode=1, throws + */ +function makeLogger(name /*: string */) /*: { + log: (msg: string) => void, + warn: (msg: string) => void, + die: (msg: string) => empty, +} */ { + // Prefix every newline-separated line of the message so multi-line output + // wraps cleanly when terminal log scrapers look for the `[name]` tag. + function format(color /*: string */, msg /*: string */) /*: string */ { + const prefix = `\x1b[${color}m[${name}]\x1b[0m`; + return msg + .split('\n') + .map(line => `${prefix} ${line}`) + .join('\n'); + } + return { + log(msg /*: string */) /*: void */ { + console.log(format('32', msg)); + }, + warn(msg /*: string */) /*: void */ { + console.warn(format('33', msg)); + }, + die(msg /*: string */) /*: empty */ { + console.error(format('31', msg)); + process.exitCode = 1; + throw new Error(msg); + }, + }; +} + +/** + * Returns a short, human-readable representation of an absolute path: + * - Paths under $HOME are shown as ~/... + * - Paths under cwd are shown as relative (if ≤2 levels up) + * - Otherwise the absolute path is returned unchanged + */ +function displayPath(p /*: string */) /*: string */ { + const home = os.homedir(); + if (p === home) return '~'; + if (p.startsWith(home + path.sep)) { + return '~' + p.slice(home.length); + } + const rel = path.relative(process.cwd(), p); + if (rel && !rel.startsWith('../../..')) { + return rel; + } + return p; +} + +/** + * Canonical React Native binary cache root. Mirrors CocoaPods' + * `ReactNativePodsUtils.shared_cache_dir()` (~/Library/Caches/ReactNative, added + * in #56847) so SPM and CocoaPods share one cache root — and so SPM stops using + * a `com.facebook.ReactNative` (bundle-id) dir that other tools may also touch. + * Honor `RCT_SKIP_CACHES=1` (same env var as CocoaPods) to bypass the shared + * tarball cache. + */ +function sharedCacheDir() /*: string */ { + return path.join(os.homedir(), 'Library', 'Caches', 'ReactNative'); +} + +/** + * Returns the default versioned cache directory for SPM's EXTRACTED xcframeworks, + * nested under the canonical cache root. Downloaded tarballs themselves go in the + * flat shared cache (sharedCacheDir()) so they are reused across SPM/CocoaPods. + * + * @param {string} versionKey Version string used as directory name. + * Pass the raw --version arg (e.g. 'nightly') so the + * cache slot is stable regardless of the resolved hash. + * @param {string} flavor 'debug' or 'release' + */ +function defaultCacheDir( + versionKey /*: string */, + flavor /*: string */, +) /*: string */ { + return path.join(sharedCacheDir(), 'spm-artifacts', versionKey, flavor); +} + +/** + * Sanitize a package/app name to a valid Swift identifier. + * e.g. "@react-native/tester" -> "RNTester", "my-app" -> "MyApp" + */ +function toSwiftName(name /*: string */) /*: string */ { + const base = name.replace(/^@[^/]+\//, ''); + return base + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +/** + * Derive a default app name from the raw package name and source path. + * Prefers the source directory name when it's meaningful (e.g. "RNTester"), + * falls back to the package name for generic dirs like "ios" or "src". + */ +function deriveAppName( + rawName /*: string */, + sourcePath /*: string */, +) /*: string */ { + const genericSourceDirs = new Set(['ios', 'app', 'sources', 'src']); + const cleanName = rawName.replace(/^@[^/]+\//, ''); + return toSwiftName( + sourcePath !== toSwiftName(cleanName) && + !genericSourceDirs.has(sourcePath.toLowerCase()) + ? sourcePath + : cleanName, + ); +} + +// $FlowFixMe[unclear-type] JSON data has dynamic shape +function readPackageJson(dir /*: string */) /*: Object | null */ { + const pkgPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgPath)) { + return null; + } + // $FlowFixMe[incompatible-return] JSON.parse returns any + return JSON.parse(fs.readFileSync(pkgPath, 'utf8')); +} + +/** + * Walk up from startDir until we find a directory containing package.json. + * Returns startDir itself if it contains package.json, or startDir as fallback + * if no package.json is found anywhere up the tree. + */ +function findProjectRoot(startDir /*: string */) /*: string */ { + const start = path.resolve(startDir); + let dir = start; + // Bounded by filesystem depth — path.dirname converges to '/' or 'C:\\'. + // The `dir = ...` updates would otherwise drop the start-fallback narrowing. + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + dir = path.dirname(dir); + } + // At filesystem root — last check before falling back. + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + return start; +} + +/** + * Resolve the react-native package root from an app directory. + * Checks appRoot/projectRoot and their ancestors for node_modules/react-native, + * then falls back to __dirname-relative resolution (monorepo layout). + * + * Returns null if react-native cannot be found. + */ +function resolveReactNativeRoot( + appRoot /*: string */, + projectRoot /*: string */, +) /*: string | null */ { + const candidates /*: Array */ = []; + const seen /*: Set */ = new Set(); + + function addAncestorCandidates(startDir /*: string */) /*: void */ { + let dir = path.resolve(startDir); + while (true) { + const candidate = path.join(dir, 'node_modules', 'react-native'); + if (!seen.has(candidate)) { + seen.add(candidate); + candidates.push(candidate); + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } + + addAncestorCandidates(appRoot); + addAncestorCandidates(projectRoot); + candidates.push(path.resolve(__dirname, '../..')); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return path.resolve(candidate); + } + } + return null; +} + +// The per-app farm lives INSIDE the codegen package (build/generated/ios) so +// it can be vended as a normal SPM headers target ("ReactAppHeaders") — the +// farm reaches consumers via SPM product dependencies, not -I. +const PER_APP_HEADERS_REL = 'build/generated/ios/ReactAppHeaders'; + +// Marker at the top of a scaffolder-generated Package.swift. Lives here (not in +// scaffold-package-swift.js) so the autolinker can recognize scaffolded files +// without a circular import (scaffold-package-swift requires the autolinker). +const SCAFFOLDER_MARKER = + '// AUTO-SCAFFOLDED by react-native spm scaffold — safe to edit & commit via patch-package.'; + +// Remote SPM package mode (prototype of the GitHub-distribution endgame): +// when active, app + libraries all depend on ONE remote package identity +// (`.package(url:exact:)`) instead of the local path-based artifacts package +// — SPM unifies the version across the graph and the local compose/symlink +// machinery is skipped. Activated via RN_SPM_REMOTE_URL + +// RN_SPM_REMOTE_VERSION (persisted per-app so Xcode-phase re-syncs without +// the env keep the mode). +const REMOTE_CONFIG_REL = 'build/generated/autolinking/spm-remote.json'; +function remotePackageIdentity(url /*: string */) /*: string */ { + const tail = url.replace(/\/+$/, '').split('/').pop() ?? ''; + return tail.replace(/\.git$/, '').toLowerCase(); +} + +/** + * Thrown by remotePackageConfig when remote SPM mode is active (a URL is set) + * but no usable RN version can be determined: react-native isn't installed, or + * the installed version is a non-publishable dev placeholder (e.g. the monorepo + * '1000.0.0', which has no remote tag) and no override was supplied. Carries a + * developer-facing message; the CLI turns it into a hard build error (exit 2). + */ +class RemoteVersionError extends Error { + constructor(message /*: string */) { + super(message); + this.name = 'RemoteVersionError'; + } +} + +/** + * Resolve the version of the installed react-native by walking up from appRoot + * looking for node_modules/react-native/package.json. Mirrors the autolinker's + * appRoot-first, up-to-5-ancestors walk-up. Returns null when not found or the + * package.json has no string version. + */ +function resolveInstalledRnVersion(appRoot /*: string */) /*: ?string */ { + let dir = path.resolve(appRoot); + for (let i = 0; i <= 5; i++) { + const pkgPath = path.join( + dir, + 'node_modules', + 'react-native', + 'package.json', + ); + if (fs.existsSync(pkgPath)) { + try { + const j = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (typeof j.version === 'string') { + return j.version; + } + } catch {} + return null; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return null; +} + +/** + * True when a version string can be resolved to a published remote tag. False + * for the monorepo dev placeholder ('1000.0.0') and 0.0.0-* dev builds, neither + * of which is published — in remote mode these require an explicit override. + */ +function isPublishableVersion(v /*: ?string */) /*: boolean */ { + if (v == null || v === '') { + return false; + } + if (v === '1000.0.0') { + return false; + } + if (/^0\.0\.0(-|$)/.test(v)) { + return false; + } + return true; +} + +/** + * Resolve the remote SPM package config for `appRoot`, or null for local mode. + * + * Remote mode is gated by a URL alone (env RN_SPM_REMOTE_URL or persisted + * `url`). The version is an OVERRIDE chain — env RN_SPM_REMOTE_VERSION → + * persisted `versionOverride` → legacy persisted `version` (back-compat) — and + * when no override is set it is DERIVED from the installed react-native. The + * derived value is never persisted, so an npm RN upgrade auto-re-pins the SPM + * graph on the next sync. A derived version that isn't publishable (or a + * missing RN install) throws RemoteVersionError so the dev placeholder doesn't + * silently pin an unpublished tag. + */ +function remotePackageConfig( + appRoot /*: string */, +) /*: ?{url: string, version: string, identity: string} */ { + const envUrl = process.env.RN_SPM_REMOTE_URL; + const envVersion = process.env.RN_SPM_REMOTE_VERSION; + const cfgPath = path.join(appRoot, REMOTE_CONFIG_REL); + + // Read any persisted config first. Legacy {url, version} (where `version` + // was a hard pin) is read with `version` honored as an override. + let persisted /*: {url?: string, versionOverride?: string, version?: string} */ = + {}; + if (fs.existsSync(cfgPath)) { + try { + const j = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + if (j != null && typeof j === 'object') { + persisted = j; + } + } catch {} + } + + // URL alone activates remote mode (env wins over persisted). + const url = envUrl != null && envUrl !== '' ? envUrl : persisted.url; + if (url == null || url === '') { + return null; // local mode + } + + // Version override chain. When none is set, derive from the installed RN — + // the SPM graph must compile against the same RN the JS/native code uses. + const override = + envVersion != null && envVersion !== '' + ? envVersion + : (persisted.versionOverride ?? persisted.version); + const version = override ?? resolveInstalledRnVersion(appRoot); + + if (version == null) { + throw new RemoteVersionError( + `Remote SPM mode is on (URL=${url}) but no React Native version could ` + + 'be resolved (react-native was not found in node_modules). Set ' + + 'RN_SPM_REMOTE_VERSION to a published tag, or install react-native.', + ); + } + if (override == null && !isPublishableVersion(version)) { + throw new RemoteVersionError( + `Remote SPM mode is on (URL=${url}) but React Native resolves to ` + + `non-publishable version '${version}'. Set RN_SPM_REMOTE_VERSION to a ` + + 'published tag, or install a released react-native.', + ); + } + + // Persist {url, versionOverride?} only when env-driven (matching the prior + // behavior of capturing env so Xcode-phase re-syncs keep the mode). A derived + // version is never frozen: omit versionOverride so the next run re-derives. + const envDriven = + (envUrl != null && envUrl !== '') || + (envVersion != null && envVersion !== ''); + if (envDriven) { + const toPersist /*: {url: string, versionOverride?: string} */ = {url}; + if (override != null) { + toPersist.versionOverride = override; + } + fs.mkdirSync(path.dirname(cfgPath), {recursive: true}); + fs.writeFileSync(cfgPath, JSON.stringify(toPersist, null, 2) + '\n'); + } + + return { + url, + version, + identity: remotePackageIdentity(url), + }; +} +function perAppHeadersDir(appRoot /*: string */) /*: string */ { + return path.join(appRoot, PER_APP_HEADERS_REL); +} + +/** + * Creates a first-wins symlink linker rooted at `outDir`. `linkInto` maps a + * virtual import path to a physical file; `foldDir` recursively links every + * .h/.hpp under a source root. `seen` records virtual->realpath so identical + * duplicates collapse to one inode and non-identical collisions are surfaced. + * Each header tree gets its OWN linker (own `seen` map) so first-wins does not + * span the shared/per-app boundary. + */ +function createHeaderLinker( + outDir /*: string */, + logger /*: {log: (msg: string) => void} */, +) /*: { + seen: Map, + stats: {collisions: number}, + linkInto: (virtualPath: string, physical: string) => void, + foldDir: (srcRoot: string) => void, +} */ { + const seen /*: Map */ = new Map(); + const stats = {collisions: 0}; + + function linkInto( + virtualPath /*: string */, + physical /*: string */, + ) /*: void */ { + let real; + try { + real = fs.realpathSync(physical); + } catch { + return; // physical file missing — skip + } + const prev = seen.get(virtualPath); + if (prev != null) { + if (prev !== real) { + stats.collisions++; + logger.log( + `WARNING: merged headers: non-identical collision for ${virtualPath} (kept first)`, + ); + } + return; + } + seen.set(virtualPath, real); + const dest = path.join(outDir, virtualPath); + fs.mkdirSync(path.dirname(dest), {recursive: true}); + fs.symlinkSync(real, dest); + } + + function foldDir(srcRoot /*: string */) /*: void */ { + let real; + try { + real = fs.realpathSync(srcRoot); + } catch { + return; + } + if (!fs.statSync(real).isDirectory()) { + return; + } + const dirs /*: Array */ = [real]; + while (dirs.length > 0) { + const dir = dirs.pop(); + if (dir == null) { + break; + } + for (const ent of fs.readdirSync(dir, {withFileTypes: true})) { + const name = String(ent.name); + const child = path.join(dir, name); + // statSync (not the Dirent flags) so symlinks are followed — the + // autolinking header farm is itself a symlink farm, so its leaf + // headers are symlinks, not regular files. + let st; + try { + st = fs.statSync(child); + } catch { + continue; // broken symlink — skip + } + if (st.isDirectory()) { + dirs.push(child); + } else if ( + st.isFile() && + (name.endsWith('.h') || name.endsWith('.hpp')) + ) { + linkInto(path.relative(real, child), child); + } + } + } + } + + return {seen, stats, linkInto, foldDir}; +} + +/*:: +type HeaderTreeResult = {path: ?string, virtualPaths: Set}; +*/ + +/** + * Materializes the PER-APP header tree at + * /build/xcframeworks/ReactAppHeaders: autolinking dep headers + codegen + * output. Per-app because it depends on which libraries the app links and the + * app's generated specs. Returns {path, virtualPaths}. + */ +function buildPerAppHeaderTree( + appRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, +) /*: HeaderTreeResult */ { + const outDir = perAppHeadersDir(appRoot); + // Build into a temp dir OUTSIDE every folded root, then swap into place. The + // farm's final home (outDir) is INSIDE build/generated/ios — a tree foldDir + // walks — so `outDir + '.tmp'` would ALSO sit there and foldDir would fold + // the half-built farm back into itself (a spurious `ReactAppHeaders.tmp/` + // namespace duplicating every codegen header). build/.react-app-headers.tmp + // is under build/ but not under any folded subdir. + const tmpDir = path.join(appRoot, 'build', '.react-app-headers.tmp'); + fs.rmSync(tmpDir, {recursive: true, force: true}); + fs.rmSync(outDir, {recursive: true, force: true}); + fs.mkdirSync(tmpDir, {recursive: true}); + + const linker = createHeaderLinker(tmpDir, logger); + linker.foldDir( + path.join(appRoot, 'build', 'generated', 'autolinking', 'headers'), + ); + linker.foldDir(path.join(appRoot, 'build', 'generated', 'ios')); + linker.foldDir( + path.join(appRoot, 'build', 'generated', 'ios', 'ReactCodegen'), + ); + + // Stub source so the farm is a valid SPM target (vended headers-only — + // see the ReactAppHeaders target in the codegen Package.swift template). + fs.writeFileSync( + path.join(tmpDir, 'ReactAppHeadersStub.c'), + '// ReactAppHeaders vends the per-app generated headers; this stub\n' + + '// satisfies SPM, which requires at least one source file per target.\n' + + 'static int ReactAppHeadersStub __attribute__((unused)) = 0;\n', + ); + fs.mkdirSync(path.dirname(outDir), {recursive: true}); + fs.renameSync(tmpDir, outDir); + + logger.log( + `Built per-app header tree (${linker.seen.size} headers` + + (linker.stats.collisions > 0 + ? `, ${linker.stats.collisions} non-identical collisions` + : '') + + ')', + ); + return {path: outDir, virtualPaths: new Set(linker.seen.keys())}; +} + +/** + * Runs React Native codegen and installs the SPM Package.swift template + * into build/generated/ios/. Used by both setup-apple-spm.js and + * sync-spm-autolinking.js. + */ + +/** + * Installs the SPM codegen template into build/generated/ios/Package.swift. + * No-op when the template or the generated/ios dir is missing — codegen + * may not have produced output yet, or the project may be SPM-only. + * + * The template is copied verbatim in local mode: it holds only fixed-relative + * `.package(path:)` references (it lives at a known depth inside the app), and + * headers come from the ReactHeaders/ReactNativeHeaders targets + the + * ReactAppHeaders product — no loader, no absolute paths. In remote mode the + * ReactNative path-dep is rewritten to the `.package(url:exact:)` identity. + */ +function installSpmCodegenTemplate( + appRoot /*: string */, + reactNativeRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, +) /*: void */ { + const spmTemplate = path.join( + reactNativeRoot, + 'scripts', + 'codegen', + 'templates', + 'Package.swift.spm-template', + ); + const codegenPkgSwift = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'Package.swift', + ); + if (!fs.existsSync(path.dirname(codegenPkgSwift))) { + // Codegen hasn't produced build/generated/ios yet — normal, nothing to do. + return; + } + if (!fs.existsSync(spmTemplate)) { + // Abnormal: the react-native package is missing its SPM codegen template. + // Without it, codegen's default mis-rooted Package.swift stays in place + // and every subsequent Resolve Package Graph fails — say so loudly. + logger.log( + `warning: SPM codegen template missing at ${spmTemplate} — ` + + 'build/generated/ios/Package.swift was NOT corrected and package ' + + 'resolution will likely fail. Is the react-native package complete?', + ); + return; + } + let content = fs.readFileSync(spmTemplate, 'utf8'); + // Remote mode: the codegen package depends on the remote ReactNative + // package identity instead of the local path-based artifacts package. + const remote = remotePackageConfig(appRoot); + if (remote != null) { + content = content + .replace( + '.package(name: "ReactNative", path: "../../xcframeworks"),', + `.package(url: "${remote.url}", exact: "${remote.version}"),`, + ) + .split('package: "ReactNative")') + .join(`package: "${remote.identity}")`); + } + fs.writeFileSync(codegenPkgSwift, content, 'utf8'); + logger.log( + 'Installed SPM codegen template' + (remote != null ? ' (remote mode)' : ''), + ); +} + +function runCodegenAndInstallTemplate( + projectRoot /*: string */, + appRoot /*: string */, + reactNativeRoot /*: string */, + logger /*: {log: (msg: string) => void} */ = {log() {}}, + opts /*: {installTemplate?: boolean} */ = {}, +) /*: void */ { + const codegenScript = path.join( + reactNativeRoot, + 'scripts', + 'generate-codegen-artifacts.js', + ); + if (!fs.existsSync(codegenScript)) { + return; + } + logger.log('Running codegen...'); + const {execSync} = require('child_process'); + const codegenArgs = + `node "${codegenScript}" -p "${projectRoot}" -t ios` + + (projectRoot !== appRoot ? ` -o "${appRoot}"` : ''); + execSync(codegenArgs, {stdio: 'inherit', cwd: projectRoot}); + // Callers that re-point the xcframework symlinks after codegen (e.g. the SPM + // sync, which runs generate-spm-package afterwards) install the template + // themselves once the symlinks are final; they pass installTemplate: false to + // avoid a wasted write that the later install would immediately supersede. + if (opts.installTemplate !== false) { + installSpmCodegenTemplate(appRoot, reactNativeRoot, logger); + } +} + +module.exports = { + makeLogger, + displayPath, + sharedCacheDir, + defaultCacheDir, + toSwiftName, + deriveAppName, + readPackageJson, + findProjectRoot, + resolveReactNativeRoot, + buildPerAppHeaderTree, + remotePackageConfig, + resolveInstalledRnVersion, + isPublishableVersion, + RemoteVersionError, + installSpmCodegenTemplate, + runCodegenAndInstallTemplate, + SCAFFOLDER_MARKER, +}; diff --git a/packages/react-native/scripts/spm/sync-spm-autolinking.js b/packages/react-native/scripts/spm/sync-spm-autolinking.js new file mode 100644 index 000000000000..c2e2992cc923 --- /dev/null +++ b/packages/react-native/scripts/spm/sync-spm-autolinking.js @@ -0,0 +1,160 @@ +/** + * 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'; + +/** + * sync-spm-autolinking.js – Lightweight script invoked by the Xcode pre-build + * phase to re-run invariant codegen and autolinking output when dependency + * inputs have changed. + * + * Usage (called from the Xcode build phase shell script): + * node sync-spm-autolinking.js --app-root --react-native-root + * + * The autolinking config (autolinking.json) is generated by the caller + * (setup-apple-spm.js) BEFORE this script runs, so codegen below can reuse it + * (findCodegenEnabledLibraries short-circuits on a present autolinking.json) + * and the slow `@react-native-community/cli config` runs exactly once per sync. + * + * This script: + * 0. Runs react-native codegen → build/generated/ios/ (reuses autolinking.json) + * 1. Installs the codegen template into build/generated/ios/Package.swift, + * replacing codegen's mis-rooted default (done immediately, before any + * step below that can throw — see the failure-atomicity comment at the + * call site) + * 2. Calls generate-spm-autolinking.js → build/generated/autolinking/Package.swift + * 3. Rebuilds the generated-headers farm + * 4. Writes build/generated/autolinking/.spm-sync-stamp + * + * Runtime frameworks are deliberately not downloaded or regenerated from an + * Xcode build. `spm add` / `spm update` own the immutable dual-flavor slots and + * the app target's linker/embed configuration. + */ + +const {main: generateAutolinking} = require('./generate-spm-autolinking'); +const { + RemoteVersionError, + buildPerAppHeaderTree, + findProjectRoot, + installSpmCodegenTemplate, + makeLogger, + runCodegenAndInstallTemplate, +} = require('./spm-utils'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs'); + +const {log} = makeLogger('sync-spm-autolinking'); + +// Collaborators are injected (with these real implementations as defaults) so +// main() can be exercised end-to-end in tests without mocking the module +// system. Tests pass fakes that record calls and point defaultCacheDir at a +// tempdir; everything fs-based runs for real against that tempdir. +const defaultDeps = { + runCodegenAndInstallTemplate, + generateAutolinking, + installSpmCodegenTemplate, + buildPerAppHeaderTree, + findProjectRoot, +}; + +async function main( + argv /*:: ?: Array */, + overrides /*:: ?: Partial */, +) /*: Promise */ { + const deps = {...defaultDeps, ...(overrides ?? {})}; + const parsed = yargs(argv ?? process.argv.slice(2)) + .version(false) + .option('app-root', { + type: 'string', + demandOption: true, + describe: 'Path to the app directory', + }) + .option('react-native-root', { + type: 'string', + demandOption: true, + describe: 'Path to react-native package root', + }) + .help() + .parseSync(); + + const appRoot = path.resolve(parsed['app-root']); + const reactNativeRoot = path.resolve(parsed['react-native-root']); + const projectRoot = deps.findProjectRoot(appRoot); + + // The caller (setup-apple-spm.js) already generated autolinking.json before + // invoking this script, so codegen reuses it here. + try { + deps.runCodegenAndInstallTemplate( + projectRoot, + appRoot, + reactNativeRoot, + {log}, + { + installTemplate: false, + }, + ); + } catch { + log('Codegen failed — continuing with existing output'); + } + + // Install the codegen template right away, before artifact download and + // autolinking-plugin execution below. Failure-atomicity: generateAutolinking + // runs fail-closed plugins that can throw, and if that happens before the + // template is installed, codegen's mis-rooted default manifest is left in + // build/generated/ios/Package.swift — breaking every subsequent Xcode + // "Resolve Package Graph". Installing it immediately after codegen means a + // later throw can never leave that broken manifest in place. + deps.installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + + log('Re-generating build/generated/autolinking/Package.swift...'); + deps.generateAutolinking([ + '--app-root', + appRoot, + '--react-native-root', + reactNativeRoot, + ]); + + // Rebuild the per-app generated-headers farm (vended as the ReactAppHeaders + // SPM target inside the codegen package). React core headers need no trees + // — they are vended by the invariant ReactHeaders/ReactNativeHeaders products. + // The + // generated manifests are fully declarative (fixed-relative package paths), + // so no path-locator JSON is written. + deps.buildPerAppHeaderTree(appRoot, {log}); + + const stampPath = path.join( + appRoot, + 'build', + 'generated', + 'autolinking', + '.spm-sync-stamp', + ); + fs.mkdirSync(path.dirname(stampPath), {recursive: true}); + fs.writeFileSync(stampPath, new Date().toISOString() + '\n', 'utf8'); + log('SPM autolinking sync complete.'); +} + +if (require.main === module) { + main().catch(e => { + if (e instanceof RemoteVersionError) { + // Clean message + exit 2 (the build phase hard-fails on it) instead of a + // stack trace, matching how setup-apple-spm.js surfaces remote-mode + // version errors. + log(e.message); + process.exitCode = 2; + return; + } + console.error(e); + process.exitCode = 1; + }); +} + +module.exports = {main}; diff --git a/packages/rn-tester/RNTester/AppDelegate.h b/packages/rn-tester/RNTester/AppDelegate.h index df578639dc44..a4a5daca71ea 100644 --- a/packages/rn-tester/RNTester/AppDelegate.h +++ b/packages/rn-tester/RNTester/AppDelegate.h @@ -5,8 +5,17 @@ * LICENSE file in the root directory of this source tree. */ +// ZERO-I: bare angle includes have no framework spelling, so the SPM zero-I +// build uses the form. rn-tester also builds via CocoaPods, where +// only the bare form resolves — hence the dual. Single-mode consumers write +// just the form matching their setup. +#if __has_include() +#import +#import +#else #import #import +#endif #import @interface AppDelegate : RCTDefaultReactNativeFactoryDelegate diff --git a/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js b/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js new file mode 100644 index 000000000000..9c825a0737d6 --- /dev/null +++ b/packages/rn-tester/js/examples/TestLibrary/TestLibraryExample.ios.js @@ -0,0 +1,102 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +const {RNTesterThemeContext} = require('../../components/RNTesterTheme'); +const React = require('react'); +const {Alert, Button, StyleSheet, Text, View} = require('react-native'); +const {greet} = require('react-native-test-library-apple'); +const {getVersion} = require('react-native-test-library-common'); + +class TestLibraryDemo extends React.Component< + {...}, + {appleResult: string, commonResult: string}, +> { + state: {appleResult: string, commonResult: string} = { + appleResult: '(not called yet)', + commonResult: '(not called yet)', + }; + + render(): React.Node { + return ( + + {theme => ( + + +