From 6df45c65b10f076f6e1936fe3456d0d4464a0bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Kosmaty?= Date: Thu, 11 Jun 2026 12:12:08 +0200 Subject: [PATCH 01/18] [core][android] Make module gradle plugin compatible with Android Gradle Plugin 9 (#46769) --- packages/expo-modules-core/CHANGELOG.md | 1 + .../modules/plugin/ProjectConfiguration.kt | 50 +++++++++++++------ .../plugin/android/AndroidLibraryExtension.kt | 11 ++-- .../android/MavenPublicationExtension.kt | 16 +++--- 4 files changed, 46 insertions(+), 32 deletions(-) diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md index a9fadfa135ed11..cffef6c275c095 100644 --- a/packages/expo-modules-core/CHANGELOG.md +++ b/packages/expo-modules-core/CHANGELOG.md @@ -24,6 +24,7 @@ ### πŸ’‘ Others +- [Android] Make `expo-module-gradle-plugin` compatible with Android Gradle Plugin 9. ([#46769](https://github.com/expo/expo/pull/46769) by [@lukmccall](https://github.com/lukmccall)) - [iOS] `@ExpoModule` now synthesizes a `_decorateModule` that binds the module's `@JS` functions directly onto the JS object, letting the module holder skip the dynamic definition path for synthesized modules. ([#46612](https://github.com/expo/expo/pull/46612) by [@tsapeta](https://github.com/tsapeta)) - Update edge-to-edge package to call `updateEdgeToEdgeFeatureFlag` ([#46335](https://github.com/expo/expo/pull/46335) by [@zoontek](https://github.com/zoontek)) - `NativeArrayBuffer` arguments no longer copy the buffer when it's already native-backed. ([#46448](https://github.com/expo/expo/pull/46448) by [@barthap](https://github.com/barthap)) diff --git a/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/ProjectConfiguration.kt b/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/ProjectConfiguration.kt index cfc300c21ed142..99bbd1ca714d0e 100644 --- a/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/ProjectConfiguration.kt +++ b/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/ProjectConfiguration.kt @@ -2,7 +2,8 @@ package expo.modules.plugin -import com.android.build.gradle.LibraryExtension +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.variant.AndroidComponentsExtension import expo.modules.plugin.android.PublicationInfo import expo.modules.plugin.android.applyLinterOptions import expo.modules.plugin.android.applyPublishingVariant @@ -20,27 +21,31 @@ import org.gradle.internal.extensions.core.extra import java.io.File internal fun Project.applyDefaultPlugins() { - if (!plugins.hasPlugin("com.android.library")) { - plugins.apply("com.android.library") - } - if (!plugins.hasPlugin("kotlin-android")) { - plugins.apply("kotlin-android") - } - if (!plugins.hasPlugin("maven-publish")) { - plugins.apply("maven-publish") + applyPluginIfNeeded("com.android.library") + + if (!hasBuiltInKotlinSupport()) { + // AGP 9 ships built-in Kotlin support (enabled by default), so applying `kotlin-android` on top + // of it fails with "Cannot add extension with name 'kotlin', …". + applyPluginIfNeeded("kotlin-android") } + + applyPluginIfNeeded("maven-publish") } internal fun Project.applyPikaPlugin() { - if (!plugins.hasPlugin("io.github.lukmccall.pika")) { - plugins.apply("io.github.lukmccall.pika") - } - + applyPluginIfNeeded("io.github.lukmccall.pika") + val pika = extensions.getByType(PikaGradleExtension::class.java) pika.introspectableAnnotation("expo.modules.kotlin.types.OptimizedRecord") pika.introspectableAnnotation("expo.modules.kotlin.views.OptimizedComposeProps") } +private fun Project.applyPluginIfNeeded(id: String) { + if (!plugins.hasPlugin(id)) { + plugins.apply(id) + } +} + internal fun Project.configurePika(shouldBeEnabled: Boolean = true) { val pika = extensions.getByType(PikaGradleExtension::class.java) pika.enabled = shouldBeEnabled @@ -72,9 +77,7 @@ internal fun Project.applyDefaultAndroidSdkVersions() { compileSdk = rootProject.extra.safeGet("compileSdkVersion") ?: logger.warnIfNotDefined("compileSdkVersion", 36), minSdk = rootProject.extra.safeGet("minSdkVersion") - ?: logger.warnIfNotDefined("minSdkVersion", 24), - targetSdk = rootProject.extra.safeGet("targetSdkVersion") - ?: logger.warnIfNotDefined("targetSdkVersion", 36) + ?: logger.warnIfNotDefined("minSdkVersion", 24) ) applyLinterOptions() } @@ -119,6 +122,21 @@ internal fun Project.applyPublishing(expoModulesExtension: ExpoModuleExtension) } } +private const val AGP_BUILT_IN_KOTLIN_MAJOR = 9 + +/** + * Whether AGP's built-in Kotlin support is active, meaning the `kotlin-android` plugin must not be + * applied. True on AGP 9+ unless the project explicitly opts out with `android.builtInKotlin=false`. + */ +internal fun Project.hasBuiltInKotlinSupport(): Boolean { + val androidComponents = extensions.findByType(AndroidComponentsExtension::class.java) + ?: return false + if (androidComponents.pluginVersion.major < AGP_BUILT_IN_KOTLIN_MAJOR) { + return false + } + return findProperty("android.builtInKotlin")?.toString()?.toBoolean() ?: true +} + internal fun Project.androidLibraryExtension() = extensions.getByType(LibraryExtension::class.java) internal fun Project.publishingExtension() = extensions.getByType(PublishingExtension::class.java) diff --git a/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/AndroidLibraryExtension.kt b/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/AndroidLibraryExtension.kt index 9bfae8d7961d6f..0de0f0ec998c3a 100644 --- a/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/AndroidLibraryExtension.kt +++ b/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/AndroidLibraryExtension.kt @@ -1,22 +1,21 @@ package expo.modules.plugin.android -import com.android.build.gradle.LibraryExtension +import com.android.build.api.dsl.LibraryExtension -internal fun LibraryExtension.applySDKVersions(compileSdk: Int, minSdk: Int, targetSdk: Int) { +internal fun LibraryExtension.applySDKVersions(compileSdk: Int, minSdk: Int) { this.compileSdk = compileSdk defaultConfig { this@defaultConfig.minSdk = minSdk - this@defaultConfig.targetSdk = targetSdk } } internal fun LibraryExtension.applyLinterOptions() { - lintOptions.isAbortOnError = false + lint.abortOnError = false } internal fun LibraryExtension.applyPublishingVariant() { - publishing { publishing -> - publishing.singleVariant("release") { + publishing { + singleVariant("release") { withSourcesJar() } } diff --git a/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/MavenPublicationExtension.kt b/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/MavenPublicationExtension.kt index f0ce04eab97719..022886b8bebaea 100644 --- a/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/MavenPublicationExtension.kt +++ b/packages/expo-modules-core/expo-module-gradle-plugin/src/main/kotlin/expo/modules/plugin/android/MavenPublicationExtension.kt @@ -23,9 +23,7 @@ import org.gradle.api.component.SoftwareComponent import org.gradle.api.publish.PublicationContainer import org.gradle.api.publish.maven.MavenPublication import org.gradle.api.tasks.TaskProvider -import java.nio.file.Path -import kotlin.io.path.exists -import kotlin.io.path.toPath +import java.io.File internal data class PublicationInfo( val components: SoftwareComponent, @@ -41,17 +39,15 @@ internal data class PublicationInfo( artifactId = requireNotNull(project.androidLibraryExtension().namespace) { "'android.namespace' is not defined" }, - version = requireNotNull(project.androidLibraryExtension().defaultConfig.versionName) { - "'android.defaultConfig.versionName' is not defined" + version = requireNotNull(project.version.toString().takeUnless { it == "unspecified" }) { + "'project.version' is not defined. Set `version = \"\"` in the module's android/build.gradle." }, ) - fun resolvePath(repositoryPath: Path): Path { + fun resolvePath(repositoryPath: File): File { val groupPath = groupId.replace('.', '/') val artifactPath = "$groupPath/$artifactId/$version" - val publicationPath = repositoryPath.resolve(artifactPath) - - return publicationPath + return File(repositoryPath, artifactPath) } override fun toString(): String { @@ -180,7 +176,7 @@ private fun Project.expoPublishBody(publicationInfo: PublicationInfo, expoModule if (pathToRepository == null) { val mavenLocal = publishingExtension().repositories.mavenLocal() - val mavenLocalPath = mavenLocal.url.toPath() + val mavenLocalPath = File(mavenLocal.url) val publicationPath = publicationInfo.resolvePath(mavenLocalPath) if (!publicationPath.exists()) { From 8ccf9877bd39fb1cd5eeb54de33f7b4849c58e10 Mon Sep 17 00:00:00 2001 From: Tomasz Sapeta Date: Thu, 11 Jun 2026 12:53:41 +0200 Subject: [PATCH 02/18] [jsi] Cache the runtime as `IRuntime` on the argument-decode hot path (#46792) --- packages/expo-modules-jsi/CHANGELOG.md | 1 + .../Runtime/JavaScriptRuntime.swift | 3 +- .../Runtime/JavaScriptValuesBuffer.swift | 11 ++++++-- .../Values/JavaScriptUnownedValue.swift | 28 +++++++++++++------ .../Tests/JavaScriptUnownedValueTests.swift | 2 +- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/expo-modules-jsi/CHANGELOG.md b/packages/expo-modules-jsi/CHANGELOG.md index 25f8543e177026..6805efef2d34eb 100644 --- a/packages/expo-modules-jsi/CHANGELOG.md +++ b/packages/expo-modules-jsi/CHANGELOG.md @@ -23,6 +23,7 @@ ### πŸ’‘ Others +- [iOS] `JavaScriptUnownedValue` and `JavaScriptValuesBuffer` now cache the runtime as the immortal `facebook.jsi.IRuntime` instead of the ARC-managed `JavaScriptRuntime` wrapper, removing per-call retain/release on the argument-decode hot path (measured ~16% faster `addNumbers`, ~24% faster `addStrings`). ([#46678](https://github.com/expo/expo/pull/46678) by [@tsapeta](https://github.com/tsapeta)) - `NativeArrayBuffer` arguments no longer copy the buffer when it's already native-backed. ([#46448](https://github.com/expo/expo/pull/46448) by [@barthap](https://github.com/barthap)) - [iOS] `JavaScriptNativeState` can now back any `jsi::NativeState` subtype via a `void *` factory, so consumers without Swift/C++ interop (e.g. `expo-modules-core`) can supply their own pointee. `expo::NativeState` ships from the xcframework as a public C++ header. ([#46330](https://github.com/expo/expo/pull/46330) by [@tsapeta](https://github.com/tsapeta)) - [iOS] Ignore already-settled promises. ([#46765](https://github.com/expo/expo/pull/46765) by [@jakex7](https://github.com/jakex7)) diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift index 70232d7e67ccbe..bf28c74ba687ea 100644 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift @@ -213,7 +213,8 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable { let context = Unmanaged.passRetained(HostObjectContext(runtime: self, get, set, getPropertyNames, dealloc)) .toOpaque() - let setterPointer: (@convention(c) (UnsafeMutableRawPointer, UnsafePointer, UnsafeMutableRawPointer) -> Void)? = setter + let setterPointer: + (@convention(c) (UnsafeMutableRawPointer, UnsafePointer, UnsafeMutableRawPointer) -> Void)? = setter // Pass a null setter to C++ when the Swift setter is nil so that JS assignment // raises a `jsi::JSError` directly, without crossing the Swift boundary. let callbacks = expo.HostObjectCallbacks( diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift index fcd23aa6d842ac..19b218f047a48d 100644 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptValuesBuffer.swift @@ -8,6 +8,12 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable { // so the runtime is always alive while the buffer exists. internal unowned let runtime: JavaScriptRuntime + // The raw `facebook.jsi.IRuntime`, cached alongside the `JavaScriptRuntime` wrapper. `IRuntime` is + // an immortal reference (`jsi.apinotes`), so reading it costs no ARC, whereas reading `.pointee` + // off the `unowned` wrapper emits an unowned retain/release on every access. The hot decode path + // (`unownedValue(at:)`, `set`) reads this; `subscript`/`copy` still need the wrapper. + internal nonisolated(unsafe) let iRuntime: facebook.jsi.IRuntime + internal nonisolated(unsafe) let bufferPointer: UnsafeMutableBufferPointer private let ownsMemory: Bool @@ -34,6 +40,7 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable { ownsMemory: Bool = false ) { self.runtime = runtime + self.iRuntime = runtime.pointee self.bufferPointer = buffer self.ownsMemory = ownsMemory } @@ -69,7 +76,7 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable { /// `baseAddress`, so passing any index into an empty buffer crashes, and an out-of-range index reads past /// the buffer. The caller is responsible for the bounds check. public func unownedValue(at index: Int) -> JavaScriptUnownedValue { - return JavaScriptUnownedValue(runtime, bufferPointer.baseAddress! + index) + return JavaScriptUnownedValue(iRuntime, bufferPointer.baseAddress! + index) } @discardableResult @@ -78,7 +85,7 @@ public struct JavaScriptValuesBuffer: JavaScriptType, ~Copyable { guard (0.. class refs, there is no trap on use-after-free. The contract is "valid only within the synchronous /// > decode call, while the owner is alive" β€” which the buffer-driven decode path honors because it /// > reads the value inline on the JS thread before the buffer is torn down. Do not store, capture, or -/// > escape it; call ``copied()`` to materialize an owning value when escape is needed. +/// > escape it; call ``copied(in:)`` to materialize an owning value when escape is needed. public struct JavaScriptUnownedValue: ~Copyable { // Borrows the `jsi::Value` at this address; it does not own it and must not outlive the owner. internal let pointer: UnsafePointer - // Non-optional `unowned`, matching `JavaScriptValuesBuffer.runtime`: it is strictly call-scoped and - // cannot outlive the runtime executing the call, so we skip both the ARC traffic and the optional unwrap - // that the owning `JavaScriptValue` pays. - internal unowned let runtime: JavaScriptRuntime + // The JSI runtime, stored as the raw `facebook.jsi.IRuntime` rather than the `JavaScriptRuntime` + // wrapper. `IRuntime` is imported as an immortal reference (see `jsi.apinotes`), so storing and + // copying it emits no ARC, unlike a `JavaScriptRuntime` field whose every per-call access pays an + // unowned retain/release. Only `getString` reads it; the type checks and numeric accessors touch + // only `pointer`. + internal let runtime: facebook.jsi.IRuntime - internal init(_ runtime: JavaScriptRuntime, _ pointer: UnsafePointer) { + internal init(_ runtime: facebook.jsi.IRuntime, _ pointer: UnsafePointer) { self.runtime = runtime self.pointer = pointer } /// Materializes an owning ``JavaScriptValue`` by copying the borrowed `jsi::Value`. Use it when the - /// value must outlive the decode call (stored, captured, handed to a `Promise`). - public func copied() -> JavaScriptValue { + /// value must outlive the decode call (stored, captured, handed to a `Promise`). Takes the + /// `JavaScriptRuntime` wrapper since the owning value needs it; the caller has it in scope. + /// + /// `runtime` must be the runtime that owns the borrowed value. Copying a reference value (string, + /// object, function, array, bigint) clones its `PointerValue` against `runtime`, so passing a + /// different runtime would clone a handle from another heap and corrupt it; the assert guards that. + public func copied(in runtime: JavaScriptRuntime) -> JavaScriptValue { + assert( + Unmanaged.passUnretained(runtime.pointee).toOpaque() == Unmanaged.passUnretained(self.runtime).toOpaque(), + "`copied(in:)` must be passed the runtime that owns the borrowed value") return JavaScriptValue(runtime, pointer.pointee) } @@ -98,7 +108,7 @@ public struct JavaScriptUnownedValue: ~Copyable { /// Returns the value as a string, or asserts if not a string. public func getString() -> String { assert(isString(), "Value is not a string") - return String(pointer.pointee.getString(runtime.pointee).utf8(runtime.pointee)) + return String(pointer.pointee.getString(runtime).utf8(runtime)) } // MARK: - Throwing conversions ("as functions") diff --git a/packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift b/packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift index f5c1924bdc60da..c9c85b7741ab82 100644 --- a/packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift +++ b/packages/expo-modules-jsi/apple/Tests/JavaScriptUnownedValueTests.swift @@ -61,7 +61,7 @@ struct JavaScriptUnownedValueTests { @Test func `copied materializes an owning value`() throws { let buffer = JavaScriptValuesBuffer.allocate(in: runtime, with: "owned") - let owning = buffer.unownedValue(at: 0).copied() + let owning = buffer.unownedValue(at: 0).copied(in: runtime) #expect(try owning.asString() == "owned") } From ae7636db62ca0a43993875b4c93961b38b85d1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Thu, 11 Jun 2026 16:48:20 +0530 Subject: [PATCH 03/18] [docs][ui] Add scrollable content examples to BottomSheet docs (#46786) ## Why The universal and community (drop-in) `BottomSheet` docs had no scrollable content example, unlike the swift-ui and jetpack-compose docs. ## How Added a scrollable content section to both. Uses a React Native `FlatList` with `nestedScrollEnabled`, wrapped in `RNHostView` for the universal sheet (the community sheet wraps children in `RNHostView` internally). ## Test Plan Docs-only. Examples verified against the component APIs. --- .../ui/drop-in-replacements/bottomsheet.mdx | 33 +++++++++++++++++ .../sdk/ui/universal/bottomsheet.mdx | 36 +++++++++++++++++++ .../ui/drop-in-replacements/bottomsheet.mdx | 33 +++++++++++++++++ .../v56.0.0/sdk/ui/universal/bottomsheet.mdx | 36 +++++++++++++++++++ 4 files changed, 138 insertions(+) diff --git a/docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx b/docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx index b9607a8e8dd356..bd87b804159a66 100644 --- a/docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx @@ -122,6 +122,39 @@ export default function DynamicBottomSheetExample() { } ``` +### Scrollable React Native content + +The bottom sheet supports a React Native `FlatList` or `ScrollView` (or a high-performance list like [FlashList](https://shopify.github.io/flash-list/) or [Legend List](https://github.com/LegendApp/legend-list)) as a child for scrollable content. With `nestedScrollEnabled`, the list scrolls its own content first. Once it reaches the top edge, the remaining drag moves the sheet. `BottomSheetFlatList` and `BottomSheetScrollView` are also exported for `@gorhom/bottom-sheet` compatibility, but they are plain re-exports of the React Native components. + +```tsx BottomSheetScrollableExample.tsx +import { useRef } from 'react'; +import { Button, FlatList, Text, View } from 'react-native'; +import BottomSheet from '@expo/ui/community/bottom-sheet'; + +const DATA = Array.from({ length: 50 }, (_, i) => `Item ${i + 1}`); + +export default function BottomSheetScrollableExample() { + const sheetRef = useRef(null); + + return ( + +