From b3f65d2f273aaff40e6dff57291419732db6c0ff Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:24:24 +0530 Subject: [PATCH 1/9] Add standalone Kotlin Multiplatform foundation --- .github/workflows/test-kmp.yml | 89 +++++++ packages/react-native/ReactShared/.gitignore | 3 + packages/react-native/ReactShared/README.md | 43 +++ .../react-native/ReactShared/build.gradle.kts | 55 ++++ .../ReactShared/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + packages/react-native/ReactShared/gradlew | 248 ++++++++++++++++++ packages/react-native/ReactShared/gradlew.bat | 93 +++++++ .../ReactShared/scripts/test-apple-smoke.sh | 51 ++++ .../ReactShared/settings.gradle.kts | 18 ++ .../ReactShared/tests/smoke/AppleKmpSmoke.mm | 31 +++ .../tests/smoke/kotlin/KmpSmoke.kt | 13 + .../tests/smoke/kotlinTest/KmpSmokeTest.kt | 34 +++ 14 files changed, 689 insertions(+) create mode 100644 .github/workflows/test-kmp.yml create mode 100644 packages/react-native/ReactShared/.gitignore create mode 100644 packages/react-native/ReactShared/README.md create mode 100644 packages/react-native/ReactShared/build.gradle.kts create mode 100644 packages/react-native/ReactShared/gradle.properties create mode 100644 packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.jar create mode 100644 packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties create mode 100755 packages/react-native/ReactShared/gradlew create mode 100644 packages/react-native/ReactShared/gradlew.bat create mode 100755 packages/react-native/ReactShared/scripts/test-apple-smoke.sh create mode 100644 packages/react-native/ReactShared/settings.gradle.kts create mode 100644 packages/react-native/ReactShared/tests/smoke/AppleKmpSmoke.mm create mode 100644 packages/react-native/ReactShared/tests/smoke/kotlin/KmpSmoke.kt create mode 100644 packages/react-native/ReactShared/tests/smoke/kotlinTest/KmpSmokeTest.kt diff --git a/.github/workflows/test-kmp.yml b/.github/workflows/test-kmp.yml new file mode 100644 index 000000000000..62a192367d93 --- /dev/null +++ b/.github/workflows/test-kmp.yml @@ -0,0 +1,89 @@ +name: Test Kotlin Multiplatform foundation + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/test-kmp.yml' + - '.github/actions/setup-gradle/**' + - '.github/actions/setup-xcode/**' + - 'packages/react-native/ReactShared/**' + push: + branches: + - main + - '*-stable' + paths: + - '.github/workflows/test-kmp.yml' + - '.github/actions/setup-gradle/**' + - '.github/actions/setup-xcode/**' + - 'packages/react-native/ReactShared/**' + +permissions: + contents: read + +concurrency: + group: kmp-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + runs-on: macos-26 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'zulu' + - name: Set up Xcode + uses: ./.github/actions/setup-xcode + with: + xcode-version: '26.4.1' + - name: Set up Gradle + uses: ./.github/actions/setup-gradle + - name: Cache Kotlin Native toolchain + uses: actions/cache@v5 + with: + path: ~/.konan + key: kmp-foundation-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/react-native/ReactShared/*.gradle.kts', 'packages/react-native/ReactShared/gradle.properties', 'packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties') }} + - name: Test common code and build Apple frameworks + working-directory: packages/react-native/ReactShared + run: >- + ./gradlew -PreactNativeSharedSmoke=true + jvmTest iosSimulatorArm64Test + linkDebugFrameworkIosArm64 linkReleaseFrameworkIosArm64 + linkDebugFrameworkIosSimulatorArm64 linkReleaseFrameworkIosSimulatorArm64 + linkDebugFrameworkIosX64 linkReleaseFrameworkIosX64 + --max-workers=2 --stacktrace --console=plain + - name: Test Objective-C consumption + working-directory: packages/react-native/ReactShared + run: ./scripts/test-apple-smoke.sh + - name: Verify nonempty tests and fixture isolation + working-directory: packages/react-native/ReactShared + run: | + ./gradlew jvmJar --max-workers=2 --console=plain + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import zipfile + for target in ('jvmTest', 'iosSimulatorArm64Test'): + suites = [ET.parse(p).getroot() for p in Path('build/smoke/test-results', target).glob('TEST-*.xml')] + assert suites and sum(int(s.get('tests', 0)) for s in suites) > 0, target + assert all(not int(s.get(k, 0)) for s in suites for k in ('failures', 'errors', 'skipped')), target + jars = list(Path('build/libs').glob('*.jar')) + assert len(jars) == 1, jars + with zipfile.ZipFile(jars[0]) as jar: + assert not any('/smoke/' in name for name in jar.namelist()), 'Test fixture leaked into default output' + PY + - name: Upload smoke reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: kotlin-multiplatform-smoke-results + path: | + packages/react-native/ReactShared/build/smoke/reports/tests + packages/react-native/ReactShared/build/smoke/test-results + packages/react-native/ReactShared/build/smoke/apple-interop/*.log + if-no-files-found: warn diff --git a/packages/react-native/ReactShared/.gitignore b/packages/react-native/ReactShared/.gitignore new file mode 100644 index 000000000000..5d8581f71fc8 --- /dev/null +++ b/packages/react-native/ReactShared/.gitignore @@ -0,0 +1,3 @@ +/build/ +/.gradle/ +/.kotlin/ diff --git a/packages/react-native/ReactShared/README.md b/packages/react-native/ReactShared/README.md new file mode 100644 index 000000000000..3bf5e0b0ca78 --- /dev/null +++ b/packages/react-native/ReactShared/README.md @@ -0,0 +1,43 @@ +# Kotlin Multiplatform foundation + +This standalone build provides a place to assess sharing library code between +the JVM and iOS with Kotlin Multiplatform. It has no Compose or other UI dependency. +No React Native application, Android artifact, CocoaPods target, or npm package +consumes this module. Normal React Native builds and runtime behavior are unchanged. + +The build has no production sources yet. Its small arithmetic fixture is enabled +only by `-PreactNativeSharedSmoke=true`, and its outputs go under `build/smoke`. +The fixture checks the compiler, common tests and Objective-C integer interop; +it is not a proposed React Native API. + +## Validation + +Use JDK 17. Apple validation also requires macOS, Xcode and an installed iOS +simulator. The separate Gradle wrapper isolates the Kotlin/Native plugin from the +main Android build. CI selects Xcode 26.4.1 and uses an Apple Silicon runner. + +From this directory: + +```sh +./gradlew -PreactNativeSharedSmoke=true jvmTest iosSimulatorArm64Test \ + linkDebugFrameworkIosArm64 linkReleaseFrameworkIosArm64 \ + linkDebugFrameworkIosSimulatorArm64 linkReleaseFrameworkIosSimulatorArm64 \ + linkDebugFrameworkIosX64 linkReleaseFrameworkIosX64 \ + --max-workers=2 --console=plain +./scripts/test-apple-smoke.sh +``` + +The native runner checks Debug and Release frameworks through Objective-C on the +host simulator. Set `RCT_KMP_SIMULATOR_UDID` to select an existing simulator. +Intel simulator frameworks are built; Intel execution and physical-device tests +require their respective hosts and are not part of this CI job. + +## Adoption is separate + +Each use case needs its own behavior, dependency, packaging and performance +review before any application consumes shared code. Earlier gradient experiments +measured additional adapter latency, allocation and Kotlin/Native runtime cost; +this foundation does not establish that those costs are acceptable. Android AAR +embedding, Apple runtime ownership, CocoaPods, SwiftPM and prebuilt distribution +belong to those follow-ups. No Apple consumer or framework is designated as the +runtime owner here. diff --git a/packages/react-native/ReactShared/build.gradle.kts b/packages/react-native/ReactShared/build.gradle.kts new file mode 100644 index 000000000000..34ae7aff89ac --- /dev/null +++ b/packages/react-native/ReactShared/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * 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 org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +plugins { kotlin("multiplatform") version "2.4.20" } + +group = "com.facebook.react" + +version = "0.0.0-local" + +val smokeEnabled = + providers.gradleProperty("reactNativeSharedSmoke").map { it.toBooleanStrict() }.getOrElse(false) + +if (smokeEnabled) { + // Keep test-only exports separate from any future production artifacts. + layout.buildDirectory.set(layout.projectDirectory.dir("build/smoke")) +} + +kotlin { + explicitApi() + jvmToolchain(17) + compilerOptions { + // Preserve compatibility with the repository's existing Kotlin consumers. + languageVersion.set(KotlinVersion.KOTLIN_2_2) + apiVersion.set(KotlinVersion.KOTLIN_2_2) + } + jvm { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } + iosArm64() + iosSimulatorArm64() + iosX64() + + targets.withType().configureEach { + binaries.framework { + baseName = "ReactNativeShared" + isStatic = true + binaryOption("bundleId", "com.facebook.react.shared") + } + } + + sourceSets { + commonMain.dependencies { implementation(kotlin("stdlib", "2.2.0")) } + commonTest.dependencies { implementation(kotlin("test")) } + if (smokeEnabled) { + commonMain { kotlin.srcDir("tests/smoke/kotlin") } + commonTest { kotlin.srcDir("tests/smoke/kotlinTest") } + } + } +} diff --git a/packages/react-native/ReactShared/gradle.properties b/packages/react-native/ReactShared/gradle.properties new file mode 100644 index 000000000000..e8b989142732 --- /dev/null +++ b/packages/react-native/ReactShared/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 +org.gradle.caching=true +kotlin.stdlib.default.dependency=false diff --git a/packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.jar b/packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties b/packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..80d82296e83e --- /dev/null +++ b/packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/react-native/ReactShared/gradlew b/packages/react-native/ReactShared/gradlew new file mode 100755 index 000000000000..739907dfd159 --- /dev/null +++ b/packages/react-native/ReactShared/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/react-native/ReactShared/gradlew.bat b/packages/react-native/ReactShared/gradlew.bat new file mode 100644 index 000000000000..c4bdd3ab8e3c --- /dev/null +++ b/packages/react-native/ReactShared/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/react-native/ReactShared/scripts/test-apple-smoke.sh b/packages/react-native/ReactShared/scripts/test-apple-smoke.sh new file mode 100755 index 000000000000..81ed4afe4c4e --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-apple-smoke.sh @@ -0,0 +1,51 @@ +#!/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 -euo pipefail + +shared_root="$(cd "$(dirname "$0")/.." && pwd)" +architecture="$(uname -m)" +case "$architecture" in + arm64) target=IosSimulatorArm64; kotlin_target=iosSimulatorArm64 ;; + x86_64) target=IosX64; kotlin_target=iosX64 ;; + *) echo "error: Unsupported simulator architecture: $architecture" >&2; exit 1 ;; +esac + +"$shared_root/gradlew" -p "$shared_root" -PreactNativeSharedSmoke=true \ + "linkDebugFramework$target" "linkReleaseFramework$target" --max-workers=2 --console=plain + +output="$shared_root/build/smoke/apple-interop" +mkdir -p "$output" +sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +simulator="${RCT_KMP_SIMULATOR_UDID:-}" +if [[ -z "$simulator" ]]; then + simulator="$(xcrun simctl list -j | python3 -c ' +import json, sys +state = json.load(sys.stdin) +runtimes = sorted((r for r in state["runtimes"] if r.get("isAvailable") and ".iOS-" in r["identifier"]), + key=lambda r: tuple(map(int, r["version"].split("."))), reverse=True) +for runtime in runtimes: + devices = [d for d in state["devices"].get(runtime["identifier"], []) if d.get("isAvailable")] + if devices: + print(devices[0]["udid"]) + break +')" +fi +if [[ -z "$simulator" ]]; then + echo 'error: Install an iOS simulator runtime and create a device in Xcode.' >&2 + exit 1 +fi + +for configuration in debug release; do + frameworks="$shared_root/build/smoke/bin/$kotlin_target/${configuration}Framework" + executable="$output/AppleKmpSmoke-$configuration" + xcrun clang++ -std=c++20 -fobjc-arc -target "$architecture-apple-ios15.1-simulator" \ + -isysroot "$sdk" -F "$frameworks" "$shared_root/tests/smoke/AppleKmpSmoke.mm" \ + -framework ReactNativeShared -framework Foundation -o "$executable" + # Standalone execution does not boot or alter the selected simulator. + xcrun simctl spawn --standalone "$simulator" "$executable" 2>&1 | tee "$output/$configuration.log" + grep -q '^Kotlin Objective-C smoke passed: 5 cases$' "$output/$configuration.log" +done diff --git a/packages/react-native/ReactShared/settings.gradle.kts b/packages/react-native/ReactShared/settings.gradle.kts new file mode 100644 index 000000000000..4d578820419a --- /dev/null +++ b/packages/react-native/ReactShared/settings.gradle.kts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { repositories { mavenCentral() } } + +// A separate build isolates the Native compiler from AGP's built-in Kotlin plugin. +rootProject.name = "react-native-shared" diff --git a/packages/react-native/ReactShared/tests/smoke/AppleKmpSmoke.mm b/packages/react-native/ReactShared/tests/smoke/AppleKmpSmoke.mm new file mode 100644 index 000000000000..9227fd266193 --- /dev/null +++ b/packages/react-native/ReactShared/tests/smoke/AppleKmpSmoke.mm @@ -0,0 +1,31 @@ +/* + * 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 + +#include +#include + +int main() +{ + @autoreleasepool { + struct Case { + int32_t left; + int32_t right; + int32_t expected; + }; + const Case cases[] = {{19, 23, 42}, {-7, 4, -3}, {0, 0, 0}, {INT32_MAX, 0, INT32_MAX}, {INT32_MIN, 0, INT32_MIN}}; + for (const auto &test : cases) { + if ([RNSKmpSmoke.shared addLeft:test.left right:test.right] != test.expected) { + fprintf(stderr, "Kotlin Objective-C integer interop failed\n"); + return 1; + } + } + puts("Kotlin Objective-C smoke passed: 5 cases"); + } + return 0; +} diff --git a/packages/react-native/ReactShared/tests/smoke/kotlin/KmpSmoke.kt b/packages/react-native/ReactShared/tests/smoke/kotlin/KmpSmoke.kt new file mode 100644 index 000000000000..f8e52f414e2a --- /dev/null +++ b/packages/react-native/ReactShared/tests/smoke/kotlin/KmpSmoke.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.shared.smoke + +// Unpublished compiler/interop fixture, included only with reactNativeSharedSmoke=true. +public object KmpSmoke { + public fun add(left: Int, right: Int): Int = left + right +} diff --git a/packages/react-native/ReactShared/tests/smoke/kotlinTest/KmpSmokeTest.kt b/packages/react-native/ReactShared/tests/smoke/kotlinTest/KmpSmokeTest.kt new file mode 100644 index 000000000000..73325f799788 --- /dev/null +++ b/packages/react-native/ReactShared/tests/smoke/kotlinTest/KmpSmokeTest.kt @@ -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. + */ + +package com.facebook.react.shared.smoke + +import kotlin.test.Test +import kotlin.test.assertEquals + +class KmpSmokeTest { + @Test + fun positiveIntegers() { + assertEquals(42, KmpSmoke.add(19, 23)) + } + + @Test + fun signedIntegers() { + assertEquals(-3, KmpSmoke.add(-7, 4)) + } + + @Test + fun zero() { + assertEquals(0, KmpSmoke.add(0, 0)) + } + + @Test + fun integerBoundaries() { + assertEquals(Int.MAX_VALUE, KmpSmoke.add(Int.MAX_VALUE, 0)) + assertEquals(Int.MIN_VALUE, KmpSmoke.add(Int.MIN_VALUE, 0)) + } +} From 6945e2ae0a216dae958fdd50967689ae8bceb7c3 Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:19:11 +0530 Subject: [PATCH 2/9] Declare RNTester test pod native module and JS engine dependencies --- packages/rn-tester/RCTTest/React-RCTTest.podspec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/rn-tester/RCTTest/React-RCTTest.podspec b/packages/rn-tester/RCTTest/React-RCTTest.podspec index e96768635781..b662ace742b5 100644 --- a/packages/rn-tester/RCTTest/React-RCTTest.podspec +++ b/packages/rn-tester/RCTTest/React-RCTTest.podspec @@ -38,8 +38,10 @@ Pod::Spec.new do |s| s.dependency "React-Core", version s.dependency "React-CoreModules", version s.dependency "ReactCommon/turbomodule/core", version + s.dependency "React-NativeModulesApple", version s.dependency "React-jsi", version + depend_on_js_engine(s) add_rn_third_party_dependencies(s) add_rncore_dependency(s) end From 597c840e10262c6e84ebbc34fbd835656295a909 Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:30:30 +0530 Subject: [PATCH 3/9] Share gradient stop calculations through Kotlin Multiplatform --- .github/workflows/test-all.yml | 7 +- .github/workflows/test-kmp.yml | 283 +++++++++++- build.gradle.kts | 2 + packages/react-native/Package.swift | 4 + .../React/Fabric/Utils/RCTGradientUtils.mm | 52 +++ .../React/React-RCTFabric.podspec | 19 +- .../ReactAndroid/build.gradle.kts | 6 + .../react/uimanager/style/ColorStop.kt | 183 +------- .../react/uimanager/style/ColorStopTest.kt | 86 ++++ packages/react-native/ReactShared/README.md | 183 ++++++-- .../ReactShared/React-KMP.podspec | 36 ++ .../react-native/ReactShared/build.gradle.kts | 24 +- .../ReactShared/cocoapods/ReactNativeShared.m | 9 + .../scripts/benchmark-apple-gradient.py | 145 ++++++ .../scripts/benchmark-kmp-build.py | 81 ++++ .../scripts/build-apple-framework.sh | 52 +++ .../scripts/test-android-consumers.py | 427 ++++++++++++++++++ .../ReactShared/scripts/test-apple-app.sh | 219 +++++++++ .../scripts/test-apple-distribution.sh | 117 +++++ .../scripts/test-apple-gradient.sh | 119 +++++ .../scripts/test-cocoapods-linking.rb | 116 +++++ .../scripts/test-kotlin-coexistence.py | 132 ++++++ .../facebook/react/shared/GradientStops.kt | 208 +++++++++ .../react/shared/GradientStopsTest.kt | 262 +++++++++++ .../tests/AppleGradientBenchmark.mm | 154 +++++++ .../ReactShared/tests/AppleGradientParity.mm | 106 +++++ .../coexistence/AppleKotlinCoexistence.mm | 69 +++ .../tests/coexistence/IndependentKotlin.kt | 20 + .../coexistence/ReactNativeRuntimeOwner.mm | 20 + .../tests/distribution/Package.swift | 25 + .../tests/distribution/Sources/Probe/Probe.m | 33 ++ .../Sources/Probe/include/Probe.h | 8 + .../Tests/ProbeTests/ProbeTests.swift | 15 + packages/react-native/package.json | 13 + .../react-native/scripts/cocoapods/kmp.rb | 29 ++ .../react-native/scripts/react_native_pods.rb | 10 + packages/react-native/settings.gradle.kts | 2 + settings.gradle.kts | 2 + 38 files changed, 3077 insertions(+), 201 deletions(-) create mode 100644 packages/react-native/ReactShared/React-KMP.podspec create mode 100644 packages/react-native/ReactShared/cocoapods/ReactNativeShared.m create mode 100644 packages/react-native/ReactShared/scripts/benchmark-apple-gradient.py create mode 100644 packages/react-native/ReactShared/scripts/benchmark-kmp-build.py create mode 100755 packages/react-native/ReactShared/scripts/build-apple-framework.sh create mode 100644 packages/react-native/ReactShared/scripts/test-android-consumers.py create mode 100755 packages/react-native/ReactShared/scripts/test-apple-app.sh create mode 100755 packages/react-native/ReactShared/scripts/test-apple-distribution.sh create mode 100755 packages/react-native/ReactShared/scripts/test-apple-gradient.sh create mode 100644 packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb create mode 100644 packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py create mode 100644 packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/GradientStops.kt create mode 100644 packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/GradientStopsTest.kt create mode 100644 packages/react-native/ReactShared/tests/AppleGradientBenchmark.mm create mode 100644 packages/react-native/ReactShared/tests/AppleGradientParity.mm create mode 100644 packages/react-native/ReactShared/tests/coexistence/AppleKotlinCoexistence.mm create mode 100644 packages/react-native/ReactShared/tests/coexistence/IndependentKotlin.kt create mode 100644 packages/react-native/ReactShared/tests/coexistence/ReactNativeRuntimeOwner.mm create mode 100644 packages/react-native/ReactShared/tests/distribution/Package.swift create mode 100644 packages/react-native/ReactShared/tests/distribution/Sources/Probe/Probe.m create mode 100644 packages/react-native/ReactShared/tests/distribution/Sources/Probe/include/Probe.h create mode 100644 packages/react-native/ReactShared/tests/distribution/Tests/ProbeTests/ProbeTests.swift create mode 100644 packages/react-native/scripts/cocoapods/kmp.rb diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index f1ee1caab331..fa1c87fd937f 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -41,8 +41,8 @@ jobs: if: github.repository == 'react/react-native' outputs: any_code_change: ${{ steps.filter_exclusions.outputs.any_code_change == 'true' || github.event_name != 'pull_request' }} - should_test_android: ${{ steps.filter_exclusions.outputs.should_test_android == 'true' || github.event_name != 'pull_request' }} - should_test_ios: ${{ steps.filter_exclusions.outputs.should_test_ios == 'true' || github.event_name != 'pull_request' }} + should_test_android: ${{ steps.filter_exclusions.outputs.should_test_android == 'true' || steps.filter_inclusions.outputs.react_shared == 'true' || github.event_name != 'pull_request' }} + should_test_ios: ${{ steps.filter_exclusions.outputs.should_test_ios == 'true' || steps.filter_inclusions.outputs.react_shared == 'true' || github.event_name != 'pull_request' }} debugger_shell: ${{ steps.filter_inclusions.outputs.debugger_shell }} steps: - name: Checkout @@ -85,6 +85,9 @@ jobs: id: filter_inclusions with: filters: | + # Shared Kotlin and its build configuration affect both platforms. + react_shared: + - 'packages/react-native/ReactShared/**' debugger_shell: - 'packages/debugger-shell/**' - 'scripts/debugger-shell/**' diff --git a/.github/workflows/test-kmp.yml b/.github/workflows/test-kmp.yml index 62a192367d93..62cf7911d773 100644 --- a/.github/workflows/test-kmp.yml +++ b/.github/workflows/test-kmp.yml @@ -1,4 +1,4 @@ -name: Test Kotlin Multiplatform foundation +name: Test Kotlin Multiplatform pilot on: workflow_dispatch: @@ -7,7 +7,37 @@ on: - '.github/workflows/test-kmp.yml' - '.github/actions/setup-gradle/**' - '.github/actions/setup-xcode/**' + - '.github/actions/setup-node/**' + - '.github/actions/yarn-install/**' + - 'Gemfile' + - 'Gemfile.lock' + - 'build.gradle.kts' + - 'gradle.properties' + - 'gradle/wrapper/**' + - 'settings.gradle.kts' + - 'yarn.lock' + - 'packages/rn-tester/Podfile' + - 'packages/rn-tester/RCTTest/**' + - 'packages/rn-tester/RNTesterPods.xcodeproj/**' + - 'packages/rn-tester/RNTester/RNTester.xctestplan' - 'packages/react-native/ReactShared/**' + - 'packages/react-native/ReactAndroid/build.gradle.kts' + - 'packages/react-native/ReactAndroid/gradle.properties' + - 'packages/react-native/gradle/libs.versions.toml' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt' + - 'packages/react-native/React/Fabric/Utils/RCTGradientUtils.*' + - 'packages/react-native/React/Fabric/RCTConversions.h' + - 'packages/react-native/Libraries/NativeAnimation/RCTAnimationUtils.*' + - 'packages/react-native/ReactCommon/react/renderer/graphics/**' + - 'packages/react-native/ReactCommon/react/utils/FloatComparison.h' + - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' + - 'packages/react-native/React/React-RCTFabric.podspec' + - 'packages/react-native/scripts/react_native_pods.rb' + - 'packages/react-native/scripts/cocoapods/kmp.rb' + - 'packages/react-native/Package.swift' + - 'packages/react-native/package.json' + - 'packages/react-native/settings.gradle.kts' push: branches: - main @@ -16,7 +46,37 @@ on: - '.github/workflows/test-kmp.yml' - '.github/actions/setup-gradle/**' - '.github/actions/setup-xcode/**' + - '.github/actions/setup-node/**' + - '.github/actions/yarn-install/**' + - 'Gemfile' + - 'Gemfile.lock' + - 'build.gradle.kts' + - 'gradle.properties' + - 'gradle/wrapper/**' + - 'settings.gradle.kts' + - 'yarn.lock' + - 'packages/rn-tester/Podfile' + - 'packages/rn-tester/RCTTest/**' + - 'packages/rn-tester/RNTesterPods.xcodeproj/**' + - 'packages/rn-tester/RNTester/RNTester.xctestplan' - 'packages/react-native/ReactShared/**' + - 'packages/react-native/ReactAndroid/build.gradle.kts' + - 'packages/react-native/ReactAndroid/gradle.properties' + - 'packages/react-native/gradle/libs.versions.toml' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/style/ColorStopTest.kt' + - 'packages/react-native/React/Fabric/Utils/RCTGradientUtils.*' + - 'packages/react-native/React/Fabric/RCTConversions.h' + - 'packages/react-native/Libraries/NativeAnimation/RCTAnimationUtils.*' + - 'packages/react-native/ReactCommon/react/renderer/graphics/**' + - 'packages/react-native/ReactCommon/react/utils/FloatComparison.h' + - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' + - 'packages/react-native/React/React-RCTFabric.podspec' + - 'packages/react-native/scripts/react_native_pods.rb' + - 'packages/react-native/scripts/cocoapods/kmp.rb' + - 'packages/react-native/Package.swift' + - 'packages/react-native/package.json' + - 'packages/react-native/settings.gradle.kts' permissions: contents: read @@ -26,6 +86,227 @@ concurrency: cancel-in-progress: true jobs: + test-shared: + runs-on: macos-26 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'zulu' + - name: Set up Xcode for Kotlin Native + uses: ./.github/actions/setup-xcode + with: + xcode-version: '26.4.1' + - name: Set up Gradle + uses: ./.github/actions/setup-gradle + - name: Set up Ruby and CocoaPods + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.0' + bundler-cache: true + - name: Check CocoaPods linking configurations + run: bundle exec ruby packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb + - name: Cache Kotlin Native toolchain + uses: actions/cache@v5 + with: + path: ~/.konan + key: kmp-native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/react-native/ReactShared/*.gradle.kts', 'packages/react-native/ReactShared/gradle.properties', 'packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties') }} + - name: Test shared code on JVM and iOS + id: shared + working-directory: packages/react-native/ReactShared + run: >- + ./gradlew + jvmTest iosSimulatorArm64Test + linkDebugFrameworkIosArm64 linkReleaseFrameworkIosArm64 + linkDebugFrameworkIosSimulatorArm64 linkReleaseFrameworkIosSimulatorArm64 + linkDebugFrameworkIosX64 linkReleaseFrameworkIosX64 + --stacktrace --console=plain + - name: Compare the Apple adapter with native gradients + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: ./scripts/test-apple-gradient.sh + - name: Compare optimized Apple gradients + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + env: + RCT_KMP_BUILD_TYPE: Release + run: ./scripts/test-apple-gradient.sh + - name: Test packaged XCFramework consumption + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: ./scripts/test-apple-distribution.sh + - name: Test independent Kotlin runtime coexistence + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: python3 scripts/test-kotlin-coexistence.py + - name: Measure native gradient costs + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: python3 scripts/benchmark-apple-gradient.py + - name: Measure clean and incremental shared builds + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: python3 scripts/benchmark-kmp-build.py + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: kotlin-multiplatform-test-results + path: | + packages/react-native/ReactShared/build/reports/tests + packages/react-native/ReactShared/build/test-results + packages/react-native/ReactShared/build/apple-distribution/**/*.json + packages/react-native/ReactShared/build/apple-distribution/**/*.log + packages/react-native/ReactShared/build/apple-distribution/**/consumer.xcresult + packages/react-native/ReactShared/build/kotlin-coexistence/**/*.json + packages/react-native/ReactShared/build/kotlin-coexistence/**/*.log + packages/react-native/ReactShared/build/apple-gradient-benchmark/**/*.json + packages/react-native/ReactShared/build/apple-gradient-benchmark/**/*.log + packages/react-native/ReactShared/build/kmp-build-benchmark/**/*.json + packages/react-native/ReactShared/build/kmp-build-benchmark/**/*.log + if-no-files-found: warn + + test-android-consumers: + name: Android Maven and npm source consumers + runs-on: 8-core-ubuntu + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Node + uses: ./.github/actions/setup-node + - name: Install JavaScript dependencies + uses: ./.github/actions/yarn-install + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'zulu' + - name: Set up Gradle + uses: ./.github/actions/setup-gradle + - name: Install Android build SDK + id: android-sdk + run: | + python3 - <<'PY' + import os, pathlib, re, shutil, subprocess, tomllib + versions = tomllib.loads(pathlib.Path('packages/react-native/gradle/libs.versions.toml').read_text())['versions'] + sdkmanager = shutil.which('sdkmanager') or str(pathlib.Path(os.environ['ANDROID_HOME']) / 'cmdline-tools/latest/bin/sdkmanager') + packages = subprocess.check_output([sdkmanager, '--list'], text=True) + base = 'platforms;android-' + versions['compileSdk'] + # Recent SDKs use a minor version in their package ID (for example android-37.0). + platform = next((name for name in [base + '.0', base] if re.search(r'^\s*' + re.escape(name) + r'\s*\|', packages, re.M)), None) + if platform is None: + raise SystemExit('No Android SDK package matches compileSdk ' + versions['compileSdk']) + subprocess.run([sdkmanager, platform, 'build-tools;' + versions['buildTools']], check=True) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write('ndk=' + versions['ndkVersion'] + '\n') + PY + - name: Enable emulator hardware acceleration + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Build and run packaged Android consumers + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + target: google_apis + arch: x86_64 + ndk: ${{ steps.android-sdk.outputs.ndk }} + cmake: 3.30.5 + cores: 4 + disable-animations: true + script: python3 packages/react-native/ReactShared/scripts/test-android-consumers.py --output-dir "$RUNNER_TEMP/kmp-android-consumers" --abi x86_64 --device emulator-5554 + - name: Upload consumer reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: kmp-android-consumers + path: | + ${{ runner.temp }}/kmp-android-consumers/*.log + ${{ runner.temp }}/kmp-android-consumers/*.json + if-no-files-found: warn + + test-apple-app: + name: RNTester KMP ${{ matrix.platform }} (${{ matrix.linkage }}) + runs-on: macos-26 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - platform: simulator + linkage: libraries + - platform: simulator + linkage: static + - platform: simulator + linkage: dynamic + - platform: device + linkage: libraries + - platform: catalyst + linkage: libraries + env: + RCT_KMP_APP_OUTPUT_DIR: ${{ github.workspace }}/packages/react-native/ReactShared/build/apple-app-results + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Node + uses: ./.github/actions/setup-node + - name: Install JavaScript dependencies + uses: ./.github/actions/yarn-install + - name: Select the Hermes compiler package + run: node scripts/releases/use-hermes-prebuilt.js + - name: Install the selected Hermes compiler + uses: ./.github/actions/yarn-install + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'zulu' + - name: Set up Xcode for Kotlin Native + uses: ./.github/actions/setup-xcode + with: + xcode-version: '26.4.1' + - name: Set up Gradle + uses: ./.github/actions/setup-gradle + - name: Set up Ruby and CocoaPods + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2.0' + bundler-cache: true + - name: Cache Kotlin Native toolchain + uses: actions/cache@v5 + with: + path: ~/.konan + key: kmp-native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/react-native/ReactShared/*.gradle.kts', 'packages/react-native/ReactShared/gradle.properties', 'packages/react-native/ReactShared/gradle/wrapper/gradle-wrapper.properties') }} + - name: Build and validate the KMP source application + run: | + if [[ '${{ matrix.linkage }}' != libraries ]]; then + export USE_FRAMEWORKS='${{ matrix.linkage }}' + fi + ./packages/react-native/ReactShared/scripts/test-apple-app.sh '${{ matrix.platform }}' + - name: Upload application test reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: kmp-app-${{ matrix.platform }}-${{ matrix.linkage }} + path: | + ${{ env.RCT_KMP_APP_OUTPUT_DIR }}/*.log + ${{ env.RCT_KMP_APP_OUTPUT_DIR }}/*.json + ${{ env.RCT_KMP_APP_OUTPUT_DIR }}/*.txt + ${{ env.RCT_KMP_APP_OUTPUT_DIR }}/*.xcresult + ${{ env.RCT_KMP_APP_OUTPUT_DIR }}/Podfile* + ${{ env.RCT_KMP_APP_OUTPUT_DIR }}/project.pbxproj + if-no-files-found: warn + smoke: runs-on: macos-26 timeout-minutes: 45 diff --git a/build.gradle.kts b/build.gradle.kts index 19fcc1df1a66..c56aa5238203 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,6 +72,7 @@ nexusPublishing { tasks.register("clean", Delete::class.java) { description = "Remove all the build files and intermediate build outputs" dependsOn(gradle.includedBuild("gradle-plugin").task(":clean")) + dependsOn(gradle.includedBuild("react-native-shared").task(":clean")) subprojects.forEach { if ( it.project.plugins.hasPlugin("com.android.library") || @@ -103,6 +104,7 @@ tasks.register("clean", Delete::class.java) { tasks.register("build") { description = "Build and test all the React Native relevant projects." dependsOn(gradle.includedBuild("gradle-plugin").task(":build")) + dependsOn(gradle.includedBuild("react-native-shared").task(":jvmTest")) } tasks.register("publishAllToMavenTempLocal") { diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index 386ca6958b7f..5e35ce276fdb 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -9,6 +9,10 @@ import Foundation import PackageDescription +if ProcessInfo.processInfo.environment["RCT_USE_KMP"] == "1" { + fatalError("RCT_USE_KMP=1 currently supports CocoaPods source builds only. Run RCT_USE_KMP=1 pod install in the iOS app; the SwiftPM prebuild does not include ReactNativeShared.") +} + let BUILD_FROM_SOURCE = false // Removing the legacy TurboModule and component interop layers is opt-in while those diff --git a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm index ee0f5ff8e0c9..8efb481a1d6f 100644 --- a/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm +++ b/packages/react-native/React/Fabric/Utils/RCTGradientUtils.mm @@ -8,10 +8,18 @@ #import "RCTGradientUtils.h" #import #import +#import #import #include #import +#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST +#define RCT_GRADIENT_USE_KMP 1 +#import +#else +#define RCT_GRADIENT_USE_KMP 0 +#endif + using namespace facebook::react; namespace { @@ -156,6 +164,45 @@ CGSize calculateMultipliers(CGSize bounds) return std::nullopt; } +#if RCT_GRADIENT_USE_KMP +static std::vector resolveSharedColorStops( + const std::vector &colorStops, + CGFloat gradientLineLength) +{ + NSMutableArray *inputs = [NSMutableArray arrayWithCapacity:colorStops.size()]; + for (const auto &stop : colorStops) { + auto position = resolveColorStopPosition(stop.position, gradientLineLength); + RNSDouble *boxedPosition = position.has_value() ? [RNSDouble numberWithDouble:position.value()] : nil; + auto input = [[RNSGradientStopInput alloc] initWithPosition:boxedPosition hasColor:static_cast(stop.color)]; + [inputs addObject:input]; + } + + auto resolved = [RNSGradientStops.shared resolveStops:inputs epsilon:kDefaultEpsilon useDoublePrecision:YES]; + std::vector result; + result.reserve(resolved.count); + NSArray *inputRange = @[ @0.0, @1.0 ]; + for (RNSResolvedGradientStop *stop in resolved) { + const auto &leftColor = colorStops[stop.leftColorIndex].color; + SharedColor color; + if (stop.leftColorIndex == stop.rightColorIndex) { + // Preserve the original native color, including dynamic UIColor behavior. + color = leftColor; + } else if (std::isfinite(stop.weight)) { + const auto &rightColor = colorStops[stop.rightColorIndex].color; + NSArray *outputRange = + @[ RCTUIColorFromSharedColor(leftColor), RCTUIColorFromSharedColor(rightColor) ]; + auto interpolatedColor = RCTInterpolateColorInRange(stop.weight, inputRange, outputRange); + auto alpha = (interpolatedColor >> 24) & 0xFF; + auto red = (interpolatedColor >> 16) & 0xFF; + auto green = (interpolatedColor >> 8) & 0xFF; + auto blue = interpolatedColor & 0xFF; + color = colorFromRGBA(red, green, blue, alpha); + } + result.push_back({.color = color, .position = stop.position}); + } + return result; +} +#else // Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint section) // Browsers add 9 intermediate color stops when a transition hint is present // Algorithm is referred from Blink engine @@ -261,12 +308,16 @@ CGSize calculateMultipliers(CGSize bounds) return colorStops; } +#endif @implementation RCTGradientUtils // https://drafts.csswg.org/css-images-4/#color-stop-fixup + (std::vector)getFixedColorStops:(const std::vector &)colorStops gradientLineLength:(CGFloat)gradientLineLength { +#if RCT_GRADIENT_USE_KMP + return resolveSharedColorStops(colorStops, gradientLineLength); +#else if (colorStops.empty()) { return {}; } @@ -334,6 +385,7 @@ @implementation RCTGradientUtils } } return processColorTransitionHints(fixedColorStops); +#endif } // CAGradientLayer linear gradient squishes the non-square gradient to square gradient. diff --git a/packages/react-native/React/React-RCTFabric.podspec b/packages/react-native/React/React-RCTFabric.podspec index 121669d1fa0e..e196aae8fdf0 100644 --- a/packages/react-native/React/React-RCTFabric.podspec +++ b/packages/react-native/React/React-RCTFabric.podspec @@ -17,6 +17,10 @@ else end new_arch_flags = ENV['RCT_NEW_ARCH_ENABLED'] == '1' ? ' -DRCT_NEW_ARCH_ENABLED=1' : '' +kmp_enabled = ENV['RCT_USE_KMP'] == '1' +if kmp_enabled && ENV['RCT_USE_PREBUILT_RNCORE'] != '0' + raise 'RCT_USE_KMP=1 requires React Native core source builds. Use use_react_native! or set RCT_USE_PREBUILT_RNCORE=0.' +end header_search_paths = [ "\"$(PODS_TARGET_SRCROOT)/ReactCommon\"", @@ -50,13 +54,26 @@ Pod::Spec.new do |s| s.module_name = module_name s.weak_framework = "JavaScriptCore" s.framework = "MobileCoreServices" - s.pod_target_xcconfig = { + pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => header_search_paths, "OTHER_CFLAGS" => "$(inherited) " + new_arch_flags, "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard() }.merge!(ENV['USE_FRAMEWORKS'] != nil ? { "PUBLIC_HEADERS_FOLDER_PATH" => "#{module_name}.framework/Headers/#{header_dir}" }: {}) + if kmp_enabled + # Catalyst has no Kotlin/Native target. Its build keeps the existing implementation. + s.dependency 'React-KMP' + pod_target_xcconfig.merge!({ + 'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphoneos*]' => '$(inherited) RCT_USE_KMP=1', + 'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]' => '$(inherited) RCT_USE_KMP=1', + 'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', + 'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', + 'OTHER_LDFLAGS[sdk=iphoneos*]' => '$(inherited) -framework ReactNativeShared', + 'OTHER_LDFLAGS[sdk=iphonesimulator*]' => '$(inherited) -framework ReactNativeShared', + }) + end + s.pod_target_xcconfig = pod_target_xcconfig s.dependency "React-Core" s.dependency "React-RCTImage" diff --git a/packages/react-native/ReactAndroid/build.gradle.kts b/packages/react-native/ReactAndroid/build.gradle.kts index f792d51a8567..f548f678cf58 100644 --- a/packages/react-native/ReactAndroid/build.gradle.kts +++ b/packages/react-native/ReactAndroid/build.gradle.kts @@ -717,6 +717,12 @@ tasks.withType().configureEach { } dependencies { + // Embed the common Kotlin implementation in react-android's AAR. This keeps published + // consumers on the existing artifact instead of requiring a separate KMP publication. + implementation( + files("$reactNativeRootDir/ReactShared/build/android/react-native-shared.jar") + .builtBy(gradle.includedBuild("react-native-shared").task(":exportAndroidJar")) + ) api(libs.androidx.appcompat) api(libs.androidx.appcompat.resources) api(libs.androidx.autofill) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt index 32e101bcbcf1..c2a0eab93a38 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ColorStop.kt @@ -8,11 +8,11 @@ package com.facebook.react.uimanager.style import androidx.core.graphics.ColorUtils -import com.facebook.react.uimanager.FloatUtil +import com.facebook.react.shared.GradientStopInput +import com.facebook.react.shared.GradientStops import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PixelUtil -import kotlin.math.ln /** * Represents a color stop in a gradient as specified by the user. @@ -68,170 +68,25 @@ internal object ColorStopUtils { colorStops: List, gradientLineLength: Float, ): List { - val fixedColorStops = Array(colorStops.size) { ProcessedColorStop() } - var hasNullPositions = false - var maxPositionSoFar = - resolveColorStopPosition(colorStops[0].position, gradientLineLength) ?: 0f - - for (i in colorStops.indices) { - val colorStop = colorStops[i] - var newPosition = resolveColorStopPosition(colorStop.position, gradientLineLength) - - // Step 1: - // If the first color stop does not have a position, - // set its position to 0%. If the last color stop does not have a position, - // set its position to 100%. - newPosition = - newPosition - ?: when (i) { - 0 -> 0f - colorStops.size - 1 -> 1f - else -> null - } - - // Step 2: - // If a color stop or transition hint has a position - // that is less than the specified position of any color stop or transition hint - // before it in the list, set its position to be equal to the - // largest specified position of any color stop or transition hint before it. - if (newPosition != null) { - newPosition = maxOf(newPosition, maxPositionSoFar) - fixedColorStops[i] = ProcessedColorStop(colorStop.color, newPosition) - maxPositionSoFar = newPosition - } else { - hasNullPositions = true - } - } - - // Step 3: - // If any color stop still does not have a position, - // then, for each run of adjacent color stops without positions, - // set their positions so that they are evenly spaced between the preceding and - // following color stops with positions. - if (hasNullPositions) { - var lastDefinedIndex = 0 - for (i in 1 until fixedColorStops.size) { - val endPosition = fixedColorStops[i].position - val startPosition = fixedColorStops[lastDefinedIndex].position - val unpositionedStops = i - lastDefinedIndex - 1 - if (endPosition != null && startPosition != null && unpositionedStops > 0) { - val increment = (endPosition - startPosition) / (unpositionedStops + 1) - for (j in 1..unpositionedStops) { - fixedColorStops[lastDefinedIndex + j] = - ProcessedColorStop( - colorStops[lastDefinedIndex + j].color, - startPosition + increment * j, - ) - } - lastDefinedIndex = i - } else if (endPosition != null) { - // Current stop has a defined position but there are no unpositioned - // stops between lastDefinedIndex and i. Still need to advance - // lastDefinedIndex so that subsequent interpolation uses the - // correct start point instead of stale data. - lastDefinedIndex = i + val inputs = + colorStops.map { stop -> + GradientStopInput( + resolveColorStopPosition(stop.position, gradientLineLength)?.toDouble(), + stop.color != null, + ) } - } - } - - return processColorTransitionHints(fixedColorStops) - } - - // Spec: https://drafts.csswg.org/css-images-4/#coloring-gradient-line (Refer transition hint - // section) - // Browsers add 9 intermediate color stops when a transition hint is present - // Algorithm is referred from Blink engine - // [source](https://github.com/chromium/chromium/blob/a296b1bad6dc1ed9d751b7528f7ca2134227b828/third_party/blink/renderer/core/css/css_gradient_value.cc#L240). - private fun processColorTransitionHints( - originalStops: Array, - ): List { - val colorStops = originalStops.toMutableList() - var indexOffset = 0 - - for (i in 1 until originalStops.size - 1) { - // Skip if not a color hint - if (originalStops[i].color != null) { - continue - } - - val x = i + indexOffset - if (x < 1) { - continue - } - - val offsetLeft = colorStops[x - 1].position - val offsetRight = colorStops[x + 1].position - val offset = colorStops[x].position - if (offsetLeft == null || offsetRight == null || offset == null) { - continue - } - val leftDist = offset - offsetLeft - val rightDist = offsetRight - offset - val totalDist = offsetRight - offsetLeft - val leftColor = colorStops[x - 1].color - val rightColor = colorStops[x + 1].color - - if (FloatUtil.floatsEqual(leftDist, rightDist)) { - colorStops.removeAt(x) - --indexOffset - continue - } - - if (FloatUtil.floatsEqual(leftDist, 0f)) { - colorStops[x].color = rightColor - continue - } - - if (FloatUtil.floatsEqual(rightDist, 0f)) { - colorStops[x].color = leftColor - continue - } - - val newStops = ArrayList(9) - - // Position the new color stops - if (leftDist > rightDist) { - for (y in 0..6) { - newStops.add(ProcessedColorStop(null, offsetLeft + leftDist * ((7f + y) / 13f))) - } - newStops.add(ProcessedColorStop(null, offset + rightDist * (1f / 3f))) - newStops.add(ProcessedColorStop(null, offset + rightDist * (2f / 3f))) - } else { - newStops.add(ProcessedColorStop(null, offsetLeft + leftDist * (1f / 3f))) - newStops.add(ProcessedColorStop(null, offsetLeft + leftDist * (2f / 3f))) - for (y in 0..6) { - newStops.add(ProcessedColorStop(null, offset + rightDist * (y / 13f))) - } - } - - // Calculate colors for the new stops - val hintRelativeOffset = leftDist / totalDist - val logRatio = ln(0.5) / ln(hintRelativeOffset) - - for (newStop in newStops) { - if (newStop.position == null) { - continue - } - val pointRelativeOffset = (newStop.position - offsetLeft) / totalDist - val weighting = Math.pow(pointRelativeOffset.toDouble(), logRatio).toFloat() - - if (!weighting.isFinite() || weighting.isNaN()) { - continue - } - - // Interpolate color using the calculated weighting - leftColor?.let { left -> - rightColor?.let { right -> newStop.color = ColorUtils.blendARGB(left, right, weighting) } - } - } - - // Replace the color hint with new color stops - colorStops.removeAt(x) - colorStops.addAll(x, newStops) - indexOffset += 8 + return GradientStops.resolve(inputs, epsilon = .00001f.toDouble()).map { stop -> + val leftColor = colorStops[stop.leftColorIndex].color + val rightColor = colorStops[stop.rightColorIndex].color + val weighting = stop.weight.toFloat() + val color = + when { + stop.leftColorIndex == stop.rightColorIndex -> leftColor + leftColor == null || rightColor == null || !weighting.isFinite() -> null + else -> ColorUtils.blendARGB(leftColor, rightColor, weighting) + } + ProcessedColorStop(color, stop.position.toFloat()) } - - return colorStops } private fun resolveColorStopPosition( 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 005b0bf6d4c6..fa89503d22fc 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 @@ -176,4 +176,90 @@ class ColorStopTest { assertThat(processed[4].color).isEqualTo(Color.MAGENTA) assertThat(processed[4].position).isEqualTo(1f) } + + @Test + fun testPointPositionsUseAndroidDisplayDensityBeforeSharedFixup() { + val metrics = DisplayMetrics() + metrics.density = 2f + DisplayMetricsHolder.setScreenDisplayMetrics(metrics) + val colorStops = + listOf( + ColorStop(Color.RED, LengthPercentage(25f, LengthPercentageType.POINT)), + ColorStop(Color.BLUE, LengthPercentage(100f, LengthPercentageType.PERCENT)), + ) + + val processed = ColorStopUtils.getFixedColorStops(colorStops, 200f) + + assertThat(processed[0].position).isEqualTo(.25f) + assertThat(processed[1].position).isEqualTo(1f) + } + + @Test + fun testTransitionHintKeepsAndroidColorAndAlphaRounding() { + val colorStops = + listOf( + ColorStop(Color.argb(128, 255, 0, 0)), + ColorStop(null, LengthPercentage(25f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE), + ) + + val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f) + + assertThat(processed).hasSize(11) + assertThat(processed[3].position).isEqualTo(.25f) + assertThat(processed[3].color).isEqualTo(Color.argb(191, 127, 0, 127)) + assertThat(processed.first().color).isEqualTo(Color.argb(128, 255, 0, 0)) + assertThat(processed.last().color).isEqualTo(Color.BLUE) + } + + @Test + fun testEndpointHintsReuseTheOppositeColorWithoutBlending() { + for ((hintPosition, expectedColor) in listOf(0f to Color.BLUE, 100f to Color.RED)) { + val colorStops = + listOf( + ColorStop(Color.RED), + ColorStop(null, LengthPercentage(hintPosition, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE), + ) + + val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f) + + assertThat(processed).hasSize(3) + assertThat(processed[1].color).isEqualTo(expectedColor) + assertThat(processed[1].position).isEqualTo(hintPosition / 100f) + } + } + + @Test + fun testCenteredHintDoesNotChangeTheColors() { + val colorStops = + listOf( + ColorStop(Color.RED), + ColorStop(null, LengthPercentage(50f, LengthPercentageType.PERCENT)), + ColorStop(Color.BLUE), + ) + + val processed = ColorStopUtils.getFixedColorStops(colorStops, 100f) + + assertThat(processed).hasSize(2) + assertThat(processed[0].color).isEqualTo(Color.RED) + assertThat(processed[1].color).isEqualTo(Color.BLUE) + } + + @Test + fun testZeroLengthGradientKeepsNonFiniteSamplesUncolored() { + val colorStops = + listOf( + ColorStop(Color.RED), + ColorStop(null, LengthPercentage(10f, LengthPercentageType.POINT)), + ColorStop(Color.BLUE, LengthPercentage(20f, LengthPercentageType.POINT)), + ) + + val processed = ColorStopUtils.getFixedColorStops(colorStops, 0f) + + assertThat(processed).hasSize(11) + assertThat(processed.first().color).isEqualTo(Color.RED) + assertThat(processed.last().color).isEqualTo(Color.BLUE) + assertThat(processed.subList(1, 10).all { it.color == null }).isTrue() + } } diff --git a/packages/react-native/ReactShared/README.md b/packages/react-native/ReactShared/README.md index 3bf5e0b0ca78..9fb2393a4dfe 100644 --- a/packages/react-native/ReactShared/README.md +++ b/packages/react-native/ReactShared/README.md @@ -1,43 +1,166 @@ -# Kotlin Multiplatform foundation +# React Native shared Kotlin gradient pilot -This standalone build provides a place to assess sharing library code between -the JVM and iOS with Kotlin Multiplatform. It has no Compose or other UI dependency. -No React Native application, Android artifact, CocoaPods target, or npm package -consumes this module. Normal React Native builds and runtime behavior are unchanged. +This is the gradient use case layered on the standalone KMP foundation. The +foundation's unpublished compiler/interop fixture remains available with +`-PreactNativeSharedSmoke=true`; its classes and reports stay under +`build/smoke` and are excluded from normal shared outputs. Run +`./scripts/test-apple-smoke.sh` to check that fixture through Objective-C. +The gradient implementation is the only production use case in this change. -The build has no production sources yet. Its small arithmetic fixture is enabled -only by `-PreactNativeSharedSmoke=true`, and its outputs go under `build/smoke`. -The fixture checks the compiler, common tests and Objective-C integer interop; -it is not a proposed React Native API. +This module shares CSS gradient stop position and transition-hint calculations +between Android and iOS using Kotlin Multiplatform. It has no Compose dependency. +Native views, gradient geometry, color interpolation, and drawing remain in their +existing platform implementations. -## Validation +`commonMain` accepts positions and color-presence flags and returns positions, +source color indices, and interpolation weights. Platform adapters retain their +existing tolerance, logarithm precision, and color-space behavior. The module does +not depend on ReactAndroid, React-Core, UIKit, JNI, or the C++ renderer. -Use JDK 17. Apple validation also requires macOS, Xcode and an installed iOS -simulator. The separate Gradle wrapper isolates the Kotlin/Native plugin from the -main Android build. CI selects Xcode 26.4.1 and uses an Apple Silicon runner. +## Build and test + +The standalone build uses its own Gradle wrapper and Kotlin plugin so that it +does not change the Kotlin plugin used by the main Android build. It requires +JDK 17. Apple builds also require macOS and Xcode; the pilot CI selects Xcode +26.4.1 without changing the existing React Native CI toolchain. From this directory: ```sh -./gradlew -PreactNativeSharedSmoke=true jvmTest iosSimulatorArm64Test \ - linkDebugFrameworkIosArm64 linkReleaseFrameworkIosArm64 \ +./gradlew jvmTest +./gradlew iosSimulatorArm64Test +./gradlew linkDebugFrameworkIosArm64 linkReleaseFrameworkIosArm64 \ linkDebugFrameworkIosSimulatorArm64 linkReleaseFrameworkIosSimulatorArm64 \ - linkDebugFrameworkIosX64 linkReleaseFrameworkIosX64 \ - --max-workers=2 --console=plain -./scripts/test-apple-smoke.sh + linkDebugFrameworkIosX64 linkReleaseFrameworkIosX64 +./scripts/test-apple-gradient.sh +RCT_KMP_BUILD_TYPE=Release ./scripts/test-apple-gradient.sh +``` + +The same common tests run on JVM and the Apple Silicon iOS simulator. The build +also defines an `iosX64` target for Intel simulators. Device and simulator +frameworks are static and named `ReactNativeShared.framework`. The Apple parity +script requires an installed iOS simulator runtime and compares the actual +Objective-C++ adapter with the existing native gradient implementation. + +With CocoaPods installed, `bundle exec ruby scripts/test-cocoapods-linking.rb` +checks generated host and test configurations for static, dynamic, and mixed +per-target linkage. It installs local fixture pods without compiling native code +and verifies that hosted tests inherit search paths without another static Kotlin +runtime. It prints the directory containing the generated configurations. + +## Android integration + +The repository and npm source-build settings include this directory as a +separate Gradle build. ReactAndroid consumes its JVM output through +`exportAndroidJar`, which writes `build/android/react-native-shared.jar`. +ReactAndroid embeds that JAR in its existing AAR; consumers continue to use the +existing `com.facebook.react:react-android` dependency. + +The JVM target uses Java 17 bytecode and Kotlin 2.2 language/API compatibility. +Android-specific build variants, resources, CMake, JNI, and publication remain +owned by ReactAndroid. + +`python3 scripts/test-android-consumers.py` publishes to a temporary local Maven +repository, packs the npm sources, and builds a fresh Android application for +each dependency route. It checks dependency resolution, shared-class uniqueness, +Debug packaging and Release shrinking. See `--help` for emulator execution and +fixture preparation options. These small consumers exercise the real Android +gradient adapter; they do not replace RNTester coverage. + +## Apple opt-in + +The Apple adapter is a source-build pilot. Set the flag when installing Pods in +the application directory that contains its Podfile: + +```sh +RCT_USE_KMP=1 bundle exec pod install +``` + +This enables the `React-KMP` support pod and selects React Native core source +builds. During the Xcode build, the support pod builds the shared static framework +for the current SDK, architecture, and configuration. The application still uses +its existing Objective-C++ and UIKit rendering code. + +For a custom Xcode configuration name that does not contain `Debug` or `Release`, +set the `RCT_KMP_BUILD_TYPE` build setting to `Debug` or `Release`. + +Mac Catalyst retains the existing gradient implementation because Kotlin/Native +does not provide a Catalyst target. SwiftPM does not support this pilot: setting +`RCT_USE_KMP=1` for that integration fails with a message directing developers to +the CocoaPods source-build route. Published React Native Apple prebuilts do not +contain the pilot framework. + +`./scripts/test-apple-distribution.sh` tests a separate binary-distribution +fixture: a device ARM64 plus universal simulator XCFramework, the matching Kotlin +license bundle, and a SwiftPM consumer. It builds iOS and Catalyst and executes +the consumer's test on the host simulator. `--build-only` omits execution; +`--package-only` packages previously built frameworks and records that reuse in +`distribution.json`. This probe does not enable React Native core's SwiftPM or +prebuilt integration. Those routes still need complete React core build and app +validation before their guard can be removed. + +To return to the default Apple implementation, remove the flag and run Pod +installation again. No change to the JavaScript gradient API is needed. + +## Application, runtime and cost checks + +From a repository checkout with RNTester dependencies installed: + +```sh +./scripts/test-apple-app.sh simulator +USE_FRAMEWORKS=static ./scripts/test-apple-app.sh simulator +USE_FRAMEWORKS=dynamic ./scripts/test-apple-app.sh simulator +./scripts/test-apple-app.sh device +./scripts/test-apple-app.sh catalyst +python3 scripts/test-kotlin-coexistence.py +python3 scripts/benchmark-apple-gradient.py +python3 scripts/benchmark-kmp-build.py ``` -The native runner checks Debug and Release frameworks through Objective-C on the -host simulator. Set `RCT_KMP_SIMULATOR_UDID` to select an existing simulator. -Intel simulator frameworks are built; Intel execution and physical-device tests -require their respective hosts and are not part of this CI job. +The app script copies RNTester into an isolated sibling directory, enables KMP, +and verifies the actual compiled gradient adapter. Simulator runs execute the +RNTester test plan and launch the app; device and Catalyst runs are unsigned +build checks. Catalyst is enabled in the copied Podfile and application project. +Each run requires fresh build outputs. It preserves RNTester's existing hosted-test topology and removes +the temporary app and any test-owned servers and simulator when it finishes. + +The coexistence fixture checks independently compiled Kotlin frameworks using +the same compiler as ReactShared, including all four combinations of static and dynamic runtime ownership, +object lifetimes, and concurrent calls. Values cross that boundary as native +primitives. It does not establish compatibility with arbitrary Kotlin compiler +versions or allow one framework's exported Kotlin objects to be cast to another's. + +The Apple benchmark compares the actual native and KMP adapters in separate +optimized executables. It records first-call and repeated-call timings, process +memory snapshots and binary size. Memory snapshots are not allocation counts, +and simulator measurements are not application startup or physical-device +performance. The build benchmark measures clean outputs, unchanged builds and a +common-source edit in a copied module with warm dependency/compiler caches and +Gradle's build-output cache disabled. Reports have no timing pass/fail thresholds. +Measure full application size, startup, allocations and build costs on representative +devices before expanding adoption. + +This is an experimental code-sharing pilot. Local optimized simulator probes +show additional latency, executable size and process memory cost from the shared +adapter and Kotlin runtime. Passing correctness and integration tests does not +establish an acceptable performance budget for broader adoption. + +## Scope and validation -## Adoption is separate +Common tests cover stop positioning and transition hints. They do not replace +platform rendering tests: color precision, gradient geometry, and framework +linking still need validation in the Android and Apple adapters. Adoption beyond +this pilot should also measure application size and startup cost and preserve +the existing Catalyst path. -Each use case needs its own behavior, dependency, packaging and performance -review before any application consumes shared code. Earlier gradient experiments -measured additional adapter latency, allocation and Kotlin/Native runtime cost; -this foundation does not establish that those costs are acceptable. Android AAR -embedding, Apple runtime ownership, CocoaPods, SwiftPM and prebuilt distribution -belong to those follow-ups. No Apple consumer or framework is designated as the -runtime owner here. +The dedicated `test-kmp.yml` workflow runs common tests, builds Debug and Release +frameworks for all three Apple targets, and checks Apple adapter parity in both +configurations with static and dynamic linkage. It also checks CocoaPods linking +for application and test targets with static, dynamic, and mixed framework settings. +Additional jobs cover KMP-enabled RNTester builds/tests and Android package consumers; +the shared job runs the distribution, coexistence and cost probes. Intel iOS +simulator frameworks are built, but executing Intel or physical-device tests +requires suitable hardware and runtimes outside these hosted jobs. +Shared Kotlin changes also trigger the existing Android and iOS test paths. This source directory and its +wrapper are included in the npm package; generated build outputs and local Gradle +caches are not. diff --git a/packages/react-native/ReactShared/React-KMP.podspec b/packages/react-native/ReactShared/React-KMP.podspec new file mode 100644 index 000000000000..a09a4fe6e026 --- /dev/null +++ b/packages/react-native/ReactShared/React-KMP.podspec @@ -0,0 +1,36 @@ +# 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 = 'React-KMP' + s.version = package['version'] + s.summary = 'Opt-in shared Kotlin gradient algorithms for React Native.' + s.homepage = 'https://reactnative.dev/' + s.license = package['license'] + s.author = 'Meta Platforms, Inc. and its affiliates' + s.source = { :git => 'https://github.com/facebook/react-native.git', :tag => "v#{package['version']}" } + s.platforms = min_supported_versions + # The small support target establishes build ordering without trying to link an + # iOS-only vendored XCFramework when the application is built for Catalyst. + s.source_files = 'cocoapods/ReactNativeShared.m' + s.preserve_paths = 'src/**/*', 'gradle/**/*', 'gradlew', '*.kts', '*.properties', 'scripts/**/*' + s.pod_target_xcconfig = { 'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO' } + s.user_target_xcconfig = { + 'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', + 'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', + } + # Link flags are added to direct consumers in react_native_post_install. + # user_target_xcconfig also reaches tests that inherit only search paths. + s.script_phase = { + :name => 'Build shared Kotlin gradient framework', + :execution_position => :before_compile, + :always_out_of_date => '1', + :script => '"${PODS_TARGET_SRCROOT}/scripts/build-apple-framework.sh"', + } +end diff --git a/packages/react-native/ReactShared/build.gradle.kts b/packages/react-native/ReactShared/build.gradle.kts index 34ae7aff89ac..a383ea7ec72a 100644 --- a/packages/react-native/ReactShared/build.gradle.kts +++ b/packages/react-native/ReactShared/build.gradle.kts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import java.util.Properties import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget @@ -13,7 +14,11 @@ plugins { kotlin("multiplatform") version "2.4.20" } group = "com.facebook.react" -version = "0.0.0-local" +val reactAndroidProperties = Properties() + +file("../ReactAndroid/gradle.properties").inputStream().use(reactAndroidProperties::load) + +version = reactAndroidProperties.getProperty("VERSION_NAME") val smokeEnabled = providers.gradleProperty("reactNativeSharedSmoke").map { it.toBooleanStrict() }.getOrElse(false) @@ -27,7 +32,7 @@ kotlin { explicitApi() jvmToolchain(17) compilerOptions { - // Preserve compatibility with the repository's existing Kotlin consumers. + // ReactAndroid consumers still compile with the older Kotlin language level. languageVersion.set(KotlinVersion.KOTLIN_2_2) apiVersion.set(KotlinVersion.KOTLIN_2_2) } @@ -45,7 +50,10 @@ kotlin { } sourceSets { - commonMain.dependencies { implementation(kotlin("stdlib", "2.2.0")) } + commonMain.dependencies { + // Keep the JVM runtime dependency compatible with the existing Android library. + implementation(kotlin("stdlib", "2.2.0")) + } commonTest.dependencies { implementation(kotlin("test")) } if (smokeEnabled) { commonMain { kotlin.srcDir("tests/smoke/kotlin") } @@ -53,3 +61,13 @@ kotlin { } } } + +// ReactAndroid embeds this JAR in its AAR, preserving the existing Maven coordinates. +// A stable output path also works when this build is included by a source-build consumer. +val jvmJar = tasks.named("jvmJar") + +tasks.register("exportAndroidJar") { + from(jvmJar.flatMap { it.archiveFile }) + into(layout.buildDirectory.dir("android")) + rename { "react-native-shared.jar" } +} diff --git a/packages/react-native/ReactShared/cocoapods/ReactNativeShared.m b/packages/react-native/ReactShared/cocoapods/ReactNativeShared.m new file mode 100644 index 000000000000..5dc776e831f0 --- /dev/null +++ b/packages/react-native/ReactShared/cocoapods/ReactNativeShared.m @@ -0,0 +1,9 @@ +/* + * 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. + */ + +// The support pod orders the Kotlin framework build before React-RCTFabric. +// All shared implementations are supplied by ReactNativeShared.framework. diff --git a/packages/react-native/ReactShared/scripts/benchmark-apple-gradient.py b/packages/react-native/ReactShared/scripts/benchmark-apple-gradient.py new file mode 100644 index 000000000000..8566c4557c47 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/benchmark-apple-gradient.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# 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. + +"""Measure the real native and KMP adapters in separate Release executables. + +Requires the same toolchain and simulator runtime as test-apple-gradient.sh. +Results are simulator microbenchmarks, not device or application benchmarks. +""" + +import argparse +import hashlib +import json +import os +import pathlib +import platform +import statistics +import subprocess +import time + + +def run(command, **kwargs): + started = time.perf_counter() + result = subprocess.run(command, capture_output=True, text=True, **kwargs) + if result.returncode: + raise RuntimeError( + f"Command failed ({result.returncode}): {command}\n" + + (result.stdout + result.stderr)[-12000:] + ) + return result, time.perf_counter() - started + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-only", action="store_true", help="Compile without accessing the simulator.") + parser.add_argument("--iterations", type=int, default=2000) + parser.add_argument("--samples", type=int, default=7) + parser.add_argument("--repeats", type=int, default=3, help="Fresh processes per case and variant.") + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--prepared-release-dir", type=pathlib.Path, + help="Reuse artifacts previously built by test-apple-gradient.sh with RCT_KMP_BUILD_TYPE=Release.") + args = parser.parse_args() + if not 1 <= args.iterations <= 1000000 or not 1 <= args.samples <= 100 or not 1 <= args.repeats <= 100: + parser.error("iterations must be 1..1000000; samples and repeats must be 1..100") + shared = pathlib.Path(__file__).resolve().parent.parent + react = shared.parent + output = (args.output or shared / "build/apple-gradient-benchmark").resolve() + output.mkdir(parents=True, exist_ok=True) + prepared = args.prepared_release_dir.resolve() if args.prepared_release_dir else output / "prepared" + preparation_seconds = None + if not args.prepared_release_dir: + env = dict(os.environ, RCT_KMP_BUILD_TYPE="Release", RCT_KMP_BUILD_ONLY="1", RCT_KMP_TEST_OUTPUT_DIR=str(prepared)) + preparation, preparation_seconds = run([str(shared / "scripts/test-apple-gradient.sh")], env=env) + (output / "prepare.log").write_text(preparation.stdout + preparation.stderr) + sdk = run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])[0].stdout.strip() + architecture = platform.machine() + graphics = react / "ReactCommon/react/renderer/graphics" + flags = [ + "-std=c++20", "-O2", "-fobjc-arc", "-DREACT_NATIVE_PRODUCTION", "-DRCTLOG_ENABLED=0", + "-include", "memory", "-include", "vector", "-target", f"{architecture}-apple-ios15.1-simulator", + "-isysroot", sdk, "-I", str(prepared / "include"), "-I", str(react / "ReactCommon"), + "-I", str(graphics / "platform/ios"), "-F", str(prepared / "ReactNativeSharedKMP"), + ] + native_objects = [str(prepared / name) for name in [ + "RCTAnimationUtils.mm.o", "Color.cpp.o", "ColorComponents.cpp.o", "HostPlatformColor.mm.o", + "RCTPlatformColorUtils.mm.o", "ManagedObjectWrapper.mm.o", + ]] + frameworks = ["-framework", "Foundation", "-framework", "UIKit", "-framework", "QuartzCore", "-framework", "CoreGraphics"] + builds = {} + executables = {} + for variant in ("native", "kmp"): + adapter = output / f"{variant}-adapter.o" + defines = ["-DRCT_USE_KMP=1"] if variant == "kmp" else ["-DRCT_USE_KMP=0", "-DRCTGradientUtils=RCTGradientUtilsBaseline"] + _, compile_seconds = run(["xcrun", "clang++", *flags, *defines, "-c", str(react / "React/Fabric/Utils/RCTGradientUtils.mm"), "-o", str(adapter)]) + executable = output / f"AppleGradientBenchmark-{variant}" + link = ["xcrun", "clang++", *flags, f"-DRCT_BENCHMARK_KMP={int(variant == 'kmp')}", + str(shared / "tests/AppleGradientBenchmark.mm"), str(adapter), *native_objects, + *frameworks, "-Wl,-dead_strip", "-o", str(executable)] + if variant == "kmp": + link.extend(["-framework", "ReactNativeShared"]) + _, link_seconds = run(link) + executables[variant] = executable + builds[variant] = {"adapterCompileSeconds": compile_seconds, "harnessCompileAndLinkSeconds": link_seconds, + "executableBytes": executable.stat().st_size} + size = run(["xcrun", "size", "-m", str(executable)])[0].stdout + (output / f"{variant}-segments.txt").write_text(size) + framework = prepared / "ReactNativeSharedKMP/ReactNativeShared.framework/ReactNativeShared" + report = { + "configuration": "Release; clang -O2; dead stripping; separate native-only and KMP executables", + "platform": "iOS simulator", "hostArchitecture": architecture, "sdk": sdk, + "xcode": run(["xcodebuild", "-version"])[0].stdout.strip(), + "preparationSeconds": preparation_seconds, "builds": builds, + "reusedReleaseArtifacts": bool(args.prepared_release_dir), + "artifactSha256": {str(path): hashlib.sha256(path.read_bytes()).hexdigest() + for path in [framework, *(pathlib.Path(item) for item in native_objects)]}, + "staticFrameworkBytes": framework.stat().st_size, + "incrementalExecutableBytes": builds["kmp"]["executableBytes"] - builds["native"]["executableBytes"], + "runtimeExecuted": False, "measurements": [], "firstCallProcesses": [], + "limitations": [ + "Simulator results are not physical-device performance or RN application startup.", + "First-call latency starts after inputs exist; process elapsed time includes simctl/OS launch overhead.", + "Memory values are process footprint snapshots, not total allocated bytes; Kotlin GC is not forced.", + "Binary size is this isolated executable, not an installed, signed, or compressed application delta.", + "Compilation timings are one warm-cache observation; preparation includes framework and parity harness builds.", + ], + } + if not args.build_only: + simulator = os.environ.get("RCT_KMP_SIMULATOR_UDID") + if not simulator: + devices = json.loads(run(["xcrun", "simctl", "list", "devices", "available", "-j"])[0].stdout)["devices"] + simulator = next((items[0]["udid"] for runtime, items in devices.items() if ".iOS-" in runtime and items), None) + if not simulator: + raise RuntimeError("Install a compatible iOS simulator runtime or set RCT_KMP_SIMULATOR_UDID.") + for repetition in range(args.repeats): + for variant in (("native", "kmp") if repetition % 2 == 0 else ("kmp", "native")): + result, elapsed = run(["xcrun", "simctl", "spawn", "--standalone", simulator, + str(executables[variant]), "two_stops", "1", "1", "--first-call-only"]) + measurement = json.loads(result.stdout) + measurement.update(repetition=repetition, processElapsedSeconds=elapsed) + report["firstCallProcesses"].append(measurement) + (output / f"first-call-{variant}-{repetition}.stderr.log").write_text(result.stderr) + cases = ("two_stops", "implicit_16", "explicit_64", "asymmetric_hint", "multiple_hints") + for repetition in range(args.repeats): + for case in cases: + # Alternate order across independent processes to reduce ordering bias. + for variant in (("native", "kmp") if repetition % 2 == 0 else ("kmp", "native")): + result, elapsed = run(["xcrun", "simctl", "spawn", "--standalone", simulator, + str(executables[variant]), case, str(args.iterations), str(args.samples)]) + measurement = json.loads(result.stdout) + measurement.update(repetition=repetition, processElapsedSeconds=elapsed) + measurement["medianNanosecondsPerCall"] = statistics.median(measurement["nanosecondsPerCall"]) + report["measurements"].append(measurement) + (output / f"{case}-{variant}-{repetition}.stderr.log").write_text(result.stderr) + pair = report["measurements"][-2:] + if pair[0]["checksum"] != pair[1]["checksum"]: + raise RuntimeError(f"Observed output checksum differs for {case}; inspect parity before comparing timings.") + report["runtimeExecuted"] = True + (output / "results.json").write_text(json.dumps(report, indent=2) + "\n") + print(f"{'Compiled' if args.build_only else 'Measured'} native and KMP gradient executables: {output / 'results.json'}") + + +if __name__ == "__main__": + main() diff --git a/packages/react-native/ReactShared/scripts/benchmark-kmp-build.py b/packages/react-native/ReactShared/scripts/benchmark-kmp-build.py new file mode 100644 index 000000000000..f748e0bfded3 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/benchmark-kmp-build.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# 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. + +"""Measure clean, unchanged, and edited shared-module builds in an isolated copy. + +Dependency/compiler caches stay warm. Gradle's build-output cache is disabled so +the clean build measures compilation instead of restoring another build's outputs. +This measures the added shared module, not the entire Android or iOS application. +""" + +import argparse +import json +import pathlib +import platform +import shutil +import subprocess +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan-only", action="store_true", help="Print the measurement plan without building.") + parser.add_argument("--output", type=pathlib.Path) + args = parser.parse_args() + shared = pathlib.Path(__file__).resolve().parent.parent + output = (args.output or shared / "build/kmp-build-benchmark").resolve() + output.mkdir(parents=True, exist_ok=True) + target = "IosSimulatorArm64" if platform.machine() == "arm64" else "IosX64" + tasks = ["jvmTest", f"linkReleaseFramework{target}"] + phases = [ + {"name": "clean_outputs", "tasks": ["clean", *tasks]}, + {"name": "unchanged_incremental", "tasks": tasks}, + {"name": "edited_common_source", "tasks": tasks}, + ] + report = { + "dependencyAndCompilerCaches": "warm; existing user caches", + "gradleBuildOutputCache": "disabled", "measurementsExecuted": False, + "scope": "isolated shared module; not total application build time", + "sourceEdit": "append a comment to the copied common source; original source remains unchanged", + "phases": phases, + } + if not args.plan_only: + if platform.system() != "Darwin": + parser.error("The paired JVM/iOS measurement requires macOS and Xcode.") + fixture = output / "fixture" + if fixture.exists(): + parser.error(f"Choose a fresh --output directory; fixture already exists: {fixture}") + copied_shared = fixture / "ReactShared" + copied_shared.mkdir(parents=True) + for name in ("build.gradle.kts", "settings.gradle.kts", "gradle.properties", "gradlew", "gradlew.bat", "gradle", "src"): + source = shared / name + if source.is_dir(): + shutil.copytree(source, copied_shared / name) + else: + shutil.copy2(source, copied_shared / name) + (fixture / "ReactAndroid").mkdir() + shutil.copy2(shared.parent / "ReactAndroid/gradle.properties", fixture / "ReactAndroid/gradle.properties") + for phase in phases: + if phase["name"] == "edited_common_source": + source = copied_shared / "src/commonMain/kotlin/com/facebook/react/shared/GradientStops.kt" + with source.open("a") as stream: + stream.write("\n// Non-semantic edit in the isolated build-cost fixture.\n") + command = [str(copied_shared / "gradlew"), *phase["tasks"], "--no-build-cache", "--max-workers=2", "--console=plain"] + started = time.perf_counter() + completed = subprocess.run(command, cwd=copied_shared, capture_output=True, text=True) + phase.update(elapsedSeconds=time.perf_counter() - started, exitCode=completed.returncode) + (output / f"{phase['name']}.log").write_text(completed.stdout + completed.stderr) + if completed.returncode != 0: + report["failurePhase"] = phase["name"] + (output / "results.json").write_text(json.dumps(report, indent=2) + "\n") + raise SystemExit(f"Build measurement failed in {phase['name']}; inspect {output}.") + report["measurementsExecuted"] = True + (output / "results.json").write_text(json.dumps(report, indent=2) + "\n") + print(f"{'Planned' if args.plan_only else 'Measured'} shared-module build costs: {output / 'results.json'}") + + +if __name__ == "__main__": + main() diff --git a/packages/react-native/ReactShared/scripts/build-apple-framework.sh b/packages/react-native/ReactShared/scripts/build-apple-framework.sh new file mode 100755 index 000000000000..16a5becb27c0 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/build-apple-framework.sh @@ -0,0 +1,52 @@ +#!/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 -euo pipefail + +if [[ "${EFFECTIVE_PLATFORM_NAME:-}" == "-maccatalyst" || "${IS_MACCATALYST:-NO}" == "YES" ]]; then + echo 'React Native KMP: Catalyst uses the existing native gradient implementation.' + exit 0 +fi + +if [[ "${ACTION:-}" == "clean" ]]; then + exit 0 +fi + +shared_root="$(cd "$(dirname "$0")/.." && pwd)" +build_type="${RCT_KMP_BUILD_TYPE:-${CONFIGURATION:-Debug}}" +case "$build_type" in + Debug|*Debug*) build_type=Debug; framework_variant=debugFramework ;; + Release|*Release*) build_type=Release; framework_variant=releaseFramework ;; + *) echo 'error: Set RCT_KMP_BUILD_TYPE=Debug or Release for a custom Xcode configuration.' >&2; exit 1 ;; +esac + +read -r -a architectures <<< "${ARCHS:-arm64}" +gradle_tasks=() +frameworks=() +for architecture in "${architectures[@]}"; do + case "${PLATFORM_NAME:-}:$architecture" in + iphoneos:arm64) kotlin_target=iosArm64; task_target=IosArm64 ;; + iphonesimulator:arm64) kotlin_target=iosSimulatorArm64; task_target=IosSimulatorArm64 ;; + iphonesimulator:x86_64) kotlin_target=iosX64; task_target=IosX64 ;; + *) echo "error: React Native KMP has no framework for ${PLATFORM_NAME:-unknown}:$architecture." >&2; exit 1 ;; + esac + gradle_tasks+=("link${build_type}Framework${task_target}") + frameworks+=("$shared_root/build/bin/$kotlin_target/$framework_variant/ReactNativeShared.framework") +done + +"$shared_root/gradlew" -p "$shared_root" --console=plain "${gradle_tasks[@]}" + +destination="${PODS_CONFIGURATION_BUILD_DIR:?Run this script from the React-KMP CocoaPods build phase}/ReactNativeSharedKMP" +mkdir -p "$destination" +rm -rf "$destination/ReactNativeShared.framework" +ditto "${frameworks[0]}" "$destination/ReactNativeShared.framework" +if [[ ${#frameworks[@]} -gt 1 ]]; then + binaries=() + for framework in "${frameworks[@]}"; do + binaries+=("$framework/ReactNativeShared") + done + xcrun lipo -create "${binaries[@]}" -output "$destination/ReactNativeShared.framework/ReactNativeShared" +fi diff --git a/packages/react-native/ReactShared/scripts/test-android-consumers.py b/packages/react-native/ReactShared/scripts/test-android-consumers.py new file mode 100644 index 000000000000..ff1512df9ac4 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-android-consumers.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +# 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. + +"""Build fresh Android consumers of the Maven AAR and packed npm source. + +Requires Python 3.12+, Node/Yarn/npm, JDK 17 and the Android SDK/NDK. Run after +the repository's Yarn install. A supplied device runs both release APKs; without +one the report explicitly records compilation/packaging validation only. +The packed-source build also builds Hermes from source, as the standard React +Native composite does; the repository-only stable-Hermes flag does not apply. +""" + +import argparse +from collections import Counter +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import tempfile +import textwrap +import time +import tomllib +import uuid +import zipfile + + +SHARED = Path(__file__).resolve().parents[1] +RN = SHARED.parent +REPOSITORY = RN.parent.parent + + +def write(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") + + +def groovy_string(value): + return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'" + + +def run(command, cwd, log, env=None): + print("Running: " + " ".join(map(str, command)), flush=True) + with log.open("w", encoding="utf-8") as output: + subprocess.run(command, cwd=cwd, env=env, stdout=output, + stderr=subprocess.STDOUT, check=True) + + +def properties(path): + return dict(line.strip().split("=", 1) for line in path.read_text().splitlines() + if "=" in line and not line.lstrip().startswith("#")) + + +def pack(package, destination, environment): + result = subprocess.run( + ["npm", "pack", "--ignore-scripts", "--json", "--pack-destination", str(destination)], + cwd=package, env=environment, check=True, capture_output=True, text=True, + ) + metadata = json.loads(result.stdout) + if isinstance(metadata, dict): + metadata = [metadata] if "filename" in metadata else list(metadata.values()) + if len(metadata) != 1: + raise AssertionError(f"Expected one npm package: {metadata}") + return destination / metadata[0]["filename"] + + +def unpack(archive, destination): + destination.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive) as tar: + members = tar.getmembers() + if any(not member.name.startswith("package/") for member in members): + raise AssertionError(f"Unexpected npm archive layout: {archive}") + for member in members: + member.name = member.name.removeprefix("package/") + tar.extractall(destination, members=members, filter="data") + + +def inspect_aar(aar): + classes = Counter() + with zipfile.ZipFile(aar) as archive: + for entry in archive.namelist(): + if entry == "classes.jar" or (entry.startswith("libs/") and entry.endswith(".jar")): + with zipfile.ZipFile(io.BytesIO(archive.read(entry))) as jar: + classes.update(name for name in jar.namelist() + if name.startswith("com/facebook/react/shared/") + and name.endswith(".class")) + required = {f"com/facebook/react/shared/{name}.class" + for name in ["GradientStops", "GradientStopInput", "ResolvedGradientStop"]} + if not required.issubset(classes) or any(count != 1 for count in classes.values()): + raise AssertionError(f"Missing or duplicated shared classes in {aar}: {classes}") + with tempfile.TemporaryDirectory(prefix="react-native-kmp-bytecode-") as directory: + jar = Path(directory) / "classes.jar" + with zipfile.ZipFile(aar) as archive: + jar.write_bytes(archive.read("classes.jar")) + bytecode = subprocess.check_output( + ["javap", "-c", "-p", "-classpath", str(jar), + "com.facebook.react.uimanager.style.ColorStopUtils"], text=True) + if not re.search(r"invoke(?:static|virtual)\s+.*// Method com/facebook/react/shared/GradientStops\.resolve(?:\$default)?:", bytecode): + raise AssertionError(f"The Android adapter does not invoke shared GradientStops in {aar}") + return {"path": str(aar), "sha256": hashlib.sha256(aar.read_bytes()).hexdigest(), + "shared_class_counts": dict(classes), "adapter_invokes_shared_resolver": True} + + +ACTIVITY = """ +package com.facebook.react.kmp.consumer; + +import android.app.Activity; +import android.graphics.Color; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.util.Log; +import android.widget.TextView; +import com.facebook.react.uimanager.DisplayMetricsHolder; +import com.facebook.react.uimanager.LengthPercentage; +import com.facebook.react.uimanager.LengthPercentageType; +import com.facebook.react.uimanager.style.ColorStop; +import com.facebook.react.uimanager.style.ColorStopUtils; +import com.facebook.react.uimanager.style.ProcessedColorStop; +import java.util.Arrays; +import java.util.List; + +// Java deliberately exercises the packaged Android adapter through its JVM API. +// No shared source files or replacement implementation are compiled into this app. +public final class MainActivity extends Activity { + @Override public void onCreate(Bundle state) { + super.onCreate(state); + DisplayMetrics metrics = new DisplayMetrics(); + metrics.setTo(getResources().getDisplayMetrics()); + metrics.density = 2f; + DisplayMetricsHolder.setScreenDisplayMetrics(metrics); + List density = ColorStopUtils.INSTANCE.getFixedColorStops( + Arrays.asList(new ColorStop(Color.RED, + new LengthPercentage(25f, LengthPercentageType.POINT)), + new ColorStop(Color.BLUE, null)), 200f); + if (density.get(0).getPosition() != .25f) throw new AssertionError("Android density"); + List hint = ColorStopUtils.INSTANCE.getFixedColorStops( + Arrays.asList(new ColorStop(Color.argb(128, 255, 0, 0), null), + new ColorStop(null, new LengthPercentage(25f, LengthPercentageType.PERCENT)), + new ColorStop(Color.BLUE, null)), 100f); + if (hint.size() != 11 || hint.get(3).getPosition() != .25f + || hint.get(3).getColor() != Color.argb(191, 127, 0, 127)) { + throw new AssertionError("Shared hint expansion or Android alpha rounding"); + } + String result = "KMP consumer PASS " + BuildConfig.CONSUMER_MODE + + " " + getIntent().getStringExtra("validationToken"); + TextView text = new TextView(this); + text.setText(result); + setContentView(text); + Log.i("KmpConsumer", result); + } +} +""" + + +def fixture(path, mode, versions, version, hermes_version, maven, abi): + source_builds = """ + includeBuild('node_modules/@react-native/gradle-plugin') + includeBuild('node_modules/react-native') { + name = 'react-native-build-from-source' + dependencySubstitution { + substitute module('com.facebook.react:react-android') using project(':packages:react-native:ReactAndroid') + substitute module('com.facebook.hermes:hermes-android') using project(':packages:react-native:ReactAndroid:hermes-engine') + } + } + """ if mode == "source" else "" + write(path / "settings.gradle", f""" + pluginManagement {{ repositories {{ google(); mavenCentral(); gradlePluginPortal() }} }} + dependencyResolutionManagement {{ + repositories {{ + exclusiveContent {{ + forRepository {{ maven {{ url = uri({groovy_string(maven)}) }} }} + filter {{ includeModule('com.facebook.react', 'react-android') }} + }} + google() + mavenCentral() + }} + }} + rootProject.name = 'kmp-{mode}-consumer' + include ':app' + {source_builds} + """) + write(path / "gradle.properties", """ + android.useAndroidX=true + org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g + org.gradle.caching=true + """) + if mode == "source": + write(path / "build.gradle", """ + tasks.register('prepareSourceAars') { + dependsOn gradle.includedBuild('react-native-build-from-source').task(':packages:react-native:ReactAndroid:bundleDebugAar') + dependsOn gradle.includedBuild('react-native-build-from-source').task(':packages:react-native:ReactAndroid:bundleReleaseAar') + } + """) + write(path / "app/build.gradle", f""" + import groovy.json.JsonOutput + import org.gradle.api.artifacts.component.ModuleComponentIdentifier + import org.gradle.api.artifacts.component.ProjectComponentIdentifier + + plugins {{ id 'com.android.application' version '{versions['agp']}' }} + android {{ + namespace 'com.facebook.react.kmp.consumer' + compileSdk {versions['compileSdk']} + ndkVersion '{versions['ndkVersion']}' + defaultConfig {{ + applicationId 'com.facebook.react.kmp.consumer.{mode}' + minSdk {versions['minSdk']} + targetSdk {versions['targetSdk']} + versionCode 1 + versionName '1.0' + ndk {{ abiFilters '{abi}' }} + buildConfigField 'String', 'CONSUMER_MODE', '"{mode}"' + }} + buildFeatures {{ buildConfig true }} + // Match React Native's Gradle plugin rules for shared Prefab libraries. + packaging {{ + jniLibs.pickFirsts += ['**/libfbjni.so', '**/libreactnative.so', '**/libjsi.so', + '**/libc++_shared.so', '**/libhermesvm.so', '**/libhermestooling.so'] + }} + compileOptions {{ + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + }} + buildTypes {{ + release {{ + minifyEnabled true + shrinkResources true + signingConfig signingConfigs.debug + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + }} + }} + }} + dependencies {{ + implementation 'com.facebook.react:react-android:{version}' + implementation 'com.facebook.hermes:hermes-android:{hermes_version}' + }} + tasks.register('verifyResolution') {{ + dependsOn 'assembleDebug', 'assembleRelease' + {"dependsOn ':prepareSourceAars'" if mode == "source" else ""} + doLast {{ + def result = ['debug', 'release'].collectEntries {{ variant -> + def configuration = configurations.getByName(variant + 'RuntimeClasspath') + def components = configuration.incoming.resolutionResult.allComponents.findAll {{ component -> + def id = component.id + (id instanceof ModuleComponentIdentifier && id.group == 'com.facebook.react' && id.module == 'react-android') || + (id instanceof ProjectComponentIdentifier && id.projectPath.endsWith(':ReactAndroid')) + }} + assert components.size() == 1 : components + def id = components.first().id + assert {str(mode == 'source').lower()} == (id instanceof ProjectComponentIdentifier) : id + if (id instanceof ModuleComponentIdentifier) assert id.version == '{version}' : id + def artifacts = configuration.incoming.artifactView {{ + componentFilter {{ component -> component == id }} + {"attributes.attribute(org.gradle.api.artifacts.type.ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, 'android-classes-jar')" if mode == "source" else ""} + }}.artifacts.artifacts + assert !artifacts.empty + def engines = configuration.incoming.resolutionResult.allComponents.findAll {{ component -> + def engine = component.id + (engine instanceof ModuleComponentIdentifier && engine.group == 'com.facebook.hermes' && engine.module == 'hermes-android') || + (engine instanceof ProjectComponentIdentifier && engine.projectPath.endsWith(':hermes-engine')) + }} + assert engines.size() == 1 : engines + def engine = engines.first().id + assert {str(mode == 'source').lower()} == (engine instanceof ProjectComponentIdentifier) : engine + if (engine instanceof ModuleComponentIdentifier) assert engine.version == '{hermes_version}' : engine + [(variant): [component: id.displayName, artifacts: artifacts.collect {{ it.file.absolutePath }}, hermes_component: engine.displayName]] + }} + file(layout.buildDirectory.file('consumer-resolution.json')).text = JsonOutput.prettyPrint(JsonOutput.toJson(result)) + }} + }} + """) + write(path / "app/src/main/AndroidManifest.xml", """ + + + + + + + + + + + """) + write(path / "app/src/main/java/com/facebook/react/kmp/consumer/MainActivity.java", ACTIVITY) + + +def device_check(adb, serial, fixture_dir, mode, log): + package = f"com.facebook.react.kmp.consumer.{mode}" + apk = fixture_dir / "app/build/outputs/apk/release/app-release.apk" + prefix = [adb, "-s", serial] + token = uuid.uuid4().hex + with log.open("w", encoding="utf-8") as output: + subprocess.run(prefix + ["install", "-r", str(apk)], stdout=output, + stderr=subprocess.STDOUT, check=True) + try: + subprocess.run(prefix + ["shell", "am", "start", "-W", "-n", + package + "/com.facebook.react.kmp.consumer.MainActivity", + "--es", "validationToken", token], + stdout=output, stderr=subprocess.STDOUT, check=True) + for _ in range(20): + text = subprocess.check_output(prefix + ["logcat", "-d", "-s", "KmpConsumer:I", "*:S"], text=True) + if f"KMP consumer PASS {mode} {token}" in text: + output.write(text) + return {"serial": serial, "release_adapter_assertions": "passed"} + time.sleep(0.5) + raise AssertionError(f"Release app did not report successful adapter checks: {mode}") + finally: + subprocess.run(prefix + ["shell", "am", "force-stop", package], check=False) + subprocess.run(prefix + ["uninstall", package], stdout=output, + stderr=subprocess.STDOUT, check=False) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--maven-repository", type=Path, help="Use an existing real ReactAndroid Maven publication instead of publishing locally") + parser.add_argument("--device", help="adb serial for executing both minified release APKs") + parser.add_argument("--abi", default="arm64-v8a") + parser.add_argument("--max-workers", type=int, default=4, help="Maximum concurrent Gradle workers") + parser.add_argument("--prepare-only", action="store_true", help="Pack and prepare fixtures; do not claim build or runtime validation") + args = parser.parse_args() + if args.max_workers < 1: + parser.error("--max-workers must be positive") + output = args.output_dir.resolve() if args.output_dir else Path(tempfile.mkdtemp(prefix="react-native-kmp-android-")) + output.mkdir(parents=True, exist_ok=True) + if any((output / name).exists() for name in ["binary", "source"]): + parser.error("Use a fresh output directory; existing consumer fixtures are never overwritten") + environment = dict(os.environ, npm_config_cache=str(output / "npm-cache"), + npm_config_fetch_timeout="30000", npm_config_fetch_retries="2") + packages = output / "packages" + packages.mkdir() + run(["yarn", "--cwd", str(REPOSITORY / "packages/react-native-codegen"), "build"], REPOSITORY, output / "codegen.log", environment) + archives = {name: pack(path, packages, environment) for name, path in { + "react-native": RN, "@react-native/codegen": REPOSITORY / "packages/react-native-codegen", + "@react-native/gradle-plugin": REPOSITORY / "packages/gradle-plugin", + }.items()} + source = output / "source" + for name, archive in archives.items(): + unpack(archive, source / "node_modules" / name) + packed_rn = source / "node_modules/react-native" + excluded_parts = {"build", ".build", ".gradle", ".kotlin", ".cxx", ".swiftpm", "__pycache__"} + leaked = [str(path.relative_to(packed_rn)) for path in (packed_rn / "ReactShared").rglob("*") + if any(part in excluded_parts for part in path.relative_to(packed_rn / "ReactShared").parts) + or path.suffix in {".pyc", ".o", ".a", ".so", ".dylib", ".class", ".framework", ".xcframework"}] + if leaked: + raise AssertionError(f"Generated ReactShared outputs leaked into the npm archive: {leaked}") + versions = tomllib.loads((packed_rn / "gradle/libs.versions.toml").read_text())["versions"] + version = properties(packed_rn / "ReactAndroid/gradle.properties")["VERSION_NAME"] + hermes = properties(packed_rn / "sdks/hermes-engine/version.properties")["HERMES_VERSION_NAME"] + maven = args.maven_repository.resolve() if args.maven_repository else output / "maven" + for mode in ["binary", "source"]: + fixture(output / mode, mode, versions, version, hermes, maven, args.abi) + report = {"status": "prepared", "archives": {name: {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} for name, path in archives.items()}, + "maven_repository": str(maven), "abi": args.abi, + "hermes_build": {"publication": "stable Maven dependency", "packed_source": "standard Hermes source build"}, + "consumers": {}} + report_file = output / "results.json" + report_file.write_text(json.dumps(report, indent=2) + "\n") + if args.prepare_only: + print(f"Prepared fixtures only; no Android builds or device tests ran. Report: {report_file}") + return + gradle = [str(REPOSITORY / "gradlew"), f"--max-workers={args.max_workers}", "--console=plain", + f"-PreactNativeArchitectures={args.abi}", + "-Dorg.gradle.internal.http.connectionTimeout=30000", "-Dorg.gradle.internal.http.socketTimeout=30000"] + try: + if not args.maven_repository: + init = output / "publication.gradle" + write(init, f""" + gradle.projectsEvaluated {{ + rootProject.allprojects.each {{ project -> + def publishing = project.extensions.findByType(org.gradle.api.publish.PublishingExtension) + publishing?.repositories?.withType(org.gradle.api.artifacts.repositories.MavenArtifactRepository)?.each {{ repository -> + if (repository.name == 'mavenTempLocal') repository.url = project.uri({groovy_string(maven)}) + }} + }} + }} + """) + run(gradle + ["-Preact.internal.useHermesStable=true", "--init-script", str(init), ":packages:react-native:ReactAndroid:publishReleasePublicationToMavenTempLocalRepository"], REPOSITORY, output / "publication.log") + aars = list((maven / "com/facebook/react/react-android" / version).glob("*.aar")) + if not aars: + raise AssertionError("The Maven publication contains no ReactAndroid AARs") + report["published_aars"] = [inspect_aar(aar) for aar in aars] + # Install dependencies for the packed tools, without installing an unpublished + # React Native version or replacing the extracted React Native source package. + write(source / "package.json", json.dumps({"private": True, "dependencies": { + name: f"file:{archives[name]}" for name in ["@react-native/codegen", "@react-native/gradle-plugin"]}}, indent=2)) + run(["npm", "install", "--ignore-scripts", "--no-audit", "--no-fund"], source, output / "source-dependencies.log", environment) + # npm may prune the manually extracted package because it is deliberately not + # installed from the registry. Re-extract the same immutable npm archive. + if (source / "node_modules/react-native").exists(): + shutil.rmtree(source / "node_modules/react-native") + unpack(archives["react-native"], source / "node_modules/react-native") + for mode in ["binary", "source"]: + consumer = output / mode + run(gradle + [":app:verifyResolution"], consumer, output / f"{mode}-build.log") + resolution = json.loads((consumer / "app/build/consumer-resolution.json").read_text()) + report["consumers"][mode] = {"debug_and_minified_release": "passed", "resolution": resolution, + "runtime": "not run: no --device supplied"} + if mode == "source": + built = list((packed_rn / "ReactAndroid/build/outputs/aar").glob("*.aar")) + if not built: + raise AssertionError("Packed-source consumer did not build a ReactAndroid AAR") + report["source_aars"] = [inspect_aar(aar) for aar in built] + if args.device: + sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + adb = str(Path(sdk) / "platform-tools/adb") if sdk else shutil.which("adb") + if not adb: + raise RuntimeError("adb is required for --device") + report["consumers"][mode]["runtime"] = device_check(adb, args.device, consumer, mode, output / f"{mode}-device.log") + report["status"] = "passed" + except Exception as error: + report["status"] = "failed" + report["error"] = str(error) + raise + finally: + report_file.write_text(json.dumps(report, indent=2) + "\n") + print(f"Android consumer report: {report_file}") + + +if __name__ == "__main__": + main() diff --git a/packages/react-native/ReactShared/scripts/test-apple-app.sh b/packages/react-native/ReactShared/scripts/test-apple-app.sh new file mode 100755 index 000000000000..9e47e8c3a09d --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-apple-app.sh @@ -0,0 +1,219 @@ +#!/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 -euo pipefail + +platform="${1:-simulator}" +case "$platform" in + simulator|device|catalyst) ;; + *) echo 'usage: test-apple-app.sh [simulator|device|catalyst] [--prepare-only]' >&2; exit 1 ;; +esac +if [[ $# -gt 2 || ( $# -eq 2 && "$2" != --prepare-only ) ]]; then + echo 'error: The only optional second argument is --prepare-only.' >&2 + exit 1 +fi + +shared_root="$(cd "$(dirname "$0")/.." && pwd)" +repo_root="$(cd "$shared_root/../../.." && pwd)" +original="$repo_root/packages/rn-tester" +if [[ ! -f "$original/Podfile" ]]; then + echo 'error: This application test requires a React Native repository checkout with RNTester.' >&2 + exit 1 +fi +output="${RCT_KMP_APP_OUTPUT_DIR:-$shared_root/build/apple-app-$platform-${USE_FRAMEWORKS:-libraries}}" +mkdir -p "$output" +output="$(cd "$output" && pwd)" +if [[ -e "$output/DerivedData" || -e "$output/Tests.xcresult" ]]; then + echo 'error: Choose a fresh RCT_KMP_APP_OUTPUT_DIR; existing products must not count as this run.' >&2 + exit 1 +fi +# A sibling preserves RNTester's relative references to the other packages. +fixture="$(mktemp -d "$repo_root/packages/.rn-tester-kmp.XXXXXX")" +simulator="" +metro_pid="" +websocket_pid="" +cleanup() { + result=$? + trap - EXIT ERR + set +e + for pid in "$metro_pid" "$websocket_pid"; do + if [[ -n "$pid" ]]; then kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; fi + done + if [[ -n "$simulator" ]]; then + xcrun simctl shutdown "$simulator" >/dev/null 2>&1 + xcrun simctl delete "$simulator" >/dev/null 2>&1 + fi + # Keep the generated configuration for diagnosis, without leaving a second app project. + cp "$fixture/Podfile" "$output/Podfile" 2>/dev/null + cp "$fixture/Podfile.lock" "$output/Podfile.lock" 2>/dev/null + cp "$fixture/package.json" "$output/package.json" 2>/dev/null + cp "$fixture/RNTesterPods.xcodeproj/project.pbxproj" "$output/project.pbxproj" 2>/dev/null + rm -rf "$fixture" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'echo "error: RNTester validation failed; logs: $output" >&2' ERR + +rsync -a --exclude Pods --exclude build --exclude node_modules --exclude .xcode.env.local \ + "$original/" "$fixture/" +if [[ -d "$original/node_modules" ]]; then + ln -s "$original/node_modules" "$fixture/node_modules" +fi +# The source dependency graph can differ from the normally prebuilt Podfile.lock. +rm -f "$fixture/Podfile.lock" +python3 - "$fixture/Podfile" "$platform" <<'PY' +import pathlib +import json +import sys + +podfile = pathlib.Path(sys.argv[1]) +# The copy must not introduce a duplicate workspace package when Metro follows +# node_modules symlinks while bundling the original or copied app. +package_path = podfile.parent / 'package.json' +package = json.loads(package_path.read_text()) +package['name'] = '@react-native/tester-kmp-' + podfile.parent.name.rsplit('.', 1)[1].lower() +package_path.write_text(json.dumps(package, indent=2) + '\n') +source = podfile.read_text() +if sys.argv[2] == 'catalyst': + setting = ':mac_catalyst_enabled => false' + if source.count(setting) != 1: + sys.exit('error: RNTester Catalyst setting changed; update the fixture.') + source = source.replace(setting, ':mac_catalyst_enabled => true') +podfile.write_text(source) +PY +if [[ "${2:-}" == --prepare-only ]]; then + echo "RNTester $platform fixture prepared successfully; no dependencies, builds or tests were run." + exit 0 +fi + +export RCT_USE_KMP=1 RCT_USE_PREBUILT_RNCORE=0 RCT_USE_RN_DEP=0 +export RCT_NO_LAUNCH_PACKAGER=1 +export NODE_BINARY="$(command -v node)" +# Use the same bundle for the repository check and the copied app's Podfile. +export BUNDLE_GEMFILE="${BUNDLE_GEMFILE:-$repo_root/Gemfile}" +cd "$repo_root" +bundle check > "$output/bundle-check.log" 2>&1 +if [[ "$platform" == catalyst ]]; then + # Xcode selects destinations before applying command-line build settings. + # Enable Catalyst in the copied application project as well as in its pods. + bundle exec ruby - "$fixture/RNTesterPods.xcodeproj" <<'RUBY' +require 'xcodeproj' +project = Xcodeproj::Project.open(ARGV.fetch(0)) +project.targets.each do |target| + target.build_configurations.each do |configuration| + configuration.build_settings['SUPPORTS_MACCATALYST'] = 'YES' + end +end +project.save +RUBY +fi +yarn --cwd packages/react-native-codegen build > "$output/codegen.log" 2>&1 +cd "$fixture" +bundle exec pod install > "$output/pod-install.log" 2>&1 + +build=(xcodebuild -workspace "$fixture/RNTesterPods.xcworkspace" -scheme RNTester + -derivedDataPath "$output/DerivedData" -jobs "${RCT_KMP_APP_JOBS:-4}" CODE_SIGNING_ALLOWED=NO) +if [[ "$platform" == simulator ]]; then + # Own the servers and simulator so cleanup never stops a developer's processes. + for port in 8081 5555; do + if lsof -n -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "error: Port $port is in use; stop its server before running the application test." >&2 + exit 1 + fi + done + cd "$original" + "$repo_root/node_modules/.bin/react-native" start --max-workers 2 > "$output/metro.log" 2>&1 & + metro_pid=$! + node IntegrationTests/websocket_integration_test_server.js > "$output/websocket.log" 2>&1 & + websocket_pid=$! + ready=0 + for ((attempt=0; attempt<60; attempt++)); do + if curl --silent --fail --max-time 1 http://localhost:8081/status | grep -q 'packager-status:running' && \ + curl --silent --max-time 1 http://localhost:5555 | grep -q 'Upgrade Required'; then ready=1; break; fi + sleep 1 + done + if [[ "$ready" != 1 ]]; then echo 'error: Test servers did not become ready.' >&2; exit 1; fi + curl --silent --show-error --fail \ + 'http://localhost:8081/js/RNTesterApp.bundle?platform=ios&dev=true' -o "$output/RNTesterApp.bundle" + xcrun simctl list -j > "$output/simulators.json" + read -r device_type runtime < <(python3 - "$output/simulators.json" <<'PY' +import json +import sys + +state = json.load(open(sys.argv[1])) +runtimes = [item for item in state['runtimes'] if item.get('isAvailable') and '.iOS-' in item['identifier']] +if not runtimes: + sys.exit('error: Install an iOS Simulator runtime before running application tests.') +for runtime in sorted(runtimes, key=lambda item: tuple(map(int, item['version'].split('.'))), reverse=True): + devices = [item for item in state['devices'].get(runtime['identifier'], []) + if item.get('isAvailable') and item['name'].startswith('iPhone') and item.get('deviceTypeIdentifier')] + if devices: + print(devices[0]['deviceTypeIdentifier'], runtime['identifier']) + break +else: + sys.exit('error: Create an available iPhone simulator in Xcode before running application tests.') +PY +) + simulator="$(xcrun simctl create 'React Native KMP application test' "$device_type" "$runtime")" + xcrun simctl boot "$simulator" + xcrun simctl bootstatus "$simulator" -b + "${build[@]}" test -configuration Debug -sdk iphonesimulator \ + -destination "platform=iOS Simulator,id=$simulator" -parallel-testing-enabled NO \ + -resultBundlePath "$output/Tests.xcresult" > "$output/xcodebuild.log" 2>&1 + xcrun xcresulttool get test-results summary --path "$output/Tests.xcresult" > "$output/test-summary.json" + python3 - "$output/test-summary.json" <<'PY' +import json +import sys + +summary = json.load(open(sys.argv[1])) +if summary.get('failedTests', 0) or not summary.get('passedTests', 0): + sys.exit('error: RNTester must execute passing tests; an empty test run is not validation.') +PY + # The existing test plan enables sanitizers, so Xcode may add a Variant-ASan-UBSan directory. + product="$(python3 - "$output/DerivedData/Build/Products" <<'PY' +import pathlib +import sys + +apps = list(pathlib.Path(sys.argv[1]).glob('**/Debug-iphonesimulator/RNTester.app')) +if len(apps) != 1: + sys.exit(f'error: Expected one tested RNTester.app, found {apps}; use a fresh output directory.') +print(apps[0]) +PY +)" + identifier=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$product/Info.plist") + xcrun simctl install "$simulator" "$product" + xcrun simctl launch --terminate-running-process "$simulator" "$identifier" > "$output/app-launch.log" 2>&1 +elif [[ "$platform" == device ]]; then + "${build[@]}" build -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \ + > "$output/xcodebuild.log" 2>&1 +else + "${build[@]}" build -configuration Debug -destination 'generic/platform=macOS,variant=Mac Catalyst' \ + SUPPORTS_MACCATALYST=YES > "$output/xcodebuild.log" 2>&1 +fi + +# Check the actual object compiled by the pod target: just finding Kotlin in the +# app would not prove that RCTGradientUtils selected the shared implementation. +python3 - "$output/DerivedData" "$platform" "$output/gradient-symbols.txt" <<'PY' +import pathlib +import subprocess +import sys + +objects = list(pathlib.Path(sys.argv[1]).rglob('RCTGradientUtils.o')) +if not objects: + sys.exit('error: The RNTester build did not compile the gradient adapter from source.') +symbols = [subprocess.check_output(['xcrun', 'nm', '-u', str(item)], text=True) for item in objects] +pathlib.Path(sys.argv[3]).write_text('\n'.join(symbols)) +expected_kmp = sys.argv[2] != 'catalyst' +if any(('OBJC_CLASS_$_RNSGradientStops' in item) != expected_kmp for item in symbols): + sys.exit('error: Compiled gradient adapter selected an unexpected KMP/native implementation.') +PY +if [[ "$platform" == simulator ]]; then + echo "RNTester tests and simulator launch passed (${USE_FRAMEWORKS:-static libraries}); reports: $output" +else + echo "RNTester $platform unsigned build passed; no device tests were run. Reports: $output" +fi diff --git a/packages/react-native/ReactShared/scripts/test-apple-distribution.sh b/packages/react-native/ReactShared/scripts/test-apple-distribution.sh new file mode 100755 index 000000000000..b33c93fcdbfa --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-apple-distribution.sh @@ -0,0 +1,117 @@ +#!/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 -euo pipefail + +mode="${1:-}" +case "$mode" in + ''|--build-only|--package-only) ;; + *) echo "Usage: $0 [--build-only|--package-only]" >&2; exit 1 ;; +esac +shared_root="$(cd "$(dirname "$0")/.." && pwd)" +build_type="${RCT_KMP_BUILD_TYPE:-Release}" +case "$build_type" in + Debug) variant=debugFramework ;; + Release) variant=releaseFramework ;; + *) echo 'error: RCT_KMP_BUILD_TYPE must be Debug or Release.' >&2; exit 1 ;; +esac +output="${RCT_KMP_DISTRIBUTION_OUTPUT_DIR:-$shared_root/build/apple-distribution/$build_type}" +mkdir -p "$output" +output="$(cd "$output" && pwd)" +# A fresh fixture avoids stale architecture slices or outputs from another build. +fixture="$(mktemp -d "$output/consumer.XXXXXX")" +cp -R "$shared_root/tests/distribution/." "$fixture/" + +if [[ "$mode" != --package-only ]]; then + "$shared_root/gradlew" -p "$shared_root" --console=plain \ + "link${build_type}FrameworkIosArm64" \ + "link${build_type}FrameworkIosSimulatorArm64" \ + "link${build_type}FrameworkIosX64" +fi + +device="$shared_root/build/bin/iosArm64/$variant/ReactNativeShared.framework" +sim_arm="$shared_root/build/bin/iosSimulatorArm64/$variant/ReactNativeShared.framework" +sim_intel="$shared_root/build/bin/iosX64/$variant/ReactNativeShared.framework" +for framework in "$device" "$sim_arm" "$sim_intel"; do + test -f "$framework/ReactNativeShared" + cmp "$device/Headers/ReactNativeShared.h" "$framework/Headers/ReactNativeShared.h" +done +ditto "$sim_arm" "$fixture/simulator/ReactNativeShared.framework" +xcrun lipo -create "$sim_arm/ReactNativeShared" "$sim_intel/ReactNativeShared" \ + -output "$fixture/simulator/ReactNativeShared.framework/ReactNativeShared" +xcodebuild -create-xcframework -framework "$device" \ + -framework "$fixture/simulator/ReactNativeShared.framework" \ + -output "$fixture/ReactNativeShared.xcframework" > "$fixture/package.log" 2>&1 + +# Include the matching Kotlin runtime's license bundle in any packaged experiment. +python3 - "$shared_root" "$fixture" "$mode" <<'PY' +import hashlib, json, os, pathlib, platform, plistlib, re, shutil, sys +root, fixture = map(pathlib.Path, sys.argv[1:3]) +compiler = re.search(r'version "([^"]+)"', (root / 'build.gradle.kts').read_text())[1] +host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' +konan = pathlib.Path(os.environ.get('KONAN_DATA_DIR', pathlib.Path.home() / '.konan')) +native_home = pathlib.Path(os.environ.get('KONAN_HOME', konan / f'kotlin-native-prebuilt-macos-{host}-{compiler}')) +licenses = native_home / 'licenses' +if not (licenses / 'LICENSE.txt').is_file(): + sys.exit(f'error: Matching Kotlin {compiler} licenses missing at {licenses}; set KONAN_HOME.') +shutil.copytree(licenses, fixture / 'licenses' / 'kotlin') +shutil.copyfile(root.parent / 'LICENSE', fixture / 'licenses' / 'ReactNative-LICENSE') +xcf = fixture / 'ReactNativeShared.xcframework' +info = plistlib.loads((xcf / 'Info.plist').read_bytes()) +slices = {(item['SupportedPlatform'], item.get('SupportedPlatformVariant', ''), + tuple(sorted(item['SupportedArchitectures']))) for item in info['AvailableLibraries']} +expected = {('ios', '', ('arm64',)), ('ios', 'simulator', ('arm64', 'x86_64'))} +if slices != expected: + sys.exit(f'error: Unexpected XCFramework slices: {slices}') +manifest = { + 'kotlinVersion': compiler, + 'reusedExistingFrameworks': sys.argv[3] == '--package-only', + 'files': {str(p.relative_to(fixture)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(xcf.rglob('*')) if p.is_file()}, + 'scope': 'Isolated binary consumer; not the React Native core SwiftPM/prebuilt integration', +} +(fixture / 'distribution.json').write_text(json.dumps(manifest, indent=2) + '\n') +PY + +if [[ "$mode" == --package-only ]]; then + echo "XCFramework slices and licenses checked using existing frameworks; consumer build/execution pending: $fixture" + exit 0 +fi + +(cd "$fixture" && swift package describe --type json) > "$fixture/package-description.json" 2> "$fixture/package-description.log" +for platform in 'iOS' 'iOS Simulator' 'macOS,variant=Mac Catalyst'; do + name="${platform// /-}" + (cd "$fixture" && xcodebuild -scheme KMPDistributionProbe \ + -destination "generic/platform=$platform" -derivedDataPath "$fixture/derived/$name" \ + -configuration "$build_type" CLANG_ENABLE_CODE_COVERAGE=NO CODE_SIGNING_ALLOWED=NO build) > "$fixture/$name.log" 2>&1 +done +if [[ "$mode" != --build-only ]]; then + simulator="${RCT_KMP_SIMULATOR_UDID:-}" + if [[ -z "$simulator" ]]; then + simulator="$(xcrun simctl list devices available -j | python3 -c ' +import json, sys +for runtime, devices in json.load(sys.stdin)["devices"].items(): + if ".iOS-" in runtime and devices: + print(devices[0]["udid"]) + break +')" + fi + : "${simulator:?Install an iOS simulator runtime or set RCT_KMP_SIMULATOR_UDID}" + (cd "$fixture" && xcodebuild -scheme KMPDistributionProbe \ + -destination "id=$simulator" -derivedDataPath "$fixture/derived/iOS-Simulator" \ + -configuration "$build_type" -resultBundlePath "$fixture/consumer.xcresult" \ + -enableCodeCoverage NO CODE_SIGNING_ALLOWED=NO test) > "$fixture/consumer-test.log" 2>&1 + xcrun xcresulttool get test-results summary --path "$fixture/consumer.xcresult" > "$fixture/test-summary.json" + python3 - "$fixture/test-summary.json" <<'PY' +import json +import sys + +summary = json.load(open(sys.argv[1])) +if summary.get('failedTests', 0) or not summary.get('passedTests', 0): + sys.exit('error: The distribution consumer must execute passing tests.') +PY +fi +echo "Apple distribution checks completed ($mode): $fixture" diff --git a/packages/react-native/ReactShared/scripts/test-apple-gradient.sh b/packages/react-native/ReactShared/scripts/test-apple-gradient.sh new file mode 100755 index 000000000000..76546496098c --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-apple-gradient.sh @@ -0,0 +1,119 @@ +#!/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 -euo pipefail + +shared_root="$(cd "$(dirname "$0")/.." && pwd)" +react_native_root="$(cd "$shared_root/.." && pwd)" +test_root="${RCT_KMP_TEST_OUTPUT_DIR:-$shared_root/build/apple-gradient-test}" +architecture="$(uname -m)" +sdk_root="$(xcrun --sdk iphonesimulator --show-sdk-path)" +mkdir -p "$test_root/include/React" + +# Build and stage through the same helper that the CocoaPods build phase uses. +PLATFORM_NAME=iphonesimulator ARCHS="$architecture" CONFIGURATION=Debug \ + PODS_CONFIGURATION_BUILD_DIR="$test_root" "$shared_root/scripts/build-apple-framework.sh" + +for header in "$react_native_root"/React/Base/*.h; do + ln -sf "$header" "$test_root/include/React/$(basename "$header")" +done +ln -sf "$react_native_root/Libraries/NativeAnimation/RCTAnimationUtils.h" "$test_root/include/React/RCTAnimationUtils.h" +ln -sf "$react_native_root/React/Fabric/Utils/RCTGradientUtils.h" "$test_root/include/React/RCTGradientUtils.h" + +# Use the real inline color conversion without pulling unrelated renderer/Folly +# headers into this standalone test. Fail if its declaration can no longer be extracted. +python3 - "$react_native_root/React/Fabric/RCTConversions.h" "$test_root/include/React/RCTConversions.h" <<'PY' +import pathlib +import re +import sys + +source = pathlib.Path(sys.argv[1]).read_text() +helpers = re.findall( + r"(?m)^inline UIColor \*_Nullable RCTUIColorFromSharedColor\([^\n]*\)\n\{[^{}]*\n\}", source +) +if len(helpers) != 1: + sys.exit("error: RCTUIColorFromSharedColor changed; update the standalone parity header extraction.") +pathlib.Path(sys.argv[2]).write_text( + "#import \n" + "#import \n" + helpers[0] + "\n" +) +PY + +graphics="$react_native_root/ReactCommon/react/renderer/graphics" +ios_graphics="$graphics/platform/ios/react/renderer/graphics" +flags=( + -std=c++20 -fobjc-arc -DREACT_NATIVE_PRODUCTION -DRCTLOG_ENABLED=0 -include memory -include vector + -target "$architecture-apple-ios15.1-simulator" -isysroot "$sdk_root" + -I "$test_root/include" -I "$react_native_root/ReactCommon" -I "$graphics/platform/ios" + -F "$test_root/ReactNativeSharedKMP" +) +if [[ "${RCT_KMP_BUILD_TYPE:-Debug}" == "Release" ]]; then + flags+=(-O2) +fi +adapter="$react_native_root/React/Fabric/Utils/RCTGradientUtils.mm" +xcrun clang++ "${flags[@]}" -DRCT_USE_KMP=1 -c "$adapter" -o "$test_root/adapter.o" +xcrun clang++ "${flags[@]}" -DRCT_USE_KMP=0 -DRCTGradientUtils=RCTGradientUtilsBaseline \ + -c "$adapter" -o "$test_root/baseline.o" +native_sources=( + "$react_native_root/Libraries/NativeAnimation/RCTAnimationUtils.mm" + "$graphics/Color.cpp" "$graphics/ColorComponents.cpp" + "$ios_graphics/HostPlatformColor.mm" "$ios_graphics/RCTPlatformColorUtils.mm" + "$react_native_root/ReactCommon/react/utils/ManagedObjectWrapper.mm" +) +native_objects=() +for source in "${native_sources[@]}"; do + object="$test_root/$(basename "$source").o" + xcrun clang++ "${flags[@]}" -x objective-c++ -c "$source" -o "$object" + native_objects+=("$object") +done +frameworks=(-framework Foundation -framework UIKit -framework QuartzCore -framework CoreGraphics) +xcrun clang++ "${flags[@]}" "$shared_root/tests/AppleGradientParity.mm" \ + "$test_root/adapter.o" "$test_root/baseline.o" "${native_objects[@]}" \ + -framework ReactNativeShared "${frameworks[@]}" \ + -o "$test_root/AppleGradientParity" + +# Exercise the dynamic RCTFabric linkage model too: Kotlin is linked into this +# library only, and the caller links the library without another Kotlin runtime. +xcrun clang++ "${flags[@]}" -dynamiclib "$test_root/adapter.o" "${native_objects[@]}" \ + -framework ReactNativeShared "${frameworks[@]}" \ + -Wl,-install_name,@rpath/libKMPGradientAdapter.dylib -o "$test_root/libKMPGradientAdapter.dylib" +xcrun clang++ "${flags[@]}" "$shared_root/tests/AppleGradientParity.mm" "$test_root/baseline.o" \ + -L "$test_root" -lKMPGradientAdapter "${frameworks[@]}" -Wl,-rpath,"$test_root" \ + -o "$test_root/AppleGradientParityDynamic" + +# Catalyst must compile the original path even when the pilot flag is defined, +# without a framework header search path or a nonexistent Catalyst K/N slice. +mac_sdk_root="$(xcrun --sdk macosx --show-sdk-path)" +xcrun clang++ -std=c++20 -fobjc-arc -DREACT_NATIVE_PRODUCTION -DRCT_USE_KMP=1 \ + -target "$architecture-apple-ios15.1-macabi" -isysroot "$mac_sdk_root" \ + -isystem "$mac_sdk_root/System/iOSSupport/usr/include" \ + -iframework "$mac_sdk_root/System/iOSSupport/System/Library/Frameworks" \ + -include memory -include vector -I "$test_root/include" \ + -I "$react_native_root/ReactCommon" -I "$graphics/platform/ios" -fsyntax-only "$adapter" + +if [[ "${RCT_KMP_BUILD_ONLY:-0}" == "1" ]]; then + echo "Apple gradient harnesses compiled; execution was not requested: $test_root" + exit 0 +fi + +simulator="${RCT_KMP_SIMULATOR_UDID:-}" +if [[ -z "$simulator" ]]; then + simulator="$(xcrun simctl list devices available -j | python3 -c ' +import json, sys +devices = json.load(sys.stdin)["devices"] +for runtime, entries in devices.items(): + if ".iOS-" in runtime and entries: + print(entries[0]["udid"]) + break +')" +fi +if [[ -z "$simulator" ]]; then + echo 'error: Install an iOS Simulator runtime in Xcode before running the Apple gradient parity test.' >&2 + exit 1 +fi +# Standalone spawn runs the executable without booting or modifying the simulator. +xcrun simctl spawn --standalone "$simulator" "$test_root/AppleGradientParity" +xcrun simctl spawn --standalone "$simulator" "$test_root/AppleGradientParityDynamic" diff --git a/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb new file mode 100644 index 000000000000..31228b27bf3c --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb @@ -0,0 +1,116 @@ +# 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 'cocoapods' +require 'fileutils' +require 'json' +require 'open3' +require 'tmpdir' + +shared_root = File.expand_path('..', __dir__) +helper = File.expand_path('../scripts/cocoapods/kmp.rb', shared_root) +output_root = ENV['RCT_KMP_LINKING_TEST_OUTPUT_DIR'] || Dir.mktmpdir('react-native-kmp-linking-') +FileUtils.mkdir_p(output_root) +results = [] + +# These local pods exercise CocoaPods' real dependency and target-inheritance +# handling without downloading dependencies or compiling a Kotlin framework. +[nil, 'static', 'dynamic', 'mixed'].each do |linkage| + name = linkage || 'static-library' + fixture = File.join(output_root, name) + FileUtils.mkdir_p(fixture) + project = Xcodeproj::Project.new(File.join(fixture, 'Fixture.xcodeproj')) + host = project.new_target(:application, 'Host', :ios, '15.1') + hosted = project.new_target(:unit_test_bundle, 'HostedTests', :ios, '15.1') + project.new_target(:unit_test_bundle, 'StandaloneTests', :ios, '15.1') + hosted.add_dependency(host) + hosted.build_configurations.each do |config| + config.build_settings['TEST_HOST'] = '$(BUILT_PRODUCTS_DIR)/Host.app/Host' + config.build_settings['BUNDLE_LOADER'] = '$(TEST_HOST)' + end + project.save + + %w[React-RCTFabric TestSupport].each do |pod_name| + pod_dir = File.join(fixture, pod_name) + FileUtils.mkdir_p(pod_dir) + File.write(File.join(pod_dir, 'Fixture.m'), "#import \n") + File.write(File.join(pod_dir, "#{pod_name}.podspec"), <<~PODSPEC) + Pod::Spec.new do |s| + s.name = #{pod_name.dump} + s.version = '1.0.0' + s.summary = 'Local CocoaPods linking fixture.' + s.homepage = 'https://reactnative.dev/' + s.license = { :type => 'MIT', :text => 'Test fixture' } + s.author = 'React Native' + s.source = { :git => 'https://example.invalid/fixture.git' } + s.platform = :ios, '15.1' + s.source_files = 'Fixture.m' + #{"s.dependency 'React-KMP'" if pod_name == 'React-RCTFabric'} + end + PODSPEC + end + + File.write(File.join(fixture, 'helpers.rb'), <<~HELPERS) + require #{helper.dump} + def min_supported_versions + { :ios => '15.1' } + end + HELPERS + File.write(File.join(fixture, 'Podfile'), <<~PODFILE) + require_relative './helpers' + platform :ios, '15.1' + install! 'cocoapods', :warn_for_unused_master_specs_repo => false + project 'Fixture.xcodeproj' + #{"use_frameworks! :linkage => :#{linkage}" if linkage && linkage != 'mixed'} + target 'Host' do + #{"use_frameworks! :linkage => :dynamic" if linkage == 'mixed'} + pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-RCTFabric', :path => './React-RCTFabric' + target 'HostedTests' do + inherit! :search_paths + pod 'TestSupport', :path => './TestSupport' + end + end + target 'StandaloneTests' do + #{"use_frameworks! :linkage => :static" if linkage == 'mixed'} + pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-RCTFabric', :path => './React-RCTFabric' + end + post_install do |installer| + ReactNativeKMPUtils.configure_aggregate_xcconfig(installer) + ReactNativeKMPUtils.configure_aggregate_xcconfig(installer) + end + PODFILE + + output, status = Open3.capture2e( + { 'COCOAPODS_DISABLE_STATS' => 'true', 'COCOAPODS_NO_BUNDLER' => 'true' }, + RbConfig.ruby, Gem.bin_path('cocoapods', 'pod'), 'install', + "--project-directory=#{fixture}", '--no-repo-update' + ) + File.write(File.join(fixture, 'pod-install.log'), output) + raise "CocoaPods #{name} fixture failed: #{output}" unless status.success? + + %w[Host HostedTests StandaloneTests].each do |target| + %w[debug release].each do |configuration| + config_path = File.join(fixture, 'Pods', 'Target Support Files', "Pods-#{target}", "Pods-#{target}.#{configuration}.xcconfig") + config = Xcodeproj::Config.new(Pathname.new(config_path)).attributes + dynamic = linkage == 'dynamic' || (linkage == 'mixed' && target == 'Host') + expected = target == 'HostedTests' || dynamic ? 0 : 1 + %w[iphoneos iphonesimulator].each do |sdk| + flags = config["OTHER_LDFLAGS[sdk=#{sdk}*]"].to_s + count = flags.scan('-framework ReactNativeShared').length + raise "#{name}/#{target}/#{configuration}/#{sdk}: expected #{expected} Kotlin links, got #{count}" unless count == expected + search_paths = config["FRAMEWORK_SEARCH_PATHS[sdk=#{sdk}*]"].to_s + raise "Missing inherited framework search path for #{name}/#{target}" unless search_paths.include?('ReactNativeSharedKMP') + end + raise "Unconditional Kotlin link leaks into Catalyst for #{name}/#{target}" if config['OTHER_LDFLAGS'].to_s.include?('ReactNativeShared') + results << { :linkage => name, :target => target, :configuration => configuration, :kotlin_links_per_ios_sdk => expected } + end + end +end + +File.write(File.join(output_root, 'results.json'), JSON.pretty_generate(results) + "\n") +puts "Passed #{results.length} CocoaPods configurations across static libraries, static frameworks, dynamic frameworks, and mixed per-target linkage." +puts "Generated fixture configs and logs: #{output_root}" diff --git a/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py b/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py new file mode 100644 index 000000000000..43d5b7802d9d --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# 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. + +"""Check two independent Kotlin/Native frameworks built with the same compiler. + +Tests all four combinations of static and dynamic runtime ownership. No +Kotlin classes are cast between frameworks. This does not promise compatibility +with arbitrary third-party compiler versions or exported Kotlin dependencies. +""" + +import argparse +import json +import os +import pathlib +import platform +import re +import subprocess + + +def run(command, log): + result = subprocess.run([str(item) for item in command], capture_output=True, text=True) + log.write_text(result.stdout + result.stderr) + if result.returncode: + raise RuntimeError(f"Command failed with exit {result.returncode}; inspect {log}") + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-only", action="store_true", help="Compile and inspect linkage without simulator execution.") + parser.add_argument("--output", type=pathlib.Path) + args = parser.parse_args() + shared = pathlib.Path(__file__).resolve().parent.parent + output = (args.output or shared / "build/kotlin-coexistence").resolve() + output.mkdir(parents=True, exist_ok=True) + fixture = shared / "tests/coexistence" + version_matches = re.findall(r'kotlin\("multiplatform"\) version "([^"]+)"', (shared / "build.gradle.kts").read_text()) + if len(version_matches) != 1: + raise RuntimeError("Cannot identify the shared module's Kotlin compiler version.") + version = version_matches[0] + architecture = platform.machine() + host = "macos-aarch64" if architecture == "arm64" else "macos-x86_64" + target = "ios_simulator_arm64" if architecture == "arm64" else "ios_x64" + konan = pathlib.Path(os.environ.get("KONAN_DATA_DIR", pathlib.Path.home() / ".konan")) + compiler = pathlib.Path(os.environ.get("RCT_KMP_KONANC", konan / f"kotlin-native-prebuilt-{host}-{version}/bin/konanc")) + if not compiler.is_file(): + raise RuntimeError("Build ReactShared once to install its compiler, or set RCT_KMP_KONANC to the matching konanc.") + compiler_version = run([compiler, "-version"], output / "compiler-version.log") + if not re.search(rf'(?, + epsilon: Double, + useDoublePrecision: Boolean = false, + ): List { + if (stops.isEmpty()) { + return emptyList() + } + + val math = Arithmetic(useDoublePrecision) + val positions = arrayOfNulls(stops.size) + var maxPositionSoFar = math.round(stops[0].position ?: 0.0) + var hasNullPositions = false + + // Set default endpoint positions and prevent specified positions from moving backwards. + for (i in stops.indices) { + val position = + stops[i].position?.let(math::round) + ?: when (i) { + 0 -> 0.0 + stops.lastIndex -> 1.0 + else -> null + } + if (position != null) { + val fixedPosition = math.max(position, maxPositionSoFar) + positions[i] = fixedPosition + maxPositionSoFar = fixedPosition + } else { + hasNullPositions = true + } + } + + // Evenly distribute each run of unpositioned stops between its own surrounding positions. + if (hasNullPositions) { + var lastDefinedIndex = 0 + for (i in 1 until positions.size) { + val endPosition = positions[i] ?: continue + val startPosition = checkNotNull(positions[lastDefinedIndex]) + val unpositionedStops = i - lastDefinedIndex - 1 + if (unpositionedStops > 0) { + val increment = + math.divide( + math.subtract(endPosition, startPosition), + (unpositionedStops + 1).toDouble(), + ) + for (j in 1..unpositionedStops) { + positions[lastDefinedIndex + j] = + math.add(startPosition, math.multiply(increment, j.toDouble())) + } + } + lastDefinedIndex = i + } + } + + val resolved = + stops.indices + .map { i -> ResolvedGradientStop(checkNotNull(positions[i]), i, i, 0.0) } + .toMutableList() + var indexOffset = 0 + for (i in 1 until stops.lastIndex) { + if (stops[i].hasColor) { + continue + } + + val index = i + indexOffset + val left = resolved[index - 1] + val right = resolved[index + 1] + val position = resolved[index].position + val leftDistance = math.subtract(position, left.position) + val rightDistance = math.subtract(right.position, position) + val totalDistance = math.subtract(right.position, left.position) + + if (math.equal(leftDistance, rightDistance, epsilon)) { + resolved.removeAt(index) + --indexOffset + continue + } + if (math.equal(leftDistance, 0.0, epsilon)) { + resolved[index] = right.copy(position = position) + continue + } + if (math.equal(rightDistance, 0.0, epsilon)) { + resolved[index] = left.copy(position = position) + continue + } + + // Use the same nine sample positions as the platform implementations, derived from Blink. + val samples = ArrayList(9) + if (leftDistance > rightDistance) { + for (y in 0..6) { + samples.add(math.sample(left.position, leftDistance, (7f + y) / 13f)) + } + samples.add(math.sample(position, rightDistance, 1f / 3f)) + samples.add(math.sample(position, rightDistance, 2f / 3f)) + } else { + samples.add(math.sample(left.position, leftDistance, 1f / 3f)) + samples.add(math.sample(left.position, leftDistance, 2f / 3f)) + for (y in 0..6) { + samples.add(math.sample(position, rightDistance, y / 13f)) + } + } + + val hintRelativeOffset = math.divide(leftDistance, totalDistance) + val hintLogarithm = math.log(hintRelativeOffset) + val logRatio = ln(0.5) / hintLogarithm + val intermediateStops = + samples.map { sample -> + val pointRelativeOffset = + math.divide(math.subtract(sample, left.position), totalDistance) + ResolvedGradientStop( + sample, + left.leftColorIndex, + right.rightColorIndex, + pointRelativeOffset.pow(logRatio), + ) + } + resolved.removeAt(index) + resolved.addAll(index, intermediateStops) + indexOffset += 8 + } + return resolved + } + + /** Keeps every platform operation at its original precision without duplicating the algorithm. */ + private class Arithmetic(private val doublePrecision: Boolean) { + fun round(value: Double): Double = if (doublePrecision) value else value.toFloat().toDouble() + + fun add(first: Double, second: Double): Double = + if (doublePrecision) first + second else (first.toFloat() + second.toFloat()).toDouble() + + fun subtract(first: Double, second: Double): Double = + if (doublePrecision) first - second else (first.toFloat() - second.toFloat()).toDouble() + + fun multiply(first: Double, second: Double): Double = + if (doublePrecision) first * second else (first.toFloat() * second.toFloat()).toDouble() + + fun divide(first: Double, second: Double): Double = + if (doublePrecision) first / second else (first.toFloat() / second.toFloat()).toDouble() + + fun max(first: Double, second: Double): Double = + if (doublePrecision) { + // Match std::max, including its behavior for NaN and signed zero. + if (first < second) second else first + } else { + maxOf(first.toFloat(), second.toFloat()).toDouble() + } + + fun log(value: Double): Double = + if (doublePrecision) ln(value) else ln(value.toFloat()).toDouble() + + fun equal(first: Double, second: Double, epsilon: Double): Boolean = + if (first.isNaN() || second.isNaN()) { + first.isNaN() && second.isNaN() + } else { + abs(subtract(second, first)) < epsilon + } + + fun sample(start: Double, distance: Double, fraction: Float): Double = + // Both native implementations originally compute these sample fractions as Float. + add(start, multiply(distance, fraction.toDouble())) + } +} diff --git a/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/GradientStopsTest.kt b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/GradientStopsTest.kt new file mode 100644 index 000000000000..e8b89637630f --- /dev/null +++ b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/GradientStopsTest.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.shared + +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GradientStopsTest { + @Test + fun emptyGradientHasNoStops() { + assertEquals(emptyList(), GradientStops.resolve(emptyList(), ANDROID_EPSILON)) + } + + @Test + fun singleColorDefaultsToStart() { + assertEquals(listOf(original(0f, 0)), resolve(colors(null))) + } + + @Test + fun positionedColorsKeepTheirInputReferences() { + assertEquals( + listOf(original(0f, 0), original(.42f, 1)), + resolve(colors(0f, .42f)), + ) + } + + @Test + fun missingEndpointsDefaultToZeroAndOne() { + assertEquals( + listOf(original(0f, 0), original(.3f, 1), original(1f, 2)), + resolve(colors(null, .3f, null)), + ) + } + + @Test + fun allUnpositionedColorsAreEvenlySpaced() { + assertEquals( + listOf(original(0f, 0), original(1f / 3f, 1), original(2f / 3f, 2), original(1f, 3)), + resolve(colors(null, null, null, null)), + ) + } + + @Test + fun decreasingPositionsUseTheLargestPreviousPosition() { + assertEquals( + listOf(0f, .3f, .3f, .6f, .6f), + resolve(colors(null, .3f, .2f, .6f, .5f)).map { it.position.toFloat() }, + ) + } + + @Test + fun positionsOutsideTheGradientArePreserved() { + assertEquals( + listOf(-1f, 0f, 1f, 2f), + resolve(colors(-1f, null, 1f, 2f)).map { it.position.toFloat() }, + ) + assertEquals(listOf(2f, 2f), resolve(colors(2f, null)).map { it.position.toFloat() }) + } + + @Test + fun eachUnpositionedRunUsesItsOwnAdjacentStops() { + assertEquals( + listOf(0f, .2f, .5f, .8f, .9f, 1f), + resolve(colors(0f, .2f, null, .8f, null, 1f)).map { it.position.toFloat() }, + ) + } + + @Test + fun centeredHintDoesNotAddAnInterpolationStop() { + assertEquals( + listOf(original(0f, 0), original(1f, 2)), + resolve(listOf(color(0f), hint(.5f), color(1f))), + ) + } + + @Test + fun hintInCollapsedIntervalIsRemovedBeforeEndpointReplacement() { + assertEquals( + listOf(original(.3f, 0), original(.3f, 2)), + resolve(listOf(color(.3f), hint(.2f), color(.1f))), + ) + } + + @Test + fun hintAtLeftEndpointReusesRightColor() { + assertEquals( + listOf(original(0f, 0), original(0f, 2), original(1f, 2)), + resolve(listOf(color(0f), hint(0f), color(1f))), + ) + } + + @Test + fun hintAtRightEndpointReusesLeftColor() { + assertEquals( + listOf(original(0f, 0), original(1f, 0), original(1f, 2)), + resolve(listOf(color(0f), hint(1f), color(1f))), + ) + } + + @Test + fun platformTolerancePreservesNearCenteredHintBehavior() { + val inputs = listOf(color(0f), hint(.502f), color(1f)) + assertEquals(11, resolve(inputs).size) + assertEquals(2, GradientStops.resolve(inputs, APPLE_EPSILON).size) + } + + @Test + fun platformTolerancePreservesNearEndpointHintBehavior() { + val inputs = listOf(color(0f), hint(.002f), color(1f)) + assertEquals(11, resolve(inputs).size) + assertEquals( + listOf(original(0f, 0), original(.002f, 2), original(1f, 2)), + GradientStops.resolve(inputs, APPLE_EPSILON), + ) + } + + @Test + fun leftHintUsesNineSamplesAndTheExpectedPowerCurve() { + val resolved = resolve(listOf(color(0f), hint(.25f), color(1f))) + assertEquals(11, resolved.size) + assertEquals(original(0f, 0), resolved.first()) + assertEquals(original(1f, 2), resolved.last()) + val samples = resolved.subList(1, 10) + val expectedPositions = + listOf( + .083333336f, + .16666667f, + .25f, + .30769232f, + .36538464f, + .42307693f, + .48076925f, + .53846157f, + .59615386f, + ) + assertEquals(expectedPositions, samples.map { it.position.toFloat() }) + for (sample in samples) { + assertEquals(0, sample.leftColorIndex) + assertEquals(2, sample.rightColorIndex) + // A quarter-position hint has exponent 1/2; Float logarithm rounding is retained. + assertEquals(sqrt(sample.position.toDouble()), sample.weight, absoluteTolerance = 1e-7) + } + } + + @Test + fun rightHintUsesNineSamplesAndEqualMixAtTheHint() { + val resolved = resolve(listOf(color(0f), hint(.75f), color(1f))) + val samples = resolved.subList(1, 10) + assertEquals( + listOf( + .40384617f, + .4615385f, + .5192308f, + .5769231f, + .6346154f, + .6923077f, + .75f, + .8333333f, + .9166667f, + ), + samples.map { it.position.toFloat() }, + ) + assertEquals(.5, samples[6].weight, absoluteTolerance = 1e-7) + assertTrue(samples.all { it.leftColorIndex == 0 && it.rightColorIndex == 2 }) + assertTrue(samples.all { it.weight.isFinite() && it.weight in 0.0..1.0 }) + assertTrue(samples.zipWithNext().all { (left, right) -> left.weight < right.weight }) + } + + @Test + fun appleLogarithmKeepsDoublePrecision() { + val inputs = listOf(color(0f), hint(.25f), color(1f)) + val apple = GradientStops.resolve(inputs, APPLE_EPSILON, useDoublePrecision = true) + val android = resolve(inputs) + + assertEquals(.5, apple[3].weight) + assertTrue(android[3].weight != apple[3].weight) + for (sample in apple.subList(1, 10)) { + assertEquals(sqrt(sample.position.toDouble()), sample.weight, absoluteTolerance = 1e-15) + } + } + + @Test + fun appleUnpositionedStopsKeepDoublePrecision() { + val inputs = colors(null, null, null, null) + val apple = GradientStops.resolve(inputs, APPLE_EPSILON, useDoublePrecision = true) + + assertEquals(listOf(0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0), apple.map { it.position }) + assertTrue(apple[1].position != resolve(inputs)[1].position) + } + + @Test + fun applePointDivisionDoesNotRoundAcrossTheCenteredHintThreshold() { + // Native point positions use CGFloat division: 100.5 points / 200 points. + // Rounding that position to Float changes whether the hint falls inside Apple's tolerance. + val inputs = + listOf( + GradientStopInput(0.0, true), + GradientStopInput(100.5 / 200.0, false), + GradientStopInput(1.0, true), + ) + + assertEquals(11, GradientStops.resolve(inputs, APPLE_EPSILON, useDoublePrecision = true).size) + assertEquals(2, GradientStops.resolve(inputs, APPLE_EPSILON).size) + } + + @Test + fun appleSamplesRetainDoublePositionsWithNativeFloatFractions() { + val inputs = listOf(color(0f), hint(.25f), color(1f)) + val apple = GradientStops.resolve(inputs, APPLE_EPSILON, useDoublePrecision = true) + + assertEquals(.25 * (1f / 3f).toDouble(), apple[1].position) + assertEquals(.25 + .75 * (6f / 13f).toDouble(), apple[9].position) + assertTrue(apple[9].position != resolve(inputs)[9].position) + } + + @Test + fun multipleHintsReferenceOriginalColorsAfterInsertionsAndRemoval() { + val inputs = + listOf(color(0f), hint(.1f), color(.5f), hint(.6f), color(.7f), hint(.9f), color(1f)) + val resolved = resolve(inputs) + assertEquals(22, resolved.size) + assertEquals(original(.5f, 2), resolved[10]) + assertEquals(original(.7f, 4), resolved[11]) + assertEquals(original(1f, 6), resolved[21]) + assertTrue(resolved.subList(1, 10).all { it.leftColorIndex == 0 && it.rightColorIndex == 2 }) + assertTrue(resolved.subList(12, 21).all { it.leftColorIndex == 4 && it.rightColorIndex == 6 }) + } + + @Test + fun repeatedResolutionDoesNotChangeTheInputs() { + val inputs = listOf(color(null), hint(.2f), color(null), color(.8f), color(null)) + val snapshot = inputs.toList() + assertEquals(resolve(inputs), resolve(inputs)) + assertEquals(snapshot, inputs) + } + + private fun resolve(stops: List): List = + GradientStops.resolve(stops, ANDROID_EPSILON) + + private fun colors(vararg positions: Float?): List = positions.map(::color) + + private fun color(position: Float?): GradientStopInput = + GradientStopInput(position?.toDouble(), true) + + private fun hint(position: Float): GradientStopInput = + GradientStopInput(position.toDouble(), false) + + private fun original(position: Float, colorIndex: Int): ResolvedGradientStop = + ResolvedGradientStop(position.toDouble(), colorIndex, colorIndex, 0.0) + + private companion object { + val ANDROID_EPSILON = .00001f.toDouble() + val APPLE_EPSILON = .005f.toDouble() + } +} diff --git a/packages/react-native/ReactShared/tests/AppleGradientBenchmark.mm b/packages/react-native/ReactShared/tests/AppleGradientBenchmark.mm new file mode 100644 index 000000000000..ae51335288f3 --- /dev/null +++ b/packages/react-native/ReactShared/tests/AppleGradientBenchmark.mm @@ -0,0 +1,154 @@ +/* + * 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 +#import + +#include +#include +#include +#include + +using namespace facebook::react; +using Clock = std::chrono::steady_clock; + +#if !RCT_BENCHMARK_KMP +@interface RCTGradientUtilsBaseline : NSObject ++ (std::vector)getFixedColorStops:(const std::vector &)colorStops + gradientLineLength:(CGFloat)gradientLineLength; +@end +#endif + +static volatile double checksum = 0; + +static void calculate(const std::vector &input) +{ +#if RCT_BENCHMARK_KMP + auto result = [RCTGradientUtils getFixedColorStops:input gradientLineLength:240]; +#else + auto result = [RCTGradientUtilsBaseline getFixedColorStops:input gradientLineLength:240]; +#endif + // Observe the output without timing another traversal or color conversion. + checksum = checksum + result.size() + result.front().position.value() + result.back().position.value(); +} + +static double nanosecondsSince(Clock::time_point start) +{ + return std::chrono::duration(Clock::now() - start).count(); +} + +static NSDictionary *memoryUsage() +{ + task_vm_info_data_t info{}; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + auto status = task_info(mach_task_self(), TASK_VM_INFO, reinterpret_cast(&info), &count); + if (status != KERN_SUCCESS) { + return @{@"taskInfoError" : @(status)}; + } + return @{@"residentBytes" : @(info.resident_size), @"physicalFootprintBytes" : @(info.phys_footprint)}; +} + +static std::vector inputForCase(const std::string &name) +{ + auto red = colorFromRGBA(255, 0, 0, 255); + auto blue = colorFromRGBA(0, 0, 255, 127); + if (name == "two_stops") { + return {{red, {}}, {blue, {}}}; + } + if (name == "asymmetric_hint") { + return {{red, {0, UnitType::Percent}}, {{}, {15, UnitType::Percent}}, {blue, {100, UnitType::Percent}}}; + } + std::vector result; + if (name == "implicit_16" || name == "explicit_64") { + const int count = name == "implicit_16" ? 16 : 64; + for (int i = 0; i < count; ++i) { + ValueUnit position = count == 16 ? ValueUnit{} : ValueUnit{100.f * i / (count - 1), UnitType::Percent}; + result.push_back({i % 2 == 0 ? red : blue, position}); + } + return result; + } + if (name == "multiple_hints") { + for (int i = 0; i < 8; ++i) { + result.push_back({i % 2 == 0 ? red : blue, {100.f * i / 7, UnitType::Percent}}); + if (i < 7) { + result.push_back({{}, {100.f * (i + 0.3f) / 7, UnitType::Percent}}); + } + } + return result; + } + fprintf(stderr, "Unknown gradient benchmark case: %s\n", name.c_str()); + exit(1); +} + +int main(int argc, char **argv) +{ + const bool firstCallOnly = argc == 5 && std::string(argv[4]) == "--first-call-only"; + if (argc != 4 && !firstCallOnly) { + fprintf(stderr, "Usage: AppleGradientBenchmark CASE ITERATIONS SAMPLES [--first-call-only]\n"); + return 1; + } + char *iterationsEnd = nullptr; + char *samplesEnd = nullptr; + const auto iterations = strtoul(argv[2], &iterationsEnd, 10); + const auto samples = strtoul(argv[3], &samplesEnd, 10); + if (*iterationsEnd != '\0' || *samplesEnd != '\0' || iterations == 0 || iterations > 1000000 || samples == 0 || + samples > 100) { + fprintf(stderr, "Iterations must be 1..1000000; samples must be 1..100.\n"); + return 1; + } + @autoreleasepool { + auto input = inputForCase(argv[1]); + auto beforeFirstCall = memoryUsage(); + auto start = Clock::now(); + @autoreleasepool { + calculate(input); + } + const auto firstCallNanoseconds = nanosecondsSince(start); + auto afterFirstCall = memoryUsage(); + const int warmupCalls = firstCallOnly ? 0 : 1000; + for (int i = 0; i < warmupCalls; ++i) { + @autoreleasepool { + calculate(input); + } + } + auto afterWarmup = memoryUsage(); + NSMutableArray *timings = [NSMutableArray new]; + for (unsigned long sample = 0; sample < (firstCallOnly ? 0 : samples); ++sample) { + start = Clock::now(); + for (unsigned long iteration = 0; iteration < iterations; ++iteration) { + @autoreleasepool { + calculate(input); + } + } + [timings addObject:@(nanosecondsSince(start) / iterations)]; + } + auto afterMeasurement = memoryUsage(); + NSDictionary *result = @{ + @"variant" : RCT_BENCHMARK_KMP ? @"kmp" : @"native", + @"case" : @(argv[1]), + @"iterationsPerSample" : @(iterations), + @"warmupCalls" : @(warmupCalls), + @"firstCallNanoseconds" : @(firstCallNanoseconds), + @"nanosecondsPerCall" : timings, + @"memoryBeforeFirstCall" : beforeFirstCall, + @"memoryAfterFirstCall" : afterFirstCall, + @"memoryAfterWarmup" : afterWarmup, + @"memoryAfterMeasurement" : afterMeasurement, + @"checksum" : @(checksum), + }; + NSError *error = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:&error]; + if (json == nil) { + fprintf(stderr, "Unable to serialize benchmark result: %s\n", error.localizedDescription.UTF8String); + return 1; + } + fwrite(json.bytes, 1, json.length, stdout); + putchar('\n'); + } + return 0; +} diff --git a/packages/react-native/ReactShared/tests/AppleGradientParity.mm b/packages/react-native/ReactShared/tests/AppleGradientParity.mm new file mode 100644 index 000000000000..8b8350298805 --- /dev/null +++ b/packages/react-native/ReactShared/tests/AppleGradientParity.mm @@ -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. + */ + +#import +#import + +#include +#include +#include +#include + +using namespace facebook::react; + +// The test runner compiles the unchanged native path under this class name. +@interface RCTGradientUtilsBaseline : NSObject ++ (std::vector)getFixedColorStops:(const std::vector &)colorStops + gradientLineLength:(CGFloat)gradientLineLength; +@end + +static size_t checkedCases = 0; + +static void checkParity(const std::vector &input, CGFloat lineLength = 240) +{ + auto expected = [RCTGradientUtilsBaseline getFixedColorStops:input gradientLineLength:lineLength]; + auto actual = [RCTGradientUtils getFixedColorStops:input gradientLineLength:lineLength]; + if (expected.size() != actual.size()) { + fprintf(stderr, "Gradient case %zu: expected %zu stops, got %zu\n", checkedCases, expected.size(), actual.size()); + exit(1); + } + for (size_t i = 0; i < expected.size(); ++i) { + if (std::abs(expected[i].position.value() - actual[i].position.value()) > 1e-12 || + static_cast(expected[i].color) != static_cast(actual[i].color) || + (expected[i].color && (*expected[i].color).getColor() != (*actual[i].color).getColor())) { + fprintf( + stderr, + "Gradient case %zu: stop %zu differs (positions %.9g / %.9g, colors %x / %x)\n", + checkedCases, + i, + expected[i].position.value(), + actual[i].position.value(), + expected[i].color ? (*expected[i].color).getColor() : 0, + actual[i].color ? (*actual[i].color).getColor() : 0); + exit(1); + } + } + ++checkedCases; +} + +int main() +{ + @autoreleasepool { + auto red = colorFromRGBA(255, 0, 0, 255); + auto blue = colorFromRGBA(0, 0, 255, 127); + auto green = colorFromRGBA(0, 255, 0, 255); + checkParity({}); + checkParity({{red, {}}}); + checkParity({{red, {}}, {green, {}}, {blue, {}}}); + checkParity({{red, {75, UnitType::Percent}}, {green, {25, UnitType::Percent}}, {blue, {}}}); + checkParity({{red, {-50, UnitType::Percent}}, {green, {}}, {blue, {150, UnitType::Percent}}}); + checkParity({{red, {10, UnitType::Point}}, {green, {}}, {blue, {220, UnitType::Point}}}); + // CGFloat precision matters here: Float rounding crosses the centered-hint epsilon. + checkParity({{red, {0, UnitType::Point}}, {{}, {100.5f, UnitType::Point}}, {blue, {200, UnitType::Point}}}, 200); + + // Symmetric, endpoint, near-epsilon and asymmetric transition hints. + for (float hint : {0.f, 0.49f, 0.51f, 1.f, 20.f, 49.8f, 50.f, 50.2f, 80.f, 99.49f, 99.51f, 100.f}) { + checkParity({{red, {0, UnitType::Percent}}, {{}, {hint, UnitType::Percent}}, {blue, {100, UnitType::Percent}}}); + } + checkParity( + {{red, {0, UnitType::Percent}}, + {{}, {15, UnitType::Percent}}, + {green, {40, UnitType::Percent}}, + {{}, {85, UnitType::Percent}}, + {blue, {100, UnitType::Percent}}}); + + // Retaining an original stop must retain its native dynamic color object. + SharedColor dynamicColor{Color( + DynamicColor{.lightColor = static_cast(0xffff0000), .darkColor = static_cast(0xff0000ff)})}; + auto dynamicResult = [RCTGradientUtils getFixedColorStops:{ + {dynamicColor, {}}, + {blue, {}} + } + gradientLineLength:240]; + if ((*dynamicResult[0].color).getUIColor() != (*dynamicColor).getUIColor()) { + fprintf(stderr, "KMP gradient adapter replaced an original dynamic UIColor\n"); + return 1; + } + checkParity({{dynamicColor, {}}, {blue, {}}}); + + std::mt19937 random(8675309); + for (int i = 0; i < 250; ++i) { + float hint = 1 + random() % 99; + auto left = colorFromRGBA(random() % 256, random() % 256, random() % 256, random() % 256); + auto right = colorFromRGBA(random() % 256, random() % 256, random() % 256, random() % 256); + checkParity({{left, {0, UnitType::Percent}}, {{}, {hint, UnitType::Percent}}, {right, {100, UnitType::Percent}}}); + checkParity( + {{left, {0, UnitType::Point}}, {{}, {hint * 1.7f, UnitType::Point}}, {right, {170, UnitType::Point}}}, + 237.25); + } + printf("Apple gradient parity passed: %zu cases; native colors and KMP positions/weights agree.\n", checkedCases); + } + return 0; +} diff --git a/packages/react-native/ReactShared/tests/coexistence/AppleKotlinCoexistence.mm b/packages/react-native/ReactShared/tests/coexistence/AppleKotlinCoexistence.mm new file mode 100644 index 000000000000..eeff8e3e198c --- /dev/null +++ b/packages/react-native/ReactShared/tests/coexistence/AppleKotlinCoexistence.mm @@ -0,0 +1,69 @@ +/* + * 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 +#import + +#include +#include + +extern "C" NSArray *ReactNativeSharedFixtureStops(); + +static void check(bool condition, const char *message) +{ + if (!condition) { + fprintf(stderr, "Kotlin framework coexistence failed: %s\n", message); + abort(); + } +} + +static void exercise(int32_t seed) +{ + // Each framework keeps ownership of its Kotlin objects. Only primitive values + // are copied between their APIs; these are not cast across Kotlin runtimes. + auto independent = IndependentKotlinIndependentProbe.shared; + auto token = [independent makeTokenSeed:seed]; + auto stops = ReactNativeSharedFixtureStops(); + check(stops.count == 3, "shared resolver returned the wrong number of stops"); + @autoreleasepool { + NSArray *values = @[ + [[IndependentKotlinDouble alloc] initWithDouble:stops[0].position], + [[IndependentKotlinDouble alloc] initWithDouble:stops[1].position], + [[IndependentKotlinDouble alloc] initWithDouble:stops[2].position], + ]; + check([independent sumValues:values] == 1.5, "independent framework could not use copied numeric values"); + for (int i = 0; i < 10; ++i) { + @autoreleasepool { + check(ReactNativeSharedFixtureStops().count == 3, "shared framework changed while another framework was alive"); + check( + [independent verifyTokenToken:[independent makeTokenSeed:i] seed:i], + "independent token did not round-trip"); + } + } + } + check(stops[1].position == 0.5, "retained shared result changed after inner autorelease pools drained"); + check([independent verifyTokenToken:token seed:seed], "retained independent object lost its state"); +} + +int main() +{ + @autoreleasepool { + for (int32_t i = 0; i < 100; ++i) { + @autoreleasepool { + exercise(i); + } + } + dispatch_apply(64, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^(size_t index) { + @autoreleasepool { + exercise(static_cast(index)); + } + }); + puts("Kotlin framework coexistence passed: 100 serial and 64 concurrent object-lifetime/value checks."); + } + return 0; +} diff --git a/packages/react-native/ReactShared/tests/coexistence/IndependentKotlin.kt b/packages/react-native/ReactShared/tests/coexistence/IndependentKotlin.kt new file mode 100644 index 000000000000..a54d9403a43d --- /dev/null +++ b/packages/react-native/ReactShared/tests/coexistence/IndependentKotlin.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.shared.fixture + +// Deliberately compiled as an independent framework, without ReactNativeShared. +public class IndependentToken(public val value: String) + +public object IndependentProbe { + public fun makeToken(seed: Int): IndependentToken = IndependentToken("independent-$seed") + + public fun verifyToken(token: IndependentToken, seed: Int): Boolean = + token.value == "independent-$seed" + + public fun sum(values: List): Double = values.sum() +} diff --git a/packages/react-native/ReactShared/tests/coexistence/ReactNativeRuntimeOwner.mm b/packages/react-native/ReactShared/tests/coexistence/ReactNativeRuntimeOwner.mm new file mode 100644 index 000000000000..16e7403c84d8 --- /dev/null +++ b/packages/react-native/ReactShared/tests/coexistence/ReactNativeRuntimeOwner.mm @@ -0,0 +1,20 @@ +/* + * 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 + +// Linked into the host for static React Native, or into one dedicated dylib for +// dynamic React Native. The dynamic host never links ReactNativeShared again. +extern "C" NSArray *ReactNativeSharedFixtureStops() +{ + NSArray *input = @[ + [[RNSGradientStopInput alloc] initWithPosition:nil hasColor:YES], + [[RNSGradientStopInput alloc] initWithPosition:nil hasColor:YES], + [[RNSGradientStopInput alloc] initWithPosition:nil hasColor:YES], + ]; + return [RNSGradientStops.shared resolveStops:input epsilon:0.005 useDoublePrecision:YES]; +} diff --git a/packages/react-native/ReactShared/tests/distribution/Package.swift b/packages/react-native/ReactShared/tests/distribution/Package.swift new file mode 100644 index 000000000000..f4b166522094 --- /dev/null +++ b/packages/react-native/ReactShared/tests/distribution/Package.swift @@ -0,0 +1,25 @@ +// 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. + */ + +import PackageDescription + +let package = Package( + name: "KMPDistributionProbe", + platforms: [.iOS(.v15), .macCatalyst(.v15)], + products: [.library(name: "KMPDistributionProbe", type: .dynamic, targets: ["Probe"])], + targets: [ + .binaryTarget(name: "ReactNativeShared", path: "ReactNativeShared.xcframework"), + .target( + name: "Probe", + dependencies: [.target(name: "ReactNativeShared", condition: .when(platforms: [.iOS]))], + cSettings: [.unsafeFlags(["-fobjc-arc"])], + linkerSettings: [.linkedFramework("Foundation")] + ), + .testTarget(name: "ProbeTests", dependencies: ["Probe"]), + ] +) diff --git a/packages/react-native/ReactShared/tests/distribution/Sources/Probe/Probe.m b/packages/react-native/ReactShared/tests/distribution/Sources/Probe/Probe.m new file mode 100644 index 000000000000..00cb0052e629 --- /dev/null +++ b/packages/react-native/ReactShared/tests/distribution/Sources/Probe/Probe.m @@ -0,0 +1,33 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "Probe.h" +#import + +#if !TARGET_OS_MACCATALYST +#import +#endif + +int RNSDistributionProbe(void) +{ +#if TARGET_OS_MACCATALYST + return 1; +#else + @autoreleasepool { + NSArray *input = @[ + [[RNSGradientStopInput alloc] initWithPosition:nil hasColor:YES], + [[RNSGradientStopInput alloc] initWithPosition:nil hasColor:YES], + [[RNSGradientStopInput alloc] initWithPosition:nil hasColor:YES], + ]; + NSArray *stops = [RNSGradientStops.shared resolveStops:input + epsilon:0.00001 + useDoublePrecision:YES]; + return stops.count == 3 && stops[0].position == 0 && stops[1].position == 0.5 && stops[2].position == 1 && + stops[1].leftColorIndex == 1; + } +#endif +} diff --git a/packages/react-native/ReactShared/tests/distribution/Sources/Probe/include/Probe.h b/packages/react-native/ReactShared/tests/distribution/Sources/Probe/include/Probe.h new file mode 100644 index 000000000000..05ea4b723bc9 --- /dev/null +++ b/packages/react-native/ReactShared/tests/distribution/Sources/Probe/include/Probe.h @@ -0,0 +1,8 @@ +/* + * 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. + */ + +int RNSDistributionProbe(void); diff --git a/packages/react-native/ReactShared/tests/distribution/Tests/ProbeTests/ProbeTests.swift b/packages/react-native/ReactShared/tests/distribution/Tests/ProbeTests/ProbeTests.swift new file mode 100644 index 000000000000..e116f7df5f42 --- /dev/null +++ b/packages/react-native/ReactShared/tests/distribution/Tests/ProbeTests/ProbeTests.swift @@ -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 Probe +import XCTest + +final class ProbeTests: XCTestCase { + func testPackagedGradientCalculation() { + XCTAssertEqual(RNSDistributionProbe(), 1) + } +} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index a09745bf963c..3c0c18ef6032 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -93,6 +93,19 @@ "!ReactAndroid/src/test", "ReactApple", "ReactCommon", + "ReactShared/*.gradle.kts", + "ReactShared/*.podspec", + "ReactShared/cocoapods", + "ReactShared/gradle.properties", + "ReactShared/gradle/wrapper", + "ReactShared/gradlew", + "ReactShared/gradlew.bat", + "ReactShared/README.md", + "ReactShared/scripts", + "ReactShared/src", + "ReactShared/tests", + "!ReactShared/**/__pycache__", + "!ReactShared/**/*.pyc", "README.md", "scripts/replace-rncore-version.js", "scripts/bundle.js", diff --git a/packages/react-native/scripts/cocoapods/kmp.rb b/packages/react-native/scripts/cocoapods/kmp.rb new file mode 100644 index 000000000000..e799f5a72885 --- /dev/null +++ b/packages/react-native/scripts/cocoapods/kmp.rb @@ -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. + +class ReactNativeKMPUtils + def self.configure_aggregate_xcconfig(installer) + installer.aggregate_targets.each do |aggregate_target| + aggregate_target.xcconfigs.each do |config_name, config_file| + # CocoaPods excludes the host's static pods from search-path-only test + # targets here. user_target_xcconfig does not perform that exclusion. + linked_pods = aggregate_target.build_settings(config_name).pod_targets_to_link + next unless linked_pods.any? { |pod| pod.pod_name == 'React-KMP' } + + # Dynamic RCTFabric already contains the static Kotlin runtime. + next if linked_pods.any? { |pod| pod.pod_name == 'React-RCTFabric' && pod.build_as_dynamic? } + + %w[iphoneos iphonesimulator].each do |sdk| + key = "OTHER_LDFLAGS[sdk=#{sdk}*]" + flags = config_file.attributes[key] || '$(inherited)' + unless flags.include?('-framework ReactNativeShared') + config_file.attributes[key] = "#{flags} -framework ReactNativeShared" + end + end + config_file.save_as(aggregate_target.xcconfig_path(config_name)) + end + end + end +end diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index c0c13480dadd..35cf97b2dbff 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -11,6 +11,7 @@ require_relative './cocoapods/rndependencies.rb' require_relative './cocoapods/rncore.rb' require_relative './cocoapods/fabric.rb' +require_relative './cocoapods/kmp.rb' require_relative './cocoapods/codegen.rb' require_relative './cocoapods/codegen_utils.rb' require_relative './cocoapods/utils.rb' @@ -115,6 +116,11 @@ def use_react_native! ( # Users can still turn them off and build from source by setting the environment variable to 0. ENV['RCT_USE_RN_DEP'] = ENV['RCT_USE_RN_DEP'] == '0' ? '0' : '1' ENV['RCT_USE_PREBUILT_RNCORE'] = ENV['RCT_USE_PREBUILT_RNCORE'] == '0' ? '0' : '1' + if ENV['RCT_USE_KMP'] == '1' + # The published core binaries do not contain the opt-in shared gradient adapter. + ENV['RCT_USE_PREBUILT_RNCORE'] = '0' + Pod::UI.puts 'React Native KMP pilot: building React Native core from source.' + end # Make `REMOVE_LEGACY_ARCH` enabled by default. This will build React Native # excluding the legacy arch unless the user turns this flag off explicitly. ENV['RCT_REMOVE_LEGACY_ARCH'] = ENV['RCT_REMOVE_LEGACY_ARCH'] == '0' ? '0' : '1' @@ -164,6 +170,9 @@ def use_react_native! ( rncore_pod 'RCTRequired', :path => "#{prefix}/Libraries/Required" pod 'RCTTypeSafety', :path => "#{prefix}/Libraries/TypeSafety", :modular_headers => true pod 'React', :path => "#{prefix}/" + if ENV['RCT_USE_KMP'] == '1' + pod 'React-KMP', :path => "#{prefix}/ReactShared" + end if !ReactNativeCoreUtils.build_rncore_from_source() pod 'React-Core-prebuilt', :podspec => "#{prefix}/React-Core-prebuilt.podspec", :modular_headers => true end @@ -632,6 +641,7 @@ def react_native_post_install( ReactNativePodsUtils.updateOSDeploymentTarget(installer) ReactNativePodsUtils.set_dynamic_frameworks_flags(installer) ReactNativePodsUtils.add_ndebug_flag_to_pods_in_release(installer) + ReactNativeKMPUtils.configure_aggregate_xcconfig(installer) if !ReactNativeCoreUtils.build_rncore_from_source() # The Xcode-26 SWIFT_ENABLE_EXPLICIT_MODULES=NO workaround (#53457) is removed: diff --git a/packages/react-native/settings.gradle.kts b/packages/react-native/settings.gradle.kts index 15a03c394345..61d6d86c5d12 100644 --- a/packages/react-native/settings.gradle.kts +++ b/packages/react-native/settings.gradle.kts @@ -21,6 +21,8 @@ pluginManagement { rootProject.name = "react-native-build-from-source" +includeBuild("ReactShared") { name = "react-native-shared" } + include(":packages:react-native:ReactAndroid") project(":packages:react-native:ReactAndroid").projectDir = file("ReactAndroid/") diff --git a/settings.gradle.kts b/settings.gradle.kts index 702a042a1088..f93a0eb604b8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,8 @@ include( includeBuild("packages/gradle-plugin/") +includeBuild("packages/react-native/ReactShared") { name = "react-native-shared" } + dependencyResolutionManagement { versionCatalogs { create("libs") { from(files("packages/react-native/gradle/libs.versions.toml")) } From 9e28d94bf595a03bf801cfe353a7b18d718c2531 Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 02:27:21 +0530 Subject: [PATCH 4/9] Fix hosted XCTest Kotlin runtime ownership --- packages/react-native/ReactShared/README.md | 11 +- .../ReactShared/scripts/test-apple-app.sh | 41 +++++++ .../scripts/test-cocoapods-linking.rb | 101 ++++++++++++++--- .../react-native/scripts/cocoapods/kmp.rb | 105 +++++++++++++++++- 4 files changed, 241 insertions(+), 17 deletions(-) diff --git a/packages/react-native/ReactShared/README.md b/packages/react-native/ReactShared/README.md index 9fb2393a4dfe..d415329e1ce8 100644 --- a/packages/react-native/ReactShared/README.md +++ b/packages/react-native/ReactShared/README.md @@ -44,9 +44,14 @@ Objective-C++ adapter with the existing native gradient implementation. With CocoaPods installed, `bundle exec ruby scripts/test-cocoapods-linking.rb` checks generated host and test configurations for static, dynamic, and mixed -per-target linkage. It installs local fixture pods without compiling native code -and verifies that hosted tests inherit search paths without another static Kotlin -runtime. It prints the directory containing the generated configurations. +per-target linkage. Local fixture pods cover inherited and full-pod sibling hosted +tests, with and without `TestTargetID`, including renamed and SDK-specific app products. Negative +controls cover standalone tests and non-KMP hosts with unrelated KMP app dependencies. +No native code is compiled; the check verifies framework search paths and expected +Kotlin archive links, then prints the generated configuration directory. +Host paths with unresolved architecture or SDK-version conditions are not treated +as proof that the app provides the Kotlin runtime; those configurations need explicit +validation before relying on hosted-test suppression. ## Android integration diff --git a/packages/react-native/ReactShared/scripts/test-apple-app.sh b/packages/react-native/ReactShared/scripts/test-apple-app.sh index 9e47e8c3a09d..8e25ef0c0ad5 100755 --- a/packages/react-native/ReactShared/scripts/test-apple-app.sh +++ b/packages/react-native/ReactShared/scripts/test-apple-app.sh @@ -213,6 +213,47 @@ if any(('OBJC_CLASS_$_RNSGradientStops' in item) != expected_kmp for item in sym sys.exit('error: Compiled gradient adapter selected an unexpected KMP/native implementation.') PY if [[ "$platform" == simulator ]]; then + # Hosted test bundles must resolve Kotlin classes through their app, even when + # their Podfile target independently declares the same pods. Standalone tests + # run in a different process and may link their own runtime. + python3 - "$product" "$output/shared-runtime-ownership.json" <<'PY' +import json +import pathlib +import plistlib +import subprocess +import sys + +app = pathlib.Path(sys.argv[1]) +def executable(bundle): + with (bundle / 'Info.plist').open('rb') as info: + return bundle / plistlib.load(info)['CFBundleExecutable'] + +def owns_runtime(binary): + symbols = subprocess.check_output(['xcrun', 'nm', '-gU', str(binary)], text=True) + return any(line.endswith(' _OBJC_CLASS_$_RNSBase') for line in symbols.splitlines()) + +main = executable(app) +candidates = [main, app / (main.name + '.debug.dylib')] +candidates.extend(executable(bundle) for bundle in (app / 'Frameworks').glob('*.framework')) +owners = [binary for binary in candidates if binary.exists() and owns_runtime(binary)] +if len(owners) != 1: + sys.exit(f'error: Expected one shared runtime owner in RNTester, found {owners}') +owner = owners[0] +hosted = [] +for bundle in (app / 'PlugIns').glob('*.xctest'): + if owns_runtime(executable(bundle)): + sys.exit(f'error: Hosted test bundle duplicates the app shared runtime: {bundle}') + # CocoaPods may also package an identical dynamic framework for XCTest. + for framework in (bundle / 'Frameworks').glob('*.framework'): + copy = executable(framework) + if owns_runtime(copy) and (framework.name != owner.parent.name or copy.read_bytes() != owner.read_bytes()): + sys.exit(f'error: Hosted test bundle has a different shared runtime framework: {copy}') + hosted.append(str(bundle.relative_to(app))) +pathlib.Path(sys.argv[2]).write_text(json.dumps({ + 'runtimeOwner': str(owner.relative_to(app)), + 'hostedBundlesWithoutOwnRuntime': hosted, +}, indent=2) + '\n') +PY echo "RNTester tests and simulator launch passed (${USE_FRAMEWORKS:-static libraries}); reports: $output" else echo "RNTester $platform unsigned build passed; no device tests were run. Reports: $output" diff --git a/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb index 31228b27bf3c..1d5b95f29049 100644 --- a/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb +++ b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb @@ -24,11 +24,61 @@ project = Xcodeproj::Project.new(File.join(fixture, 'Fixture.xcodeproj')) host = project.new_target(:application, 'Host', :ios, '15.1') hosted = project.new_target(:unit_test_bundle, 'HostedTests', :ios, '15.1') - project.new_target(:unit_test_bundle, 'StandaloneTests', :ios, '15.1') - hosted.add_dependency(host) - hosted.build_configurations.each do |config| - config.build_settings['TEST_HOST'] = '$(BUILT_PRODUCTS_DIR)/Host.app/Host' - config.build_settings['BUNDLE_LOADER'] = '$(TEST_HOST)' + sibling = project.new_target(:unit_test_bundle, 'SiblingHostedTests', :ios, '15.1') + without_metadata = project.new_target(:unit_test_bundle, 'SiblingWithoutMetadata', :ios, '15.1') + standalone = project.new_target(:unit_test_bundle, 'StandaloneTests', :ios, '15.1') + plain_host = project.new_target(:application, 'PlainHost', :ios, '15.1') + separate = project.new_target(:unit_test_bundle, 'TestsWithNonKMPHost', :ios, '15.1') + stale = project.new_target(:unit_test_bundle, 'StaleHostMetadataTests', :ios, '15.1') + mismatched = project.new_target(:unit_test_bundle, 'MismatchedLoaderTests', :ios, '15.1') + sdk_hosted = project.new_target(:unit_test_bundle, 'SDKHostedTests', :ios, '15.1') + unresolved = project.new_target(:unit_test_bundle, 'UnresolvedHostTests', :ios, '15.1') + renamed = project.new_target(:application, 'OriginalName', :ios, '15.1') + renamed.name = 'Renamed Host' + renamed_test = project.new_target(:unit_test_bundle, 'RenamedHostTests', :ios, '15.1') + + # Neither a mere app dependency nor an unrelated KMP app is the test's host. + [standalone, separate, stale].each { |test| test.add_dependency(host) } + sdk_hosted.add_dependency(plain_host) + unresolved.add_dependency(plain_host) + [[hosted, host], [sibling, host], [without_metadata, host], [separate, plain_host], + [stale, plain_host], [mismatched, host], [renamed_test, renamed], [sdk_hosted, host], + [unresolved, host]].each do |test, app| + test.add_dependency(app) + test.build_configurations.each do |config| + config.build_settings['TEST_HOST'] = "$(BUILT_PRODUCTS_DIR)/#{app.name}.app/#{app.name}" + config.build_settings['BUNDLE_LOADER'] = '$(TEST_HOST)' + end + end + [hosted, sibling, stale].each do |test| + (project.root_object.attributes['TargetAttributes'] ||= {})[test.uuid] = { 'TestTargetID' => host.uuid } + end + mismatched.build_configurations.each do |config| + config.build_settings['BUNDLE_LOADER'] = '$(BUILT_PRODUCTS_DIR)/PlainHost.app/PlainHost' + end + sdk_hosted.build_configurations.each do |config| + config.build_settings['TEST_HOST[sdk=iphonesimulator*]'] = '$(BUILT_PRODUCTS_DIR)/PlainHost.app/PlainHost' + end + unresolved.build_configurations.each do |config| + # An unresolved qualifier must not be overwritten by a later matching SDK key. + config.build_settings['TEST_HOST[arch=arm64]'] = '$(BUILT_PRODUCTS_DIR)/PlainHost.app/PlainHost' + config.build_settings['TEST_HOST[sdk=iphonesimulator*]'] = '$(BUILT_PRODUCTS_DIR)/Host.app/Host' + end + renamed.build_configurations.each do |config| + product_config = File.join(fixture, "products-#{config.name}.xcconfig") + File.write(product_config, <<~XCCONFIG) + #include? "Pods/Target Support Files/Pods-Renamed Host/Pods-Renamed Host.#{config.name.downcase}.xcconfig" + APP_BUNDLE_BASE = $(PROJECT_NAME) #{config.name} + XCCONFIG + config.base_configuration_reference = project.new_file(product_config) + config.build_settings['PRODUCT_NAME'] = '$(APP_BUNDLE_BASE) Product' + config.build_settings['PRODUCT_NAME[sdk=iphonesimulator*]'] = '$(APP_BUNDLE_BASE) Simulator Product' + config.build_settings['EXECUTABLE_NAME'] = '$(TARGET_NAME) Binary' + test_config = renamed_test.build_configuration_list[config.name] + test_config.build_settings['TEST_HOST'] = "\"${BUILT_PRODUCTS_DIR}/Fixture #{config.name} Product.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Renamed Host Binary\"" + test_config.build_settings['BUNDLE_LOADER'] = "$(BUILT_PRODUCTS_DIR)/Fixture #{config.name} Product.app/Renamed Host Binary" + test_config.build_settings['TEST_HOST[sdk=iphonesimulator*]'] = "${BUILT_PRODUCTS_DIR}/Fixture #{config.name} Simulator Product.app/Renamed Host Binary" + test_config.build_settings['BUNDLE_LOADER[sdk=iphonesimulator*]'] = '$(TEST_HOST)' end project.save @@ -73,12 +123,30 @@ def min_supported_versions pod 'TestSupport', :path => './TestSupport' end end - target 'StandaloneTests' do - #{"use_frameworks! :linkage => :static" if linkage == 'mixed'} - pod 'React-KMP', :path => #{shared_root.dump} - pod 'React-RCTFabric', :path => './React-RCTFabric' + #{['StandaloneTests', 'SiblingHostedTests', 'SiblingWithoutMetadata', 'Renamed Host', 'RenamedHostTests', + 'TestsWithNonKMPHost', 'StaleHostMetadataTests', 'MismatchedLoaderTests', 'SDKHostedTests', 'UnresolvedHostTests'].map do |target| + <<~TARGET + target #{target.dump} do + #{'use_frameworks! :linkage => :static' if linkage == 'mixed'} + pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-RCTFabric', :path => './React-RCTFabric' + end + TARGET + end.join} + target 'PlainHost' do + pod 'TestSupport', :path => './TestSupport' end post_install do |installer| + installer.aggregate_targets.each do |aggregate| + aggregate.xcconfigs.each_key do |configuration| + %w[iphoneos iphonesimulator].each do |sdk| + target = aggregate.user_targets.first.name + expected = %w[HostedTests SiblingHostedTests SiblingWithoutMetadata RenamedHostTests].include?(target) || (target == 'SDKHostedTests' && sdk == 'iphoneos') + actual = ReactNativeKMPUtils.test_host_links_kmp?(aggregate, configuration, installer.aggregate_targets, sdk) + raise "Wrong KMP host detection for \#{aggregate.label}/\#{configuration}/\#{sdk}: \#{actual}" unless actual == expected + end + end + end ReactNativeKMPUtils.configure_aggregate_xcconfig(installer) ReactNativeKMPUtils.configure_aggregate_xcconfig(installer) end @@ -92,21 +160,28 @@ def min_supported_versions File.write(File.join(fixture, 'pod-install.log'), output) raise "CocoaPods #{name} fixture failed: #{output}" unless status.success? - %w[Host HostedTests StandaloneTests].each do |target| + ['Host', 'HostedTests', 'SiblingHostedTests', 'SiblingWithoutMetadata', 'Renamed Host', 'RenamedHostTests', + 'StandaloneTests', 'TestsWithNonKMPHost', 'StaleHostMetadataTests', 'MismatchedLoaderTests', 'SDKHostedTests', 'UnresolvedHostTests', 'PlainHost'].each do |target| %w[debug release].each do |configuration| config_path = File.join(fixture, 'Pods', 'Target Support Files', "Pods-#{target}", "Pods-#{target}.#{configuration}.xcconfig") config = Xcodeproj::Config.new(Pathname.new(config_path)).attributes dynamic = linkage == 'dynamic' || (linkage == 'mixed' && target == 'Host') - expected = target == 'HostedTests' || dynamic ? 0 : 1 + counts = {} %w[iphoneos iphonesimulator].each do |sdk| + hosted_kmp = %w[HostedTests SiblingHostedTests SiblingWithoutMetadata RenamedHostTests].include?(target) || (target == 'SDKHostedTests' && sdk == 'iphoneos') + expected = target == 'PlainHost' || hosted_kmp || dynamic ? 0 : 1 flags = config["OTHER_LDFLAGS[sdk=#{sdk}*]"].to_s count = flags.scan('-framework ReactNativeShared').length raise "#{name}/#{target}/#{configuration}/#{sdk}: expected #{expected} Kotlin links, got #{count}" unless count == expected search_paths = config["FRAMEWORK_SEARCH_PATHS[sdk=#{sdk}*]"].to_s - raise "Missing inherited framework search path for #{name}/#{target}" unless search_paths.include?('ReactNativeSharedKMP') + if target != 'PlainHost' && !search_paths.include?('ReactNativeSharedKMP') + raise "Missing inherited framework search path for #{name}/#{target}" + end + counts[sdk] = count end raise "Unconditional Kotlin link leaks into Catalyst for #{name}/#{target}" if config['OTHER_LDFLAGS'].to_s.include?('ReactNativeShared') - results << { :linkage => name, :target => target, :configuration => configuration, :kotlin_links_per_ios_sdk => expected } + results << { :linkage => name, :target => target, :configuration => configuration, + :kotlin_links_per_ios_sdk => counts } end end end diff --git a/packages/react-native/scripts/cocoapods/kmp.rb b/packages/react-native/scripts/cocoapods/kmp.rb index e799f5a72885..4957a6d522d7 100644 --- a/packages/react-native/scripts/cocoapods/kmp.rb +++ b/packages/react-native/scripts/cocoapods/kmp.rb @@ -14,8 +14,11 @@ def self.configure_aggregate_xcconfig(installer) # Dynamic RCTFabric already contains the static Kotlin runtime. next if linked_pods.any? { |pod| pod.pod_name == 'React-RCTFabric' && pod.build_as_dynamic? } - %w[iphoneos iphonesimulator].each do |sdk| + # Full-pod sibling tests also reuse their host's runtime. Resolve each + # SDK separately because TEST_HOST and product settings can be conditional. + next if test_host_links_kmp?(aggregate_target, config_name, installer.aggregate_targets, sdk) + key = "OTHER_LDFLAGS[sdk=#{sdk}*]" flags = config_file.attributes[key] || '$(inherited)' unless flags.include?('-framework ReactNativeShared') @@ -26,4 +29,104 @@ def self.configure_aggregate_xcconfig(installer) end end end + + def self.test_host_links_kmp?(aggregate_target, config_name, aggregate_targets, sdk) + user_targets = aggregate_target.user_targets + !user_targets.empty? && user_targets.all? do |target| + next false unless target.symbol_type == :unit_test_bundle + + settings = target_settings(target, config_name, sdk) + test_host = expand_build_setting(settings['TEST_HOST'], settings) + bundle_loader = expand_build_setting(settings['BUNDLE_LOADER'], settings) + next false if test_host.to_s.empty? || bundle_loader.to_s.empty? + next false unless Pathname.new(test_host).cleanpath == Pathname.new(bundle_loader).cleanpath + + project = aggregate_target.user_project + host_uuid = project.root_object.attributes.dig('TargetAttributes', target.uuid, 'TestTargetID') + # TestTargetID is optional. A dependency is only a candidate, not evidence + # of hosting: both loader settings must identify its actual app executable. + candidates = host_uuid ? project.targets.select { |app| app.uuid == host_uuid } : target.dependencies.map(&:target).compact + hosts = candidates.select do |app| + next false unless app.project == project && app.symbol_type == :application + app_settings = target_settings(app, config_name, sdk) + executable = expand_build_setting(app_settings['EXECUTABLE_PATH'], app_settings) + next false if executable.to_s.empty? || executable.include?('$') + path = expand_build_setting("$(BUILT_PRODUCTS_DIR)/#{executable}", app_settings) + path && Pathname.new(path).cleanpath == Pathname.new(test_host).cleanpath + end + next false unless hosts.one? + + aggregate_targets.any? do |candidate| + candidate.user_project == project && candidate.user_target_uuids.include?(hosts.first.uuid) && + candidate.build_settings(config_name).pod_targets_to_link.any? { |pod| pod.pod_name == 'React-KMP' } + end + end + end + + def self.target_settings(target, config_name, sdk) + # Xcodeproj's resolved_build_setting drops unknown SDK variables and does + # not supply product defaults. Preserve those variables for exact path matching. + settings = { + 'TARGET_NAME' => target.name, 'CONFIGURATION' => config_name, + 'PROJECT_NAME' => target.project.path.basename('.xcodeproj').to_s, + 'SRCROOT' => target.project.project_dir.to_s, 'PROJECT_DIR' => target.project.project_dir.to_s, + 'PRODUCT_NAME' => '$(TARGET_NAME)', 'EXECUTABLE_PREFIX' => '', 'EXECUTABLE_SUFFIX' => '', + 'EXECUTABLE_NAME' => '$(EXECUTABLE_PREFIX)$(PRODUCT_NAME)$(EXECUTABLE_SUFFIX)', + 'WRAPPER_NAME' => '$(PRODUCT_NAME).app', 'FULL_PRODUCT_NAME' => '$(WRAPPER_NAME)', + 'EXECUTABLE_FOLDER_PATH' => '$(FULL_PRODUCT_NAME)', + 'EXECUTABLE_PATH' => '$(EXECUTABLE_FOLDER_PATH)/$(EXECUTABLE_NAME)', + 'BUNDLE_EXECUTABLE_FOLDER_PATH' => '', + 'BUILT_PRODUCTS_DIR' => '$(CONFIGURATION_BUILD_DIR)', + 'TARGET_BUILD_DIR' => '$(CONFIGURATION_BUILD_DIR)', + } + [target.project.build_configuration_list[config_name], target.build_configuration_list[config_name]].compact.each do |config| + path = config.base_configuration_reference&.real_path + xcconfig = path&.file? ? Xcodeproj::Config.new(path).to_hash : {} + [xcconfig, config.build_settings].each do |layer| + # Conditional values override their unqualified value within the layer. + uncertain = [] + layer.sort_by { |key, _| key.count('[') }.each do |key, value| + next unless value.is_a?(String) + name = key.split('[').first + conditions = key.scan(/\[([^=]+)=([^\]]+)\]/).map do |qualifier, pattern| + case qualifier + when 'config' then File.fnmatch?(pattern, config_name) + when 'sdk' + if File.fnmatch?(pattern, sdk) + true + else + # A partial/version-specific match cannot be resolved at pod install. + prefix = pattern.split(/[*?\[]/, 2).first.to_s + (sdk.start_with?(prefix) || prefix.start_with?(sdk)) ? nil : false + end + end + end + next if conditions.include?(false) + # Do not guess the host if an unsupported qualifier affects its path. + uncertain << name if conditions.include?(nil) + inherited = /\$\(inherited\)|\$\{inherited\}/ + unresolved_inheritance = settings.key?(name) && settings[name].nil? && value.match?(inherited) + settings[name] = unresolved_inheritance ? nil : value.gsub(inherited) { settings[name].to_s } + end + # Unknown precedence must not depend on the order of equally qualified keys. + uncertain.each { |name| settings[name] = nil } + end + end + settings + end + + def self.expand_build_setting(value, settings, expanding = []) + return nil if value.nil? + + value.to_s.sub(/\A(["'])(.*)\1\z/m, '\\2').gsub(/\$\((\w+)\)|\$\{(\w+)\}/) do + key = Regexp.last_match(1) || Regexp.last_match(2) + if settings.key?(key) && !expanding.include?(key) + expanded = expand_build_setting(settings[key], settings, expanding + [key]) + return nil if expanded.nil? + expanded + else + "$(#{key})" + end + end + end end From fc402a6b72034ec4e0edf2dfcc0f2f506904c4e3 Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:29:54 +0530 Subject: [PATCH 5/9] Fix multipart header separators crossing part boundaries --- .../react/devsupport/MultipartStreamReader.kt | 2 +- .../devsupport/MultipartStreamReaderTest.kt | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt index f7d4d12d4b93..a49f2c98339e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt @@ -156,7 +156,7 @@ internal class MultipartStreamReader( val marker: ByteString = ByteString.encodeUtf8(CRLF + CRLF) val indexOfMarker = content.indexOf(marker, 0) - if (indexOfMarker == -1L || indexOfMarker >= chunkLength) { + if (indexOfMarker == -1L || indexOfMarker > chunkLength - marker.size()) { // No headers marker found inside the chunk. Treat the entire chunk as body. val bodyLength = chunkLength val body = Okio.buffer(FixedLengthSource(content, bodyLength)) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt index 1ef4499184aa..205c47840911 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt @@ -10,6 +10,8 @@ package com.facebook.react.devsupport import okio.Buffer import okio.BufferedSource import okio.ByteString +import okio.ForwardingSource +import okio.Okio import org.assertj.core.api.Assertions.assertThat import org.junit.Test @@ -212,6 +214,42 @@ class MultipartStreamReaderTest { assertThat(callback.callCount).isEqualTo(1) } + @Test + fun testDelimitersAcrossEveryReadBoundary() { + // The trailing CRLF must not become a header separator spanning the next boundary. + val body = "binary\u0000\r\n--samplX\r\n--sample-\r\n" + val response = "preamble\r\n--sample\r\n${body}\r\n--sample\r\nsecond\r\n--sample--\r\nepilogue" + for (readSize in 1..response.length) { + val upstream = Buffer().writeUtf8(response) + val source = + Okio.buffer( + object : ForwardingSource(upstream) { + override fun read(sink: Buffer, byteCount: Long): Long = + super.read(sink, minOf(byteCount, readSize.toLong())) + } + ) + val parts = mutableListOf() + val last = mutableListOf() + val success = + MultipartStreamReader(source, "sample") + .readAllParts( + object : CallCountTrackingChunkCallback() { + override fun onChunkComplete( + headers: Map, + body: BufferedSource, + isLastChunk: Boolean, + ) { + parts.add(body.readUtf8()) + last.add(isLastChunk) + } + } + ) + assertThat(success).describedAs("read size %s", readSize).isTrue() + assertThat(parts).containsExactly(body, "second") + assertThat(last).containsExactly(false, true) + } + } + internal open class CallCountTrackingChunkCallback : MultipartStreamReader.ChunkListener { var callCount = 0 private set From 86eb2905eef30f3ad77b925aa9528a8a834355ac Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:39:26 +0530 Subject: [PATCH 6/9] Share multipart framing and headers through a common Kotlin runtime --- .github/workflows/test-kmp.yml | 18 ++ packages/react-native/React-Core.podspec | 18 +- .../React/Base/RCTMultipartStreamReader.m | 56 +++++- .../React/React-RCTFabric.podspec | 2 - .../react/devsupport/MultipartStreamReader.kt | 57 +++--- .../devsupport/MultipartStreamReaderTest.kt | 50 +++++ packages/react-native/ReactShared/README.md | 38 +++- .../ReactShared/React-KMP.podspec | 4 +- .../scripts/test-android-consumers.py | 58 +++++- .../scripts/test-android-multipart.py | 115 ++++++++++++ .../ReactShared/scripts/test-apple-app.sh | 25 ++- .../scripts/test-apple-multipart.sh | 90 +++++++++ .../scripts/test-cocoapods-linking.rb | 15 +- .../scripts/test-kotlin-coexistence.py | 9 + .../facebook/react/shared/MultipartFraming.kt | 79 ++++++++ .../react/shared/MultipartFramingTest.kt | 139 ++++++++++++++ .../tests/AndroidMultipartBenchmark.kt | 124 ++++++++++++ .../ReactShared/tests/AppleMultipartParity.m | 177 ++++++++++++++++++ .../react-native/scripts/cocoapods/kmp.rb | 5 +- .../RCTMultipartStreamReaderTests.m | 110 +++++++++++ 20 files changed, 1118 insertions(+), 71 deletions(-) create mode 100755 packages/react-native/ReactShared/scripts/test-android-multipart.py create mode 100755 packages/react-native/ReactShared/scripts/test-apple-multipart.sh create mode 100644 packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/MultipartFraming.kt create mode 100644 packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/MultipartFramingTest.kt create mode 100644 packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt create mode 100644 packages/react-native/ReactShared/tests/AppleMultipartParity.m diff --git a/.github/workflows/test-kmp.yml b/.github/workflows/test-kmp.yml index 62cf7911d773..fe1d3fb841ee 100644 --- a/.github/workflows/test-kmp.yml +++ b/.github/workflows/test-kmp.yml @@ -33,6 +33,11 @@ on: - 'packages/react-native/ReactCommon/react/utils/FloatComparison.h' - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' - 'packages/react-native/React/React-RCTFabric.podspec' + - 'packages/react-native/React-Core.podspec' + - 'packages/react-native/React/Base/RCTMultipartStreamReader.*' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt' + - 'packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m' - 'packages/react-native/scripts/react_native_pods.rb' - 'packages/react-native/scripts/cocoapods/kmp.rb' - 'packages/react-native/Package.swift' @@ -72,6 +77,11 @@ on: - 'packages/react-native/ReactCommon/react/utils/FloatComparison.h' - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' - 'packages/react-native/React/React-RCTFabric.podspec' + - 'packages/react-native/React-Core.podspec' + - 'packages/react-native/React/Base/RCTMultipartStreamReader.*' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt' + - 'packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m' - 'packages/react-native/scripts/react_native_pods.rb' - 'packages/react-native/scripts/cocoapods/kmp.rb' - 'packages/react-native/Package.swift' @@ -135,6 +145,12 @@ jobs: env: RCT_KMP_BUILD_TYPE: Release run: ./scripts/test-apple-gradient.sh + - name: Compare native and shared multipart adapters + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: | + ./scripts/test-apple-multipart.sh + python3 scripts/test-android-multipart.py --max-workers 2 - name: Test packaged XCFramework consumption if: ${{ !cancelled() && steps.shared.outcome == 'success' }} working-directory: packages/react-native/ReactShared @@ -159,6 +175,8 @@ jobs: path: | packages/react-native/ReactShared/build/reports/tests packages/react-native/ReactShared/build/test-results + packages/react-native/ReactShared/build/apple-multipart-test/**/*.log + packages/react-native/ReactShared/build/android-multipart-test/build/test-results packages/react-native/ReactShared/build/apple-distribution/**/*.json packages/react-native/ReactShared/build/apple-distribution/**/*.log packages/react-native/ReactShared/build/apple-distribution/**/consumer.xcresult diff --git a/packages/react-native/React-Core.podspec b/packages/react-native/React-Core.podspec index 4e5802e7035d..1e209d256306 100644 --- a/packages/react-native/React-Core.podspec +++ b/packages/react-native/React-Core.podspec @@ -7,6 +7,10 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) version = package['version'] +kmp_enabled = ENV['RCT_USE_KMP'] == '1' +if kmp_enabled && ENV['RCT_USE_PREBUILT_RNCORE'] != '0' + raise 'RCT_USE_KMP=1 requires React Native core source builds. Use use_react_native! or set RCT_USE_PREBUILT_RNCORE=0.' +end source = { :git => 'https://github.com/facebook/react-native.git' } if version == '1000.0.0' @@ -54,13 +58,25 @@ Pod::Spec.new do |s| s.compiler_flags = js_engine_flags() s.header_dir = "React" s.weak_framework = "JavaScriptCore" - s.pod_target_xcconfig = { + pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => header_search_paths, "DEFINES_MODULE" => "YES", "GCC_PREPROCESSOR_DEFINITIONS" => "RCT_METRO_PORT=${RCT_METRO_PORT}", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "FRAMEWORK_SEARCH_PATHS" => frameworks_search_paths.join(" ") } + if kmp_enabled + s.dependency 'React-KMP' + # React-Core is the common dependency of all Apple consumers. With dynamic + # pods it owns the Kotlin runtime once, even before Core calls a shared API. + # Static builds link the archive in the application via the post-install helper. + %w[iphoneos iphonesimulator].each do |sdk| + pod_target_xcconfig["GCC_PREPROCESSOR_DEFINITIONS[sdk=#{sdk}*]"] = '$(inherited) RCT_USE_KMP=1' + pod_target_xcconfig["FRAMEWORK_SEARCH_PATHS[sdk=#{sdk}*]"] = '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"' + pod_target_xcconfig["OTHER_LDFLAGS[sdk=#{sdk}*]"] = '$(inherited) -ObjC -framework ReactNativeShared' + end + end + s.pod_target_xcconfig = pod_target_xcconfig s.user_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\""} s.default_subspec = "Default" diff --git a/packages/react-native/React/Base/RCTMultipartStreamReader.m b/packages/react-native/React/Base/RCTMultipartStreamReader.m index a57b9ea944bf..0ab5a7b23a77 100644 --- a/packages/react-native/React/Base/RCTMultipartStreamReader.m +++ b/packages/react-native/React/Base/RCTMultipartStreamReader.m @@ -7,6 +7,14 @@ #import "RCTMultipartStreamReader.h" #import +#import + +#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST +#define RCT_MULTIPART_USE_KMP 1 +#import +#else +#define RCT_MULTIPART_USE_KMP 0 +#endif #define CRLF @"\r\n" @@ -30,6 +38,12 @@ - (NSDictionary *)parseHeaders:(NSData *)data { NSMutableDictionary *headers = [NSMutableDictionary new]; NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +#if RCT_MULTIPART_USE_KMP + for (RNSMultipartHeader *header in [RNSMultipartHeaders.shared parseText:text ?: @""]) { + NSString *value = [header.value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + [headers setValue:value forKey:header.name]; + } +#else NSArray *lines = [text componentsSeparatedByString:CRLF]; for (NSString *line in lines) { NSUInteger location = [line rangeOfString:@":"].location; @@ -41,6 +55,7 @@ - (NSDictionary *)parseHeaders:(NSData *)data stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; [headers setValue:value forKey:key]; } +#endif return headers; } @@ -84,13 +99,17 @@ - (void)emitProgress:(NSDictionary *)headers - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback progressCallback:(RCTMultipartProgressCallback)progressCallback { - NSInteger chunkStart = 0; - NSInteger bytesSeen = 0; - NSData *delimiter = [[NSString stringWithFormat:@"%@--%@%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding]; NSData *closeDelimiter = [[NSString stringWithFormat:@"%@--%@--%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding]; +#if RCT_MULTIPART_USE_KMP + RNSMultipartFraming *framing = [[RNSMultipartFraming alloc] initWithDelimiterLength:(int32_t)delimiter.length + closeDelimiterLength:(int32_t)closeDelimiter.length]; +#else + NSInteger chunkStart = 0; + NSInteger bytesSeen = 0; +#endif NSMutableData *content = [[NSMutableData alloc] initWithCapacity:1]; NSDictionary *currentHeaders = nil; NSUInteger currentHeadersLength = 0; @@ -101,9 +120,13 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback [_stream open]; while (true) { BOOL isCloseDelimiter = NO; - // Search only a subset of chunk that we haven't seen before + few bytes - // to allow for the edge case when the delimiter is cut by read call +#if RCT_MULTIPART_USE_KMP + NSInteger searchStart = [framing searchStartBufferOffset:0]; + NSInteger chunkStart = [framing partStartBufferOffset:0]; +#else + // Preserve overlap when a delimiter is split between reads. NSInteger searchStart = MAX(bytesSeen - (NSInteger)closeDelimiter.length, chunkStart); +#endif NSRange remainingBufferRange = NSMakeRange(searchStart, content.length - searchStart); // Check for delimiters. @@ -113,6 +136,14 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange]; } +#if RCT_MULTIPART_USE_KMP + NSInteger index = range.location == NSNotFound ? -1 : (NSInteger)range.location; + RNSMultipartChunk *chunk = [framing nextChunkBufferLength:content.length + bufferOffset:0 + delimiterIndex:isCloseDelimiter ? -1 : index + closeDelimiterIndex:isCloseDelimiter ? index : -1]; +#endif + if (range.location == NSNotFound) { if (currentHeaders == nil) { // Check for the headers delimiter. @@ -131,7 +162,9 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback callback:progressCallback]; } +#if !RCT_MULTIPART_USE_KMP bytesSeen = content.length; +#endif NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen]; if (bytesRead <= 0 || _stream.streamError) { return NO; @@ -140,12 +173,19 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback continue; } +#if RCT_MULTIPART_USE_KMP + NSInteger chunkEnd = chunk.end; + BOOL isPart = chunk.isPart; + isCloseDelimiter = chunk.isLast; +#else NSInteger chunkEnd = range.location; - NSInteger length = chunkEnd - chunkStart; + BOOL isPart = chunkStart > 0; bytesSeen = chunkEnd; +#endif + NSInteger length = chunkEnd - chunkStart; // Ignore preamble - if (chunkStart > 0) { + if (isPart) { NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)]; [self emitProgress:currentHeaders contentLength:chunk.length - currentHeadersLength @@ -160,7 +200,9 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback return YES; } +#if !RCT_MULTIPART_USE_KMP chunkStart = chunkEnd + delimiter.length; +#endif } } diff --git a/packages/react-native/React/React-RCTFabric.podspec b/packages/react-native/React/React-RCTFabric.podspec index e196aae8fdf0..92b4de666d5b 100644 --- a/packages/react-native/React/React-RCTFabric.podspec +++ b/packages/react-native/React/React-RCTFabric.podspec @@ -69,8 +69,6 @@ Pod::Spec.new do |s| 'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]' => '$(inherited) RCT_USE_KMP=1', 'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', 'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', - 'OTHER_LDFLAGS[sdk=iphoneos*]' => '$(inherited) -framework ReactNativeShared', - 'OTHER_LDFLAGS[sdk=iphonesimulator*]' => '$(inherited) -framework ReactNativeShared', }) end s.pod_target_xcconfig = pod_target_xcconfig diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt index a49f2c98339e..8e7eb1173ff8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt @@ -9,6 +9,8 @@ package com.facebook.react.devsupport +import com.facebook.react.shared.MultipartFraming +import com.facebook.react.shared.MultipartHeaders import java.io.IOException import java.util.TreeMap import kotlin.math.max @@ -52,30 +54,22 @@ internal class MultipartStreamReader( // throughput and fewer syscalls. For a 2MB bundle: ~128 reads instead of ~512. // Memory impact is negligible (12KB increase) while I/O overhead is significantly reduced. val bufferLen = 16 * 1024 - var chunkStart: Long = 0 - var bytesSeen: Long = 0 + val framing = MultipartFraming(delimiter.size(), closeDelimiter.size()) + var bufferOffset = 0L val content = Buffer() var currentHeaders: Map? = null var currentBodyStartIndexInContent: Long = -1 while (true) { - var isCloseDelimiter = false - - // Search only a subset of chunk that we haven't seen before + few bytes - // to allow for the edge case when the delimiter is cut by read call. - val searchStart = - max((bytesSeen - closeDelimiter.size()).toDouble(), chunkStart.toDouble()).toLong() - - var indexOfDelimiter = content.indexOf(delimiter, searchStart) - if (indexOfDelimiter == -1L) { - isCloseDelimiter = true - indexOfDelimiter = content.indexOf(closeDelimiter, searchStart) - } - - if (indexOfDelimiter == -1L) { - bytesSeen = content.size() - + val searchStart = framing.searchStart(bufferOffset) + val indexOfDelimiter = content.indexOf(delimiter, searchStart) + val indexOfCloseDelimiter = + if (indexOfDelimiter < 0) content.indexOf(closeDelimiter, searchStart) else -1L + val chunk = + framing.nextChunk(content.size(), bufferOffset, indexOfDelimiter, indexOfCloseDelimiter) + + if (chunk == null) { if (currentHeaders == null) { val indexOfHeadersDelimiter = content.indexOf(headersDelimiter, searchStart) if (indexOfHeadersDelimiter >= 0) { @@ -97,29 +91,27 @@ internal class MultipartStreamReader( continue } - val chunkEnd = indexOfDelimiter - val length = chunkEnd - chunkStart + val chunkEnd = chunk.end + val length = chunkEnd - chunk.start // Ignore preamble - if (chunkStart > 0) { + if (chunk.isPart) { if (currentHeaders != null && currentBodyStartIndexInContent >= 0) { val loadedFinal = max(0L, chunkEnd - currentBodyStartIndexInContent) emitProgress(currentHeaders, loadedFinal, true, listener) } - content.skip(chunkStart) - emitChunk(content, length, isCloseDelimiter, listener) + content.skip(chunk.start) + emitChunk(content, length, chunk.isLast, listener) currentHeaders = null currentBodyStartIndexInContent = -1 } else { content.skip(chunkEnd) } - if (isCloseDelimiter) { + if (chunk.isLast) { return true } - - chunkStart = delimiter.size().toLong() - bytesSeen = chunkStart + bufferOffset += chunkEnd } } @@ -127,14 +119,9 @@ internal class MultipartStreamReader( // Header names are case-insensitive val headers: MutableMap = TreeMap(String.CASE_INSENSITIVE_ORDER) val text = data.readUtf8() - val lines = text.split(CRLF.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - for (line in lines) { - val indexOfSeparator = line.indexOf(":") - if (indexOfSeparator == -1) { - continue - } - val key = line.substring(0, indexOfSeparator).trim { it <= ' ' } - val value = line.substring(indexOfSeparator + 1).trim { it <= ' ' } + for (header in MultipartHeaders.parse(text)) { + val key = header.name.trim { it <= ' ' } + val value = header.value.trim { it <= ' ' } headers[key] = value } return headers diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt index 205c47840911..2e39c56e73b5 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt @@ -250,6 +250,56 @@ class MultipartStreamReaderTest { } } + @Test + fun testHeaderWhitespaceDuplicatesAndColonValues() { + val source = + Buffer() + .writeUtf8( + "\r\n--sample\r\n X-Name : first\r\nx-name: second:extra \r\ninvalid\r\n\r\nbody\r\n--sample--\r\n" + ) + var calls = 0 + assertThat( + MultipartStreamReader(source, "sample") + .readAllParts( + object : CallCountTrackingChunkCallback() { + override fun onChunkComplete( + headers: Map, + body: BufferedSource, + isLastChunk: Boolean, + ) { + calls++ + assertThat(headers).hasSize(1) + assertThat(headers["X-Name"]).isEqualTo("second:extra") + assertThat(body.readUtf8()).isEqualTo("body") + assertThat(isLastChunk).isTrue() + } + } + ) + ) + .isTrue() + assertThat(calls).isEqualTo(1) + } + + @Test + fun testFinalProgressForFragmentedBody() { + val body = "x".repeat(64 * 1024) + val source = + Buffer() + .writeUtf8( + "\r\n--sample\r\nContent-Length: ${body.length}\r\n\r\n$body\r\n--sample--\r\n" + ) + val progress = mutableListOf>() + val callback = + object : CallCountTrackingChunkCallback() { + override fun onChunkProgress(headers: Map, loaded: Long, total: Long) { + progress.add(loaded to total) + } + } + assertThat(MultipartStreamReader(source, "sample").readAllParts(callback)).isTrue() + assertThat(callback.callCount).isEqualTo(1) + assertThat(progress.last()).isEqualTo(body.length.toLong() to body.length.toLong()) + } + internal open class CallCountTrackingChunkCallback : MultipartStreamReader.ChunkListener { var callCount = 0 private set diff --git a/packages/react-native/ReactShared/README.md b/packages/react-native/ReactShared/README.md index d415329e1ce8..a11e272a854e 100644 --- a/packages/react-native/ReactShared/README.md +++ b/packages/react-native/ReactShared/README.md @@ -1,11 +1,11 @@ -# React Native shared Kotlin gradient pilot +# React Native shared Kotlin experiments -This is the gradient use case layered on the standalone KMP foundation. The +These use cases build on the standalone KMP foundation. The foundation's unpublished compiler/interop fixture remains available with `-PreactNativeSharedSmoke=true`; its classes and reports stay under `build/smoke` and are excluded from normal shared outputs. Run `./scripts/test-apple-smoke.sh` to check that fixture through Objective-C. -The gradient implementation is the only production use case in this change. +Each use case has a separate behavior and performance review. This module shares CSS gradient stop position and transition-hint calculations between Android and iOS using Kotlin Multiplatform. It has no Compose dependency. @@ -17,6 +17,30 @@ source color indices, and interpolation weights. Platform adapters retain their existing tolerance, logarithm precision, and color-space behavior. The module does not depend on ReactAndroid, React-Core, UIKit, JNI, or the C++ renderer. +## Multipart framing + +`MultipartFraming` shares delimiter overlap, preamble and completed-part state +between Android's sliding Okio buffer and Apple's retained NSData buffer. +`MultipartHeaders` splits raw header fields; each platform keeps its existing +whitespace and key-comparison rules. Native code retains buffer searches, stream +and body ownership, callbacks and progress timing. Body bytes never cross the +Kotlin/Objective-C boundary. + +Run `./scripts/test-apple-multipart.sh` and +`python3 scripts/test-android-multipart.py` for the actual adapters' tests. +The Apple runner also compares exact callbacks/body bytes against the native +fallback and checks Catalyst. Set `RCT_KMP_BENCHMARK=1` for its optional 2–20 MiB +parser benchmark. The Android runner supports `--baseline-ref` with an explicit +native parser revision and `--benchmark`; see `--help`. Its timings and allocation +counters describe a host JVM, not Android ART or network download throughput. +Both probes exclude constructing their known input payloads. + +This use case can amortize interop over buffer reads. A C++ implementation could +also share decisions and directly search native buffers, but would add an Android +JNI interface to this currently Kotlin/Objective-C utility. Neither approach +removes platform I/O or Catalyst fallback. Measure application memory and real +bundle-download behavior before broadening adoption. + ## Build and test The standalone build uses its own Gradle wrapper and Kotlin plugin so that it @@ -86,6 +110,14 @@ builds. During the Xcode build, the support pod builds the shared static framewo for the current SDK, architecture, and configuration. The application still uses its existing Objective-C++ and UIKit rendering code. +All Apple consumers use one shared Kotlin runtime. With dynamic CocoaPods +frameworks, `React-Core` owns the static Kotlin archive and exports its Objective-C +classes to dependent pods such as Fabric. The archive is explicitly loaded so the +owner does not depend on which shared algorithm it happens to call. With static +libraries or static frameworks, the application owns the archive instead. Hosted +tests inherit their host's runtime. Adding an algorithm must not add another +framework link to its consuming pod. + For a custom Xcode configuration name that does not contain `Debug` or `Release`, set the `RCT_KMP_BUILD_TYPE` build setting to `Debug` or `Release`. diff --git a/packages/react-native/ReactShared/React-KMP.podspec b/packages/react-native/ReactShared/React-KMP.podspec index a09a4fe6e026..9a36d38ba47f 100644 --- a/packages/react-native/ReactShared/React-KMP.podspec +++ b/packages/react-native/ReactShared/React-KMP.podspec @@ -10,7 +10,7 @@ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) Pod::Spec.new do |s| s.name = 'React-KMP' s.version = package['version'] - s.summary = 'Opt-in shared Kotlin gradient algorithms for React Native.' + s.summary = 'Opt-in shared Kotlin algorithms for React Native.' s.homepage = 'https://reactnative.dev/' s.license = package['license'] s.author = 'Meta Platforms, Inc. and its affiliates' @@ -28,7 +28,7 @@ Pod::Spec.new do |s| # Link flags are added to direct consumers in react_native_post_install. # user_target_xcconfig also reaches tests that inherit only search paths. s.script_phase = { - :name => 'Build shared Kotlin gradient framework', + :name => 'Build shared Kotlin framework', :execution_position => :before_compile, :always_out_of_date => '1', :script => '"${PODS_TARGET_SRCROOT}/scripts/build-apple-framework.sh"', diff --git a/packages/react-native/ReactShared/scripts/test-android-consumers.py b/packages/react-native/ReactShared/scripts/test-android-consumers.py index ff1512df9ac4..0dd596b1974b 100644 --- a/packages/react-native/ReactShared/scripts/test-android-consumers.py +++ b/packages/react-native/ReactShared/scripts/test-android-consumers.py @@ -92,23 +92,31 @@ def inspect_aar(aar): if name.startswith("com/facebook/react/shared/") and name.endswith(".class")) required = {f"com/facebook/react/shared/{name}.class" - for name in ["GradientStops", "GradientStopInput", "ResolvedGradientStop"]} + for name in ["GradientStops", "GradientStopInput", "ResolvedGradientStop", + "MultipartFraming", "MultipartChunk", "MultipartHeaders", "MultipartHeader"]} if not required.issubset(classes) or any(count != 1 for count in classes.values()): raise AssertionError(f"Missing or duplicated shared classes in {aar}: {classes}") with tempfile.TemporaryDirectory(prefix="react-native-kmp-bytecode-") as directory: jar = Path(directory) / "classes.jar" with zipfile.ZipFile(aar) as archive: jar.write_bytes(archive.read("classes.jar")) - bytecode = subprocess.check_output( - ["javap", "-c", "-p", "-classpath", str(jar), - "com.facebook.react.uimanager.style.ColorStopUtils"], text=True) - if not re.search(r"invoke(?:static|virtual)\s+.*// Method com/facebook/react/shared/GradientStops\.resolve(?:\$default)?:", bytecode): - raise AssertionError(f"The Android adapter does not invoke shared GradientStops in {aar}") + adapters = { + "com.facebook.react.uimanager.style.ColorStopUtils": ("GradientStops.resolve",), + "com.facebook.react.devsupport.MultipartStreamReader": ( + "MultipartFraming.nextChunk", "MultipartHeaders.parse"), + } + for adapter, methods in adapters.items(): + bytecode = subprocess.check_output( + ["javap", "-c", "-p", "-classpath", str(jar), adapter], text=True) + for method in methods: + pattern = r"invoke(?:static|virtual)\s+.*// Method com/facebook/react/shared/" + re.escape(method) + r"(?:\$default)?:" + if not re.search(pattern, bytecode): + raise AssertionError(f"{adapter} does not invoke shared {method} in {aar}") return {"path": str(aar), "sha256": hashlib.sha256(aar.read_bytes()).hexdigest(), "shared_class_counts": dict(classes), "adapter_invokes_shared_resolver": True} -ACTIVITY = """ +ACTIVITY = r""" package com.facebook.react.kmp.consumer; import android.app.Activity; @@ -117,6 +125,7 @@ def inspect_aar(aar): import android.util.DisplayMetrics; import android.util.Log; import android.widget.TextView; +import com.facebook.react.devsupport.MultipartStreamReader; import com.facebook.react.uimanager.DisplayMetricsHolder; import com.facebook.react.uimanager.LengthPercentage; import com.facebook.react.uimanager.LengthPercentageType; @@ -125,6 +134,10 @@ def inspect_aar(aar): import com.facebook.react.uimanager.style.ProcessedColorStop; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.io.IOException; +import okio.Buffer; +import okio.BufferedSource; // Java deliberately exercises the packaged Android adapter through its JVM API. // No shared source files or replacement implementation are compiled into this app. @@ -148,6 +161,7 @@ def inspect_aar(aar): || hint.get(3).getColor() != Color.argb(191, 127, 0, 127)) { throw new AssertionError("Shared hint expansion or Android alpha rounding"); } + checkMultipart(); String result = "KMP consumer PASS " + BuildConfig.CONSUMER_MODE + " " + getIntent().getStringExtra("validationToken"); TextView text = new TextView(this); @@ -155,6 +169,36 @@ def inspect_aar(aar): setContentView(text); Log.i("KmpConsumer", result); } + + private static void checkMultipart() { + // The first body ends in CRLF, so a header marker can straddle the boundary. + Buffer input = new Buffer().writeUtf8( + "\r\n--sample\r\nfirst\r\n\r\n--sample\r\n" + + "content-type: text/plain\r\n\r\nsecond\r\n--sample--\r\n"); + int[] parts = {0}; + try { + boolean complete = new MultipartStreamReader(input, "sample").readAllParts( + new MultipartStreamReader.ChunkListener() { + @Override public void onChunkComplete( + Map headers, BufferedSource body, boolean last) throws IOException { + int index = parts[0]++; + String expected = index == 0 ? "first\r\n" : "second"; + if (index > 1 || !expected.equals(body.readUtf8()) || last != (index == 1)) { + throw new AssertionError("Shared multipart body or completion"); + } + if (index == 0 ? !headers.isEmpty() + : !"text/plain".equals(headers.get("CONTENT-TYPE"))) { + throw new AssertionError("Android multipart header policy"); + } + } + @Override public void onChunkProgress( + Map headers, long loaded, long total) {} + }); + if (!complete || parts[0] != 2) throw new AssertionError("Shared multipart framing"); + } catch (IOException error) { + throw new AssertionError(error); + } + } } """ diff --git a/packages/react-native/ReactShared/scripts/test-android-multipart.py b/packages/react-native/ReactShared/scripts/test-android-multipart.py new file mode 100755 index 000000000000..f48a3bfb2353 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-android-multipart.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# 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. + +"""Run the real Android multipart adapter's JVM tests without building ReactAndroid. + +An optional benchmark compares the adapter with an explicitly selected native Git +baseline. This measures a host JVM, not Android ART or network throughput. +""" + +import argparse +import json +import pathlib +import re +import shutil +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--baseline-ref", help="Git revision of the native adapter") + parser.add_argument("--benchmark", action="store_true") + parser.add_argument("--offline", action="store_true") + parser.add_argument("--max-workers", type=int, default=2) + args = parser.parse_args() + if args.benchmark and not args.baseline_ref: + parser.error("--benchmark requires an explicit --baseline-ref") + + shared = pathlib.Path(__file__).resolve().parents[1] + react_native = shared.parent + repo = react_native.parents[1] + output = (args.output or shared / "build/android-multipart-test").resolve() + output.mkdir(parents=True, exist_ok=True) + gradle = [str(shared / "gradlew"), "--console=plain", f"--max-workers={args.max_workers}"] + if args.offline: + gradle.append("--offline") + subprocess.run(gradle + ["-p", str(shared), "exportAndroidJar"], check=True) + + version = re.search(r'kotlin\("multiplatform"\) version "([^"]+)"', (shared / "build.gradle.kts").read_text()) + if not version: + raise RuntimeError("Could not find the shared module's Kotlin compiler version") + (output / "settings.gradle.kts").write_text(''' +pluginManagement { + resolutionStrategy { eachPlugin { + if (requested.id.id == "org.jetbrains.kotlin.jvm") + useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:${requested.version}") + } } + repositories { mavenCentral(); gradlePluginPortal() } +} +dependencyResolutionManagement { repositories { mavenCentral() } } +rootProject.name = "multipart-adapter-tests" +''') + (output / "build.gradle.kts").write_text(''' +plugins { kotlin("jvm") version %s } +kotlin { jvmToolchain(17) } +sourceSets { + main { kotlin.srcDir("main") } + test { kotlin.srcDir("test") } +} +dependencies { + implementation(files(%s)) + implementation("com.squareup.okio:okio:1.17.2") + testImplementation("junit:junit:4.13.2") + testImplementation("org.assertj:assertj-core:3.25.1") +} +tasks.test { testLogging { events("passed", "failed", "skipped") } } +tasks.register("benchmark") { + classpath = sourceSets.main.get().runtimeClasspath + mainClass.set("com.facebook.react.devsupport.AndroidMultipartBenchmarkKt") +} +''' % (json.dumps(version[1]), json.dumps(str(shared / "build/android/react-native-shared.jar")))) + + # Keep only this runner's generated fixture inputs when reusing an output path. + generated = { + "main": ("MultipartStreamReader.kt", "MultipartStreamReaderBaseline.kt", "AndroidMultipartBenchmark.kt"), + "test": ("MultipartStreamReaderTest.kt", "MultipartStreamReaderBaselineTest.kt"), + } + for name, files in generated.items(): + directory = output / name + directory.mkdir(exist_ok=True) + for source in files: + (directory / source).unlink(missing_ok=True) + adapter = react_native / "ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt" + tests = react_native / "ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt" + shutil.copyfile(adapter, output / "main" / adapter.name) + shutil.copyfile(tests, output / "test" / tests.name) + if args.baseline_ref: + baseline_commit = subprocess.check_output( + ["git", "rev-parse", "--verify", f"{args.baseline_ref}^{{commit}}"], cwd=repo, text=True + ).strip() + print(f"Native baseline: {baseline_commit}", flush=True) + baseline = subprocess.check_output( + ["git", "show", f"{baseline_commit}:{adapter.relative_to(repo)}"], cwd=repo, text=True + ) + if "com.facebook.react.shared" in baseline: + raise RuntimeError("The selected baseline already uses shared Kotlin code") + (output / "main/MultipartStreamReaderBaseline.kt").write_text( + baseline.replace("MultipartStreamReader", "MultipartStreamReaderBaseline") + ) + (output / "test/MultipartStreamReaderBaselineTest.kt").write_text( + tests.read_text().replace("MultipartStreamReader", "MultipartStreamReaderBaseline") + ) + if args.benchmark: + shutil.copyfile(shared / "tests/AndroidMultipartBenchmark.kt", output / "main/AndroidMultipartBenchmark.kt") + subprocess.run(gradle + ["-p", str(output), "test"], check=True) + if args.benchmark: + # Keep test compilation/execution out of the measurement window. + subprocess.run(gradle + ["-p", str(output), "benchmark"], check=True) + + +if __name__ == "__main__": + main() diff --git a/packages/react-native/ReactShared/scripts/test-apple-app.sh b/packages/react-native/ReactShared/scripts/test-apple-app.sh index 8e25ef0c0ad5..20b314937614 100755 --- a/packages/react-native/ReactShared/scripts/test-apple-app.sh +++ b/packages/react-native/ReactShared/scripts/test-apple-app.sh @@ -197,20 +197,27 @@ else fi # Check the actual object compiled by the pod target: just finding Kotlin in the -# app would not prove that RCTGradientUtils selected the shared implementation. -python3 - "$output/DerivedData" "$platform" "$output/gradient-symbols.txt" <<'PY' +# app would not prove that each adapter selected the shared implementation. +python3 - "$output/DerivedData" "$platform" "$output/shared-adapter-symbols.txt" <<'PY' import pathlib import subprocess import sys -objects = list(pathlib.Path(sys.argv[1]).rglob('RCTGradientUtils.o')) -if not objects: - sys.exit('error: The RNTester build did not compile the gradient adapter from source.') -symbols = [subprocess.check_output(['xcrun', 'nm', '-u', str(item)], text=True) for item in objects] -pathlib.Path(sys.argv[3]).write_text('\n'.join(symbols)) expected_kmp = sys.argv[2] != 'catalyst' -if any(('OBJC_CLASS_$_RNSGradientStops' in item) != expected_kmp for item in symbols): - sys.exit('error: Compiled gradient adapter selected an unexpected KMP/native implementation.') +reports = [] +for filename, classes in ( + ('RCTGradientUtils.o', ('RNSGradientStops',)), + ('RCTMultipartStreamReader.o', ('RNSMultipartFraming', 'RNSMultipartHeaders')), +): + objects = list(pathlib.Path(sys.argv[1]).rglob(filename)) + if not objects: + sys.exit(f'error: RNTester did not compile {filename} from source.') + symbols = [subprocess.check_output(['xcrun', 'nm', '-u', str(item)], text=True) for item in objects] + reports.extend([filename, *symbols]) + for class_name in classes: + if any((f'OBJC_CLASS_$_{class_name}' in item) != expected_kmp for item in symbols): + sys.exit(f'error: {filename} selected an unexpected implementation for {class_name}.') +pathlib.Path(sys.argv[3]).write_text('\n'.join(reports)) PY if [[ "$platform" == simulator ]]; then # Hosted test bundles must resolve Kotlin classes through their app, even when diff --git a/packages/react-native/ReactShared/scripts/test-apple-multipart.sh b/packages/react-native/ReactShared/scripts/test-apple-multipart.sh new file mode 100755 index 000000000000..add5815e04fb --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-apple-multipart.sh @@ -0,0 +1,90 @@ +#!/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 -euo pipefail + +shared_root="$(cd "$(dirname "$0")/.." && pwd)" +react_native_root="$(cd "$shared_root/.." && pwd)" +repo_root="$(cd "$react_native_root/../.." && pwd)" +test_root="${RCT_KMP_TEST_OUTPUT_DIR:-$shared_root/build/apple-multipart-test}" +architecture="$(uname -m)" +sdk_root="$(xcrun --sdk iphonesimulator --show-sdk-path)" +developer="$(xcode-select -p)/Platforms/iPhoneSimulator.platform/Developer" +mkdir -p "$test_root/include/React" +ln -sf "$react_native_root/React/Base/RCTMultipartStreamReader.h" "$test_root/include/React/RCTMultipartStreamReader.h" + +PLATFORM_NAME=iphonesimulator ARCHS="$architecture" CONFIGURATION=Release \ + PODS_CONFIGURATION_BUILD_DIR="$test_root" "$shared_root/scripts/build-apple-framework.sh" + +flags=( + -fobjc-arc -O2 -target "$architecture-apple-ios15.1-simulator" -isysroot "$sdk_root" + -I "$test_root/include" -F "$test_root/ReactNativeSharedKMP" +) +adapter="$react_native_root/React/Base/RCTMultipartStreamReader.m" +xcrun clang "${flags[@]}" -DRCT_USE_KMP=1 -c "$adapter" -o "$test_root/adapter.o" +xcrun clang "${flags[@]}" -DRCT_USE_KMP=0 -DRCTMultipartStreamReader=RCTMultipartStreamReaderBaseline \ + -c "$adapter" -o "$test_root/baseline.o" + +# Check that the opt-in actually calls the shared framing/header types. +nm -u "$test_root/adapter.o" | grep -q 'OBJC_CLASS_\$_RNSMultipartFraming' +nm -u "$test_root/adapter.o" | grep -q 'OBJC_CLASS_\$_RNSMultipartHeaders' +if nm -u "$test_root/baseline.o" | grep -q 'OBJC_CLASS_\$_RNS'; then + echo 'error: Native fallback unexpectedly references Kotlin classes.' >&2 + exit 1 +fi + +# Compile the actual RNTester XCTest source, then run the same tests on both paths. +for mode in native kmp; do + bundle="$test_root/Multipart-$mode.xctest" + mkdir -p "$bundle" + use_kmp=0 + if [[ "$mode" == kmp ]]; then use_kmp=1; fi + xcrun clang "${flags[@]}" -DRCT_USE_KMP="$use_kmp" -bundle \ + -F "$developer/Library/Frameworks" -framework XCTest \ + -Wl,-rpath,"$developer/Library/Frameworks" \ + "$adapter" "$repo_root/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m" \ + -framework Foundation -framework QuartzCore -framework ReactNativeShared -o "$bundle/MultipartTests" + /usr/libexec/PlistBuddy -c Clear "$bundle/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c 'Add :CFBundleExecutable string MultipartTests' "$bundle/Info.plist" + /usr/libexec/PlistBuddy -c 'Add :CFBundleIdentifier string com.facebook.react.MultipartTests' "$bundle/Info.plist" + /usr/libexec/PlistBuddy -c 'Add :CFBundlePackageType string BNDL' "$bundle/Info.plist" +done + +xcrun clang "${flags[@]}" "$shared_root/tests/AppleMultipartParity.m" \ + "$test_root/adapter.o" "$test_root/baseline.o" \ + -framework Foundation -framework QuartzCore -framework ReactNativeShared -o "$test_root/AppleMultipartParity" + +# Catalyst keeps the Foundation implementation, including when opt-in is set. +mac_sdk_root="$(xcrun --sdk macosx --show-sdk-path)" +xcrun clang -fobjc-arc -DRCT_USE_KMP=1 \ + -target "$architecture-apple-ios15.1-macabi" -isysroot "$mac_sdk_root" \ + -isystem "$mac_sdk_root/System/iOSSupport/usr/include" \ + -iframework "$mac_sdk_root/System/iOSSupport/System/Library/Frameworks" \ + -fsyntax-only "$adapter" + +if [[ "${RCT_KMP_BUILD_ONLY:-0}" == "1" ]]; then exit 0; fi +simulator="${RCT_KMP_SIMULATOR_UDID:-}" +if [[ -z "$simulator" ]]; then + simulator="$(xcrun simctl list devices available -j | python3 -c ' +import json, sys +for runtime, entries in json.load(sys.stdin)["devices"].items(): + if ".iOS-" in runtime and entries: + print(entries[0]["udid"]) + break +')" +fi +if [[ -z "$simulator" ]]; then + echo 'error: Install an iOS Simulator runtime before running multipart tests.' >&2 + exit 1 +fi +for mode in native kmp; do + xcrun simctl spawn --standalone "$simulator" "$developer/Library/Xcode/Agents/xctest" \ + "$test_root/Multipart-$mode.xctest" +done +xcrun simctl spawn --standalone "$simulator" "$test_root/AppleMultipartParity" +if [[ "${RCT_KMP_BENCHMARK:-0}" == "1" ]]; then + xcrun simctl spawn --standalone "$simulator" "$test_root/AppleMultipartParity" --benchmark +fi diff --git a/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb index 1d5b95f29049..a84e91918087 100644 --- a/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb +++ b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb @@ -23,6 +23,7 @@ FileUtils.mkdir_p(fixture) project = Xcodeproj::Project.new(File.join(fixture, 'Fixture.xcodeproj')) host = project.new_target(:application, 'Host', :ios, '15.1') + project.new_target(:application, 'CoreOnly', :ios, '15.1') hosted = project.new_target(:unit_test_bundle, 'HostedTests', :ios, '15.1') sibling = project.new_target(:unit_test_bundle, 'SiblingHostedTests', :ios, '15.1') without_metadata = project.new_target(:unit_test_bundle, 'SiblingWithoutMetadata', :ios, '15.1') @@ -82,7 +83,7 @@ end project.save - %w[React-RCTFabric TestSupport].each do |pod_name| + %w[React-Core React-RCTFabric TestSupport].each do |pod_name| pod_dir = File.join(fixture, pod_name) FileUtils.mkdir_p(pod_dir) File.write(File.join(pod_dir, 'Fixture.m'), "#import \n") @@ -97,7 +98,8 @@ s.source = { :git => 'https://example.invalid/fixture.git' } s.platform = :ios, '15.1' s.source_files = 'Fixture.m' - #{"s.dependency 'React-KMP'" if pod_name == 'React-RCTFabric'} + #{"s.dependency 'React-KMP'" if pod_name == 'React-Core'} + #{"s.dependency 'React-Core'" if pod_name == 'React-RCTFabric'} end PODSPEC end @@ -117,6 +119,7 @@ def min_supported_versions target 'Host' do #{"use_frameworks! :linkage => :dynamic" if linkage == 'mixed'} pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-Core', :path => './React-Core' pod 'React-RCTFabric', :path => './React-RCTFabric' target 'HostedTests' do inherit! :search_paths @@ -129,10 +132,16 @@ def min_supported_versions target #{target.dump} do #{'use_frameworks! :linkage => :static' if linkage == 'mixed'} pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-Core', :path => './React-Core' pod 'React-RCTFabric', :path => './React-RCTFabric' end TARGET end.join} + target 'CoreOnly' do + #{'use_frameworks! :linkage => :static' if linkage == 'mixed'} + pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-Core', :path => './React-Core' + end target 'PlainHost' do pod 'TestSupport', :path => './TestSupport' end @@ -161,7 +170,7 @@ def min_supported_versions raise "CocoaPods #{name} fixture failed: #{output}" unless status.success? ['Host', 'HostedTests', 'SiblingHostedTests', 'SiblingWithoutMetadata', 'Renamed Host', 'RenamedHostTests', - 'StandaloneTests', 'TestsWithNonKMPHost', 'StaleHostMetadataTests', 'MismatchedLoaderTests', 'SDKHostedTests', 'UnresolvedHostTests', 'PlainHost'].each do |target| + 'CoreOnly', 'StandaloneTests', 'TestsWithNonKMPHost', 'StaleHostMetadataTests', 'MismatchedLoaderTests', 'SDKHostedTests', 'UnresolvedHostTests', 'PlainHost'].each do |target| %w[debug release].each do |configuration| config_path = File.join(fixture, 'Pods', 'Target Support Files', "Pods-#{target}", "Pods-#{target}.#{configuration}.xcconfig") config = Xcodeproj::Config.new(Pathname.new(config_path)).attributes diff --git a/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py b/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py index 43d5b7802d9d..71e740b20676 100644 --- a/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py +++ b/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py @@ -65,6 +65,15 @@ def main(): sdk = subprocess.check_output(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"], text=True).strip() flags = ["-std=c++20", "-fobjc-arc", "-O2", "-target", f"{architecture}-apple-ios15.1-simulator", "-isysroot", sdk, "-F", str(shared_frameworks), "-F", str(output / "static")] + # React-Core must export the shared classes even when only dependent pods + # call them. Exercise its linker flags without any algorithm references. + empty_owner = output / "libReactNativeRuntimeOnly.dylib" + run(["xcrun", "clang++", *flags, "-dynamiclib", "-ObjC", "-framework", "ReactNativeShared", + "-framework", "Foundation", "-Wl,-dead_strip", "-o", empty_owner], output / "empty-owner-link.log") + empty_symbols = run(["xcrun", "nm", "-gU", empty_owner], output / "empty-owner-symbols.log").stdout + for class_name in ("RNSBase", "RNSGradientStops"): + if f"_OBJC_CLASS_$_{class_name}" not in empty_symbols: + raise RuntimeError(f"An owner without algorithm references did not retain {class_name}.") run(["xcrun", "clang++", *flags, "-c", fixture / "ReactNativeRuntimeOwner.mm", "-o", output / "owner.o"], output / "owner-compile.log") run(["xcrun", "clang++", *flags, "-c", fixture / "AppleKotlinCoexistence.mm", "-o", output / "host.o"], output / "host-compile.log") run(["xcrun", "clang++", *flags, "-dynamiclib", output / "owner.o", "-framework", "ReactNativeShared", "-framework", "Foundation", diff --git a/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/MultipartFraming.kt b/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/MultipartFraming.kt new file mode 100644 index 000000000000..70ec180d0390 --- /dev/null +++ b/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/MultipartFraming.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.shared + +/** A completed region in the caller's current buffer. The first region is the preamble. */ +public class MultipartChunk( + public val start: Long, + public val end: Long, + public val isPart: Boolean, + public val isLast: Boolean, +) + +/** + * Incremental multipart framing, independent of stream and buffer ownership. + * + * The caller searches its native buffer for delimiters starting at [searchStart], then passes their + * indices to [nextChunk]. After a chunk, it may discard bytes before the delimiter and advance + * bufferOffset by the same amount. Offsets let both sliding and retained buffers use the same state + * machine without copying body bytes into Kotlin. + */ +public class MultipartFraming( + private val delimiterLength: Int, + private val closeDelimiterLength: Int, +) { + private var chunkStart: Long = 0 + private var bytesSeen: Long = 0 + private var hasBoundary: Boolean = false + + /** Retain enough overlap to find a delimiter split between two reads. */ + public fun searchStart(bufferOffset: Long): Long = + maxOf(bytesSeen - closeDelimiterLength, chunkStart) - bufferOffset + + /** Start of the current part in the caller's buffer, including its headers. */ + public fun partStart(bufferOffset: Long): Long = chunkStart - bufferOffset + + /** + * Returns a completed region, or null when another read is needed. A negative index means that + * delimiter was not found. Normal delimiters take precedence, matching both adapters. + */ + public fun nextChunk( + bufferLength: Long, + bufferOffset: Long, + delimiterIndex: Long, + closeDelimiterIndex: Long, + ): MultipartChunk? { + val isClosing = delimiterIndex < 0 + val end = if (isClosing) closeDelimiterIndex else delimiterIndex + if (end < 0) { + bytesSeen = bufferOffset + bufferLength + return null + } + + val chunk = MultipartChunk(chunkStart - bufferOffset, end, hasBoundary, isClosing) + if (!isClosing) { + hasBoundary = true + chunkStart = bufferOffset + end + delimiterLength + bytesSeen = chunkStart + } + return chunk + } +} + +/** An untrimmed header. Native adapters retain their whitespace and map-key policies. */ +public class MultipartHeader(public val name: String, public val value: String) + +public object MultipartHeaders { + /** Split CRLF-delimited headers at the first colon, ignoring lines without a separator. */ + public fun parse(text: String): List = + text.split("\r\n").mapNotNull { line -> + val separator = line.indexOf(':') + if (separator < 0) null + else MultipartHeader(line.substring(0, separator), line.substring(separator + 1)) + } +} diff --git a/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/MultipartFramingTest.kt b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/MultipartFramingTest.kt new file mode 100644 index 000000000000..9e67686648de --- /dev/null +++ b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/MultipartFramingTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MultipartFramingTest { + @Test + fun framingIsIndependentOfReadBoundariesAndBufferRetention() { + val input = + "preamble\r\n--sample\r\nA: b\r\n\r\none\r\n--sample\r\ntwo\r\n--sample--\r\nepilogue" + for (readSize in 1..input.length) { + for (discard in listOf(false, true)) { + assertEquals( + listOf("A: b\r\n\r\none", "two") to true, + parse(input, "sample", readSize, discard), + "readSize=$readSize discard=$discard", + ) + } + } + } + + @Test + fun missingDelimiterDoesNotComplete() { + assertEquals(emptyList() to false, parse("no delimiter", "sample", 1, true)) + } + + @Test + fun missingClosingDelimiterDoesNotComplete() { + assertEquals(emptyList() to false, parse("\r\n--sample\r\nbody", "sample", 1, false)) + } + + @Test + fun incompleteFinalPartKeepsPreviouslyCompletedParts() { + val input = "\r\n--s\r\nfirst\r\n--s\r\nincomplete" + assertEquals(listOf("first") to false, parse(input, "s", 2, true)) + } + + @Test + fun closingDelimiterWithoutPartsOnlyDiscardsPreamble() { + assertEquals(emptyList() to true, parse("preamble\r\n--s--\r\n", "s", 1, false)) + } + + @Test + fun nearMatchesRemainInTheBody() { + val body = "binary\u0000\r\n--samplX\r\n\r\n--sample-\r\n" + val input = "\r\n--sample\r\n$body\r\n--sample--\r\n" + for (readSize in 1..input.length) { + assertEquals(listOf(body) to true, parse(input, "sample", readSize, true)) + } + } + + @Test + fun overlapStartsAtThePartUntilMoreBytesArrive() { + val framing = MultipartFraming(7, 9) + assertEquals(0L, framing.searchStart(0)) + val preamble = framing.nextChunk(7, 0, 0, -1)!! + assertFalse(preamble.isPart) + assertEquals(7L, framing.partStart(0)) + assertEquals(7L, framing.searchStart(0)) + assertNull(framing.nextChunk(30, 0, -1, -1)) + assertEquals(21L, framing.searchStart(0)) + } + + @Test + fun offsetsRemainExactBeyondIntRange() { + val framing = MultipartFraming(7, 9) + val offset = Int.MAX_VALUE.toLong() + 100 + framing.nextChunk(offset + 7, 0, offset, -1) + assertEquals(7L, framing.partStart(offset)) + assertNull(framing.nextChunk(30, offset, -1, -1)) + assertEquals(21L, framing.searchStart(offset)) + val part = framing.nextChunk(40, offset, -1, 31)!! + assertEquals(7L, part.start) + assertEquals(31L, part.end) + assertTrue(part.isPart) + assertTrue(part.isLast) + } + + @Test + fun normalDelimiterRetainsExistingSearchPrecedence() { + val framing = MultipartFraming(7, 9) + assertFalse(framing.nextChunk(40, 0, 20, 5)!!.isLast) + } + + @Test + fun headersRetainNativeWhitespaceCaseAndDuplicatePolicies() { + val headers = + MultipartHeaders.parse( + " Content-Type : text/plain:extra \r\ninvalid\r\nx:1\r\nX:2\r\n:empty\r\n" + ) + assertEquals(listOf(" Content-Type ", "x", "X", ""), headers.map { it.name }) + assertEquals(listOf(" text/plain:extra ", "1", "2", "empty"), headers.map { it.value }) + assertTrue(MultipartHeaders.parse("").isEmpty()) + } + + private fun parse( + input: String, + boundary: String, + readSize: Int, + discard: Boolean, + ): Pair, Boolean> { + val delimiter = "\r\n--$boundary\r\n" + val closeDelimiter = "\r\n--$boundary--\r\n" + val framing = MultipartFraming(delimiter.length, closeDelimiter.length) + var buffer = "" + var offset = 0L + var read = 0 + val parts = mutableListOf() + while (true) { + val start = framing.searchStart(offset).toInt() + val normal = buffer.indexOf(delimiter, start) + val close = if (normal < 0) buffer.indexOf(closeDelimiter, start) else -1 + val chunk = framing.nextChunk(buffer.length.toLong(), offset, normal.toLong(), close.toLong()) + if (chunk == null) { + if (read == input.length) return parts to false + val end = minOf(input.length, read + readSize) + buffer += input.substring(read, end) + read = end + } else { + if (chunk.isPart) parts += buffer.substring(chunk.start.toInt(), chunk.end.toInt()) + if (chunk.isLast) return parts to true + if (discard) { + buffer = buffer.substring(chunk.end.toInt()) + offset += chunk.end + } + } + } + } +} diff --git a/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt b/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt new file mode 100644 index 000000000000..207e27395403 --- /dev/null +++ b/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.devsupport + +import java.lang.management.ManagementFactory +import okio.Buffer +import okio.BufferedSource +import okio.ByteString + +// Compiled with the actual adapter and the explicitly selected, renamed Git baseline. +private fun read(native: Boolean, source: BufferedSource, capture: Boolean): Any { + var bytes = 0L + var calls = 0 + var result: ByteString? = null + fun complete(body: BufferedSource, last: Boolean) { + check(last) + calls++ + if (capture) { + result = body.readByteString() + } else { + val scratch = Buffer() + while (body.read(scratch, 8192) != -1L) { + bytes += scratch.size() + scratch.clear() + } + } + } + val success = + if (native) { + MultipartStreamReaderBaseline(source, "sample") + .readAllParts( + object : MultipartStreamReaderBaseline.ChunkListener { + override fun onChunkComplete( + headers: Map, + body: BufferedSource, + isLastChunk: Boolean, + ) = complete(body, isLastChunk) + + override fun onChunkProgress( + headers: Map, + loaded: Long, + total: Long, + ) = Unit + } + ) + } else { + MultipartStreamReader(source, "sample") + .readAllParts( + object : MultipartStreamReader.ChunkListener { + override fun onChunkComplete( + headers: Map, + body: BufferedSource, + isLastChunk: Boolean, + ) = complete(body, isLastChunk) + + override fun onChunkProgress( + headers: Map, + loaded: Long, + total: Long, + ) = Unit + } + ) + } + check(success && calls == 1) + return result ?: bytes +} + +fun main() { + val bean = ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean + check(bean.isThreadAllocatedMemorySupported) + bean.isThreadAllocatedMemoryEnabled = true + val thread = Thread.currentThread().id + for (megabytes in listOf(2, 20)) { + val size = megabytes * 1024 * 1024 + val body = ByteArray(size) { (it % 251).toByte() } + val response = + Buffer() + .apply { + writeUtf8("preamble\r\n--sample\r\nContent-Length: $size\r\n\r\n") + write(body) + writeUtf8("\r\n--sample--\r\nepilogue") + } + .readByteArray() + val nativeBody = read(true, Buffer().write(response), true) + val sharedBody = read(false, Buffer().write(response), true) + check(nativeBody == sharedBody && sharedBody == ByteString.of(*body)) + + fun measure(native: Boolean): Pair { + val source = Buffer().write(response) + val allocated = bean.getThreadAllocatedBytes(thread) + val start = System.nanoTime() + val result = read(native, source, false) + val elapsed = (System.nanoTime() - start) / 1_000_000.0 + val bytes = bean.getThreadAllocatedBytes(thread) - allocated + check(result == size.toLong()) + return elapsed to bytes + } + repeat(30) { + measure(true) + measure(false) + } + val native = mutableListOf>() + val shared = mutableListOf>() + repeat(41) { i -> + if (i % 2 == 0) { + native += measure(true) + shared += measure(false) + } else { + shared += measure(false) + native += measure(true) + } + } + val baselineMs = native.map { it.first }.sorted()[20] + val sharedMs = shared.map { it.first }.sorted()[20] + println( + """{"bytes":$size,"iterations":41,"nativeMedianMs":$baselineMs,"kmpMedianMs":$sharedMs,"ratio":${sharedMs / baselineMs},"nativeMedianAllocatedBytes":${native.map { it.second }.sorted()[20]},"kmpMedianAllocatedBytes":${shared.map { it.second }.sorted()[20]},"nativeSamplesMs":${native.map { it.first }},"kmpSamplesMs":${shared.map { it.first }}}""" + ) + } +} diff --git a/packages/react-native/ReactShared/tests/AppleMultipartParity.m b/packages/react-native/ReactShared/tests/AppleMultipartParity.m new file mode 100644 index 000000000000..df627767a9d1 --- /dev/null +++ b/packages/react-native/ReactShared/tests/AppleMultipartParity.m @@ -0,0 +1,177 @@ +/* + * 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 +#import + +@interface RCTMultipartStreamReaderBaseline : NSObject +- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary; +- (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback + progressCallback:(RCTMultipartProgressCallback)progressCallback; +@end + +@interface FragmentedMultipartStream : NSInputStream +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize; +@end + +@implementation FragmentedMultipartStream { + NSData *_data; + NSUInteger _offset; + NSUInteger _readSize; +} +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize +{ + if (self = [super init]) { + _data = data; + _readSize = readSize; + } + return self; +} +- (void)open +{ +} +- (NSError *)streamError +{ + return nil; +} +- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)length +{ + NSUInteger count = MIN(MIN(length, _readSize), _data.length - _offset); + [_data getBytes:buffer range:NSMakeRange(_offset, count)]; + _offset += count; + return count; +} +@end + +static NSDictionary *Read(Class readerClass, NSData *data, NSUInteger readSize, BOOL retainBodies) +{ + NSInputStream *stream = [[FragmentedMultipartStream alloc] initWithData:data readSize:readSize]; + RCTMultipartStreamReader *reader = [[readerClass alloc] initWithInputStream:stream boundary:@"sample"]; + NSMutableArray *parts = [NSMutableArray new]; + NSMutableArray *completedProgress = [NSMutableArray new]; + __block NSArray *progress; + BOOL success = [reader + readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *body, BOOL done) { + [parts addObject:@[ headers ?: @{}, retainBodies ? (id)body : @(body.length), @(done) ]]; + [completedProgress addObject:progress ?: @[]]; + progress = nil; + } + progressCallback:^(NSDictionary *headers, NSNumber *length, NSNumber *loaded) { + progress = @[ headers, length, loaded ]; + }]; + return @{@"success" : @(success), @"parts" : parts, @"finalProgress" : completedProgress}; +} + +static void Require(BOOL condition, NSString *message) +{ + if (!condition) { + fprintf(stderr, "FAIL: %s\n", message.UTF8String); + exit(1); + } +} + +static NSData *Response(NSUInteger size) +{ + NSMutableData *data = [[NSString stringWithFormat:@"preamble\r\n--sample\r\nContent-Length: %lu\r\n\r\n", + (unsigned long)size] dataUsingEncoding:NSUTF8StringEncoding] + .mutableCopy; + NSMutableData *body = [NSMutableData dataWithLength:size]; + // Deterministic binary content, without a valid delimiter or header separator. + uint8_t *bytes = body.mutableBytes; + for (NSUInteger i = 0; i < size; i++) + bytes[i] = (uint8_t)(i % 251); + [data appendData:body]; + [data appendData:[@"\r\n--sample--\r\nepilogue" dataUsingEncoding:NSUTF8StringEncoding]]; + return data; +} + +static double Measure(Class readerClass, NSData *data, NSUInteger size) +{ + CFTimeInterval start = CACurrentMediaTime(); + @autoreleasepool { + NSDictionary *result = Read(readerClass, data, 4096, NO); + Require([result[@"success"] boolValue], @"benchmark completion"); + Require( + [result[@"parts"] count] == 1 && [result[@"parts"][0][1] unsignedIntegerValue] == size, + @"benchmark body length"); + } + return (CACurrentMediaTime() - start) * 1000; +} + +int main(int argc, const char *argv[]) +{ + @autoreleasepool { + Class native = RCTMultipartStreamReaderBaseline.class; + Class shared = RCTMultipartStreamReader.class; + if (argc == 2 && strcmp(argv[1], "--benchmark") == 0) { + for (NSNumber *megabytes in @[ @2, @20 ]) { + NSUInteger size = megabytes.unsignedIntegerValue * 1024 * 1024; + NSData *data = Response(size); + Require([Read(native, data, 4096, YES) isEqual:Read(shared, data, 4096, YES)], @"large body exact parity"); + for (NSUInteger i = 0; i < 5; i++) { + Measure(native, data, size); + Measure(shared, data, size); + } + NSMutableArray *nativeSamples = [NSMutableArray new]; + NSMutableArray *sharedSamples = [NSMutableArray new]; + for (NSUInteger i = 0; i < 21; i++) { + if (i % 2 == 0) { + [nativeSamples addObject:@(Measure(native, data, size))]; + [sharedSamples addObject:@(Measure(shared, data, size))]; + } else { + [sharedSamples addObject:@(Measure(shared, data, size))]; + [nativeSamples addObject:@(Measure(native, data, size))]; + } + } + double baseline = [[nativeSamples sortedArrayUsingSelector:@selector(compare:)][10] doubleValue]; + double kmp = [[sharedSamples sortedArrayUsingSelector:@selector(compare:)][10] doubleValue]; + NSDictionary *result = @{ + @"bytes" : @(size), + @"iterations" : @21, + @"nativeMedianMs" : @(baseline), + @"kmpMedianMs" : @(kmp), + @"ratio" : @(kmp / baseline), + @"nativeSamplesMs" : nativeSamples, + @"kmpSamplesMs" : sharedSamples + }; + puts([[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:result options:0 error:nil] + encoding:NSUTF8StringEncoding] + .UTF8String); + } + return 0; + } + + NSArray *inputs = @[ + @"Yolo", + @"preamble\r\n--sample--\r\n", + @"\r\n--sample\r\none\r\n--sample\r\ntwo\r\n--sample--\r\nepilogue", + @"\r\n--sample\r\nX: a:b\r\nx: c\r\n invalid \r\n\r\nbody\r\n--sample--\r\n", + @"\r\n--sample\r\nfirst\r\n--sample\r\nincomplete", + @"\r\n--sample\r\nbinary\0\r\n--samplX\r\n--sample-\r\n--sample--\r\n" + ]; + NSUInteger cases = 0; + for (NSString *input in inputs) { + NSData *data = [input dataUsingEncoding:NSUTF8StringEncoding]; + for (NSUInteger readSize = 1; readSize <= data.length; readSize++) { + Require( + [Read(native, data, readSize, YES) isEqual:Read(shared, data, readSize, YES)], + [NSString stringWithFormat:@"native/KMP parity case %lu read %lu", + (unsigned long)cases, + (unsigned long)readSize]); + cases++; + } + } + for (NSNumber *size in @[ @4095, @4096, @4097, @65536 ]) { + NSData *data = Response(size.unsignedIntegerValue); + Require([Read(native, data, 4096, YES) isEqual:Read(shared, data, 4096, YES)], @"binary body/progress parity"); + cases++; + } + printf("PASS: %lu real Apple multipart adapter parity cases\n", (unsigned long)cases); + } + return 0; +} diff --git a/packages/react-native/scripts/cocoapods/kmp.rb b/packages/react-native/scripts/cocoapods/kmp.rb index 4957a6d522d7..1d4ba752a8aa 100644 --- a/packages/react-native/scripts/cocoapods/kmp.rb +++ b/packages/react-native/scripts/cocoapods/kmp.rb @@ -12,8 +12,9 @@ def self.configure_aggregate_xcconfig(installer) linked_pods = aggregate_target.build_settings(config_name).pod_targets_to_link next unless linked_pods.any? { |pod| pod.pod_name == 'React-KMP' } - # Dynamic RCTFabric already contains the static Kotlin runtime. - next if linked_pods.any? { |pod| pod.pod_name == 'React-RCTFabric' && pod.build_as_dynamic? } + # Dynamic React-Core already contains the static Kotlin runtime. Other + # consumers use their existing React-Core dependency to share that owner. + next if linked_pods.any? { |pod| pod.pod_name == 'React-Core' && pod.build_as_dynamic? } %w[iphoneos iphonesimulator].each do |sdk| # Full-pod sibling tests also reuse their host's runtime. Resolve each # SDK separately because TEST_HOST and product settings can be conditional. diff --git a/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m b/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m index 4e711d03f56e..84688e4fdaf5 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m @@ -9,6 +9,44 @@ #import +@interface RCTMultipartFragmentedInputStream : NSInputStream +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize; +@end + +@implementation RCTMultipartFragmentedInputStream { + NSData *_data; + NSUInteger _offset; + NSUInteger _readSize; +} + +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize +{ + if (self = [super init]) { + _data = data; + _readSize = readSize; + } + return self; +} + +- (void)open +{ +} + +- (NSError *)streamError +{ + return nil; +} + +- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)length +{ + NSUInteger count = MIN(MIN(length, _readSize), _data.length - _offset); + [_data getBytes:buffer range:NSMakeRange(_offset, count)]; + _offset += count; + return count; +} + +@end + @interface RCTMultipartStreamReaderTests : XCTestCase @end @@ -115,4 +153,76 @@ - (void)testNoCloseDelimiter XCTAssertEqual(count, 1); } +- (void)testDelimitersAcrossEveryReadBoundary +{ + NSString *body = @"binary\0\r\n--samplX\r\n--sample-\r\n"; + NSString *response = + [NSString stringWithFormat:@"preamble\r\n--sample\r\n%@\r\n--sample\r\nsecond\r\n--sample--\r\nepilogue", body]; + NSData *data = [response dataUsingEncoding:NSUTF8StringEncoding]; + for (NSUInteger readSize = 1; readSize <= data.length; readSize++) { + NSInputStream *stream = [[RCTMultipartFragmentedInputStream alloc] initWithData:data readSize:readSize]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:stream boundary:@"sample"]; + NSMutableArray *parts = [NSMutableArray new]; + NSMutableArray *last = [NSMutableArray new]; + BOOL success = [reader + readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, NSData *content, BOOL done) { + [parts addObject:content]; + [last addObject:@(done)]; + } + progressCallback:nil]; + XCTAssertTrue(success, @"read size %lu", (unsigned long)readSize); + XCTAssertEqualObjects( + parts, + (@[ [body dataUsingEncoding:NSUTF8StringEncoding], [@"second" dataUsingEncoding:NSUTF8StringEncoding] ])); + XCTAssertEqualObjects(last, (@[ @NO, @YES ])); + } +} + +- (void)testHeaderWhitespaceDuplicatesAndColonValues +{ + NSString *response = + @"\r\n--sample\r\n X-Name : first\r\nx-name: second:extra \r\nx-name: last:extra \r\ninvalid\r\n\r\nbody\r\n--sample--\r\n"; + NSInputStream *stream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:stream boundary:@"sample"]; + __block NSUInteger calls = 0; + BOOL success = [reader + readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *content, BOOL done) { + calls++; + // Apple keeps header names verbatim and only trims values. + XCTAssertEqualObjects(headers, (@{@" X-Name " : @"first", @"x-name" : @"last:extra"})); + XCTAssertEqualObjects(content, [@"body" dataUsingEncoding:NSUTF8StringEncoding]); + XCTAssertTrue(done); + } + progressCallback:nil]; + XCTAssertTrue(success); + XCTAssertEqual(calls, 1); +} + +- (void)testFinalProgressForFragmentedBody +{ + NSString *body = [@"" stringByPaddingToLength:64 * 1024 withString:@"x" startingAtIndex:0]; + NSString *response = [NSString stringWithFormat:@"\r\n--sample\r\nContent-Length: %lu\r\n\r\n%@\r\n--sample--\r\n", + (unsigned long)body.length, + body]; + NSInputStream *stream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:stream boundary:@"sample"]; + __block NSUInteger calls = 0; + __block NSNumber *lastLength; + __block NSNumber *lastLoaded; + BOOL success = [reader + readAllPartsWithCompletionCallback:^( + __unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) { + calls++; + } + progressCallback:^(__unused NSDictionary *headers, NSNumber *length, NSNumber *loaded) { + lastLength = length; + lastLoaded = loaded; + }]; + XCTAssertTrue(success); + XCTAssertEqual(calls, 1); + XCTAssertEqualObjects(lastLength, @(body.length)); + // Preserve Apple's existing progress accounting, including the header/body separator. + XCTAssertEqualObjects(lastLoaded, @(body.length + 4)); +} + @end From 35db533908c116548a4667ce99f37c52767c176b Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:48:48 +0530 Subject: [PATCH 7/9] Allow the regression to compile with ReactAndroid legacy Okio --- .../com/facebook/react/devsupport/MultipartStreamReaderTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt index 2e39c56e73b5..84d227da0fa3 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt @@ -215,6 +215,7 @@ class MultipartStreamReaderTest { } @Test + @Suppress("DEPRECATION_ERROR") // Match the reader's compatibility with legacy Okio. fun testDelimitersAcrossEveryReadBoundary() { // The trailing CRLF must not become a header separator spanning the next boundary. val body = "binary\u0000\r\n--samplX\r\n--sample-\r\n" From 2962f646f878d533669ba99d217f6b96fcb18f15 Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 00:52:37 +0530 Subject: [PATCH 8/9] Match multipart fixture dependencies to ReactAndroid --- packages/react-native/ReactShared/README.md | 12 ++++--- .../scripts/test-android-multipart.py | 33 +++++++++++++++---- .../scripts/test-apple-multipart.sh | 7 +++- .../tests/AndroidMultipartBenchmark.kt | 5 +-- 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/react-native/ReactShared/README.md b/packages/react-native/ReactShared/README.md index a11e272a854e..3ca57609f314 100644 --- a/packages/react-native/ReactShared/README.md +++ b/packages/react-native/ReactShared/README.md @@ -27,7 +27,11 @@ and body ownership, callbacks and progress timing. Body bytes never cross the Kotlin/Objective-C boundary. Run `./scripts/test-apple-multipart.sh` and -`python3 scripts/test-android-multipart.py` for the actual adapters' tests. +`python3 scripts/test-android-multipart.py` for the actual adapters' tests from a +repository checkout; their Android and RNTester test sources are not distributed +in the npm package. The Android runner requires Python 3.11 or later and uses the +repository's Okio, AssertJ, JUnit and Kotlin standard-library versions. Its newer +standalone compiler retains ReactAndroid's Kotlin language and API level. The Apple runner also compares exact callbacks/body bytes against the native fallback and checks Catalyst. Set `RCT_KMP_BENCHMARK=1` for its optional 2–20 MiB parser benchmark. The Android runner supports `--baseline-ref` with an explicit @@ -94,7 +98,7 @@ repository, packs the npm sources, and builds a fresh Android application for each dependency route. It checks dependency resolution, shared-class uniqueness, Debug packaging and Release shrinking. See `--help` for emulator execution and fixture preparation options. These small consumers exercise the real Android -gradient adapter; they do not replace RNTester coverage. +gradient and multipart adapters; they do not replace RNTester coverage. ## Apple opt-in @@ -155,8 +159,8 @@ python3 scripts/benchmark-kmp-build.py ``` The app script copies RNTester into an isolated sibling directory, enables KMP, -and verifies the actual compiled gradient adapter. Simulator runs execute the -RNTester test plan and launch the app; device and Catalyst runs are unsigned +and verifies the actual compiled gradient and multipart adapters. Simulator runs +execute the RNTester test plan and launch the app; device and Catalyst runs are unsigned build checks. Catalyst is enabled in the copied Podfile and application project. Each run requires fresh build outputs. It preserves RNTester's existing hosted-test topology and removes the temporary app and any test-owned servers and simulator when it finishes. diff --git a/packages/react-native/ReactShared/scripts/test-android-multipart.py b/packages/react-native/ReactShared/scripts/test-android-multipart.py index f48a3bfb2353..54dd21744f73 100755 --- a/packages/react-native/ReactShared/scripts/test-android-multipart.py +++ b/packages/react-native/ReactShared/scripts/test-android-multipart.py @@ -16,6 +16,7 @@ import re import shutil import subprocess +import tomllib def main(): @@ -32,6 +33,12 @@ def main(): shared = pathlib.Path(__file__).resolve().parents[1] react_native = shared.parent repo = react_native.parents[1] + adapter = react_native / "ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt" + tests = react_native / "ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt" + if not tests.is_file(): + parser.error("This fixture requires a React Native repository checkout; Android test sources are not included in the npm package.") + versions = tomllib.loads((react_native / "gradle/libs.versions.toml").read_text())["versions"] + language_version = ".".join(versions["kotlin"].split(".")[:2]) output = (args.output or shared / "build/android-multipart-test").resolve() output.mkdir(parents=True, exist_ok=True) gradle = [str(shared / "gradlew"), "--console=plain", f"--max-workers={args.max_workers}"] @@ -53,25 +60,39 @@ def main(): dependencyResolutionManagement { repositories { mavenCentral() } } rootProject.name = "multipart-adapter-tests" ''') + # Keep the standalone compiler compatible with its Gradle wrapper while + # matching ReactAndroid's language/API level and runtime dependencies. + (output / "gradle.properties").write_text("kotlin.stdlib.default.dependency=false\n") (output / "build.gradle.kts").write_text(''' +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + plugins { kotlin("jvm") version %s } -kotlin { jvmToolchain(17) } +kotlin { + jvmToolchain(17) + compilerOptions { + languageVersion.set(KotlinVersion.fromVersion(%s)) + apiVersion.set(KotlinVersion.fromVersion(%s)) + } +} sourceSets { main { kotlin.srcDir("main") } test { kotlin.srcDir("test") } } dependencies { implementation(files(%s)) - implementation("com.squareup.okio:okio:1.17.2") - testImplementation("junit:junit:4.13.2") - testImplementation("org.assertj:assertj-core:3.25.1") + implementation("org.jetbrains.kotlin:kotlin-stdlib:%s") + implementation("com.squareup.okio:okio:%s") + testImplementation("junit:junit:%s") + testImplementation("org.assertj:assertj-core:%s") } tasks.test { testLogging { events("passed", "failed", "skipped") } } tasks.register("benchmark") { classpath = sourceSets.main.get().runtimeClasspath mainClass.set("com.facebook.react.devsupport.AndroidMultipartBenchmarkKt") } -''' % (json.dumps(version[1]), json.dumps(str(shared / "build/android/react-native-shared.jar")))) +''' % (json.dumps(version[1]), json.dumps(language_version), json.dumps(language_version), + json.dumps(str(shared / "build/android/react-native-shared.jar")), + versions["kotlin"], versions["okio"], versions["junit"], versions["assertj"])) # Keep only this runner's generated fixture inputs when reusing an output path. generated = { @@ -83,8 +104,6 @@ def main(): directory.mkdir(exist_ok=True) for source in files: (directory / source).unlink(missing_ok=True) - adapter = react_native / "ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt" - tests = react_native / "ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt" shutil.copyfile(adapter, output / "main" / adapter.name) shutil.copyfile(tests, output / "test" / tests.name) if args.baseline_ref: diff --git a/packages/react-native/ReactShared/scripts/test-apple-multipart.sh b/packages/react-native/ReactShared/scripts/test-apple-multipart.sh index add5815e04fb..ce6bce09acb2 100755 --- a/packages/react-native/ReactShared/scripts/test-apple-multipart.sh +++ b/packages/react-native/ReactShared/scripts/test-apple-multipart.sh @@ -9,6 +9,11 @@ set -euo pipefail shared_root="$(cd "$(dirname "$0")/.." && pwd)" react_native_root="$(cd "$shared_root/.." && pwd)" repo_root="$(cd "$react_native_root/../.." && pwd)" +tests="$repo_root/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m" +if [[ ! -f "$tests" ]]; then + echo 'error: This fixture requires a React Native repository checkout with RNTester test sources.' >&2 + exit 1 +fi test_root="${RCT_KMP_TEST_OUTPUT_DIR:-$shared_root/build/apple-multipart-test}" architecture="$(uname -m)" sdk_root="$(xcrun --sdk iphonesimulator --show-sdk-path)" @@ -45,7 +50,7 @@ for mode in native kmp; do xcrun clang "${flags[@]}" -DRCT_USE_KMP="$use_kmp" -bundle \ -F "$developer/Library/Frameworks" -framework XCTest \ -Wl,-rpath,"$developer/Library/Frameworks" \ - "$adapter" "$repo_root/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m" \ + "$adapter" "$tests" \ -framework Foundation -framework QuartzCore -framework ReactNativeShared -o "$bundle/MultipartTests" /usr/libexec/PlistBuddy -c Clear "$bundle/Info.plist" >/dev/null /usr/libexec/PlistBuddy -c 'Add :CFBundleExecutable string MultipartTests' "$bundle/Info.plist" diff --git a/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt b/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt index 207e27395403..3c2f64003645 100644 --- a/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt +++ b/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt @@ -11,6 +11,7 @@ import java.lang.management.ManagementFactory import okio.Buffer import okio.BufferedSource import okio.ByteString +import okio.ByteString.Companion.toByteString // Compiled with the actual adapter and the explicitly selected, renamed Git baseline. private fun read(native: Boolean, source: BufferedSource, capture: Boolean): Any { @@ -25,7 +26,7 @@ private fun read(native: Boolean, source: BufferedSource, capture: Boolean): Any } else { val scratch = Buffer() while (body.read(scratch, 8192) != -1L) { - bytes += scratch.size() + bytes += scratch.size scratch.clear() } } @@ -88,7 +89,7 @@ fun main() { .readByteArray() val nativeBody = read(true, Buffer().write(response), true) val sharedBody = read(false, Buffer().write(response), true) - check(nativeBody == sharedBody && sharedBody == ByteString.of(*body)) + check(nativeBody == sharedBody && sharedBody == body.toByteString()) fun measure(native: Boolean): Pair { val source = Buffer().write(response) From d14ae50dc89fc1add5d273ae0cd0f632a7be5e9d Mon Sep 17 00:00:00 2001 From: Kunal Das Date: Fri, 11 Sep 2026 01:04:01 +0530 Subject: [PATCH 9/9] Pin Python for repository-version multipart validation --- .github/workflows/test-kmp.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test-kmp.yml b/.github/workflows/test-kmp.yml index fe1d3fb841ee..a8c91f29eb2e 100644 --- a/.github/workflows/test-kmp.yml +++ b/.github/workflows/test-kmp.yml @@ -102,6 +102,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' - name: Set up JDK 17 uses: actions/setup-java@v5 with: