From 9c9423ecdd3bf2c98f75a8368c6a486369343f4e Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Tue, 18 Aug 2026 06:08:50 -0400 Subject: [PATCH 1/9] [android][secure-store] Add confirmation option (#48556) # Why Android biometric prompts default to requiring an explicit confirmation after a successful passive biometric match. Some applications need the platform-supported implicit-authentication path while retaining `BIOMETRIC_STRONG`, the existing `CryptoObject`, and authenticated keystore access. # How Adds an Android-only `requireConfirmation` SecureStore option, defaulting to `true`. The option is applied only when constructing the current `BiometricPrompt`; it is not persisted and does not change key aliases, key generation, authentication strength, invalidation, or iOS behavior. Reads use the stored authentication requirement together with the current call's confirmation preference. The native-component fixture exposes the option for both sync and async reads/writes. # Test Plan Automated: - `pnpm exec turbo build --filter=expo-secure-store` - `pnpm test` in `packages/expo-secure-store` (Android and iOS; 37 tests / 25 snapshots) - `pnpm typecheck` in `packages/expo-secure-store` - `pnpm lint` in `packages/expo-secure-store` - `pnpm exec et check-packages expo-secure-store` - `./gradlew :expo-secure-store:testDebugUnitTest` in `apps/bare-expo/android` Manual fixture coverage is available in the SecureStore native-component screen: enable authentication and compare `requireConfirmation` enabled/disabled for sync and async set/get on an Android device with passive biometrics. Android may override implicit authentication based on device or policy. On iOS the option is accepted but ignored, preserving the existing Keychain prompt behavior. # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) Co-authored-by: Brent Vatne --- .../src/screens/SecureStoreScreen.tsx | 44 ++++++-------- packages/expo-secure-store/CHANGELOG.md | 2 + .../expo-secure-store/android/build.gradle | 2 + .../securestore/AuthenticationHelper.kt | 15 +++-- .../securestore/AuthenticationPrompt.kt | 22 +++++-- .../modules/securestore/SecureStoreModule.kt | 7 ++- .../modules/securestore/SecureStoreOptions.kt | 17 +++++- .../securestore/encryptors/AESEncryptor.kt | 14 +++-- .../encryptors/HybridAESEncryptor.kt | 3 +- .../encryptors/KeyBasedEncryptor.kt | 3 +- .../securestore/AuthenticationPromptTest.kt | 44 ++++++++++++++ .../securestore/SecureStoreOptionsTest.kt | 51 ++++++++++++++++ packages/expo-secure-store/src/SecureStore.ts | 9 +++ .../src/__tests__/SecureStore-test.native.ts | 59 +++++++++++++++++++ 14 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 packages/expo-secure-store/android/src/test/java/expo/modules/securestore/AuthenticationPromptTest.kt create mode 100644 packages/expo-secure-store/android/src/test/java/expo/modules/securestore/SecureStoreOptionsTest.kt diff --git a/apps/native-component-list/src/screens/SecureStoreScreen.tsx b/apps/native-component-list/src/screens/SecureStoreScreen.tsx index 8c338b8ec03bac..45009cb745e35c 100644 --- a/apps/native-component-list/src/screens/SecureStoreScreen.tsx +++ b/apps/native-component-list/src/screens/SecureStoreScreen.tsx @@ -37,6 +37,7 @@ function SecureStoreView() { const [value, setValue] = React.useState(); const [service, setService] = React.useState(); const [requireAuth, setRequireAuth] = React.useState(false); + const [requireConfirmation, setRequireConfirmation] = React.useState(true); const [byteSize, setByteSize] = React.useState('4096'); const storeOptions = React.useMemo( @@ -44,17 +45,14 @@ function SecureStoreView() { keychainService: service, requireAuthentication: requireAuth, authenticationPrompt: requireAuth ? 'Authenticate' : undefined, + ...(Platform.OS === 'android' ? { requireConfirmation } : {}), }), - [requireAuth, service] + [requireAuth, requireConfirmation, service] ); async function storeValueAsync(value: string, key: string) { try { - await SecureStore.setItemAsync(key, value, { - keychainService: service, - requireAuthentication: requireAuth, - authenticationPrompt: 'Authenticate', - }); + await SecureStore.setItemAsync(key, value, storeOptions); Alert.alert('Success!', 'Value: ' + value + ', stored successfully for key: ' + key, [ { text: 'OK', onPress: () => {} }, ]); @@ -65,11 +63,7 @@ function SecureStoreView() { function storeValue(value: string, key: string) { try { - SecureStore.setItem(key, value, { - keychainService: service, - requireAuthentication: requireAuth, - authenticationPrompt: 'Authenticate', - }); + SecureStore.setItem(key, value, storeOptions); Alert.alert('Success!', 'Value: ' + value + ', stored successfully for key: ' + key, [ { text: 'OK', onPress: () => {} }, ]); @@ -80,11 +74,7 @@ function SecureStoreView() { async function getValueAsync(key: string) { try { - const fetchedValue = await SecureStore.getItemAsync(key, { - keychainService: service, - requireAuthentication: requireAuth, - authenticationPrompt: 'Authenticate', - }); + const fetchedValue = await SecureStore.getItemAsync(key, storeOptions); Alert.alert('Success!', 'Fetched value: ' + fetchedValue, [ { text: 'OK', onPress: () => {} }, ]); @@ -95,11 +85,7 @@ function SecureStoreView() { function getValue(key: string) { try { - const fetchedValue = SecureStore.getItem(key, { - keychainService: service, - requireAuthentication: requireAuth, - authenticationPrompt: 'Authenticate', - }); + const fetchedValue = SecureStore.getItem(key, storeOptions); Alert.alert('Success!', 'Fetched value: ' + fetchedValue, [ { text: 'OK', onPress: () => {} }, ]); @@ -184,10 +170,18 @@ function SecureStoreView() { Can use biometric authentication: {SecureStore.canUseBiometricAuthentication().toString()} {SecureStore.canUseBiometricAuthentication() && ( - - Requires authentication: - - + <> + + Requires authentication: + + + {Platform.OS === 'android' && requireAuth && ( + + Requires confirmation: + + + )} + )} {value && key && ( storeValueAsync(value, key)} title="Store value with key" /> diff --git a/packages/expo-secure-store/CHANGELOG.md b/packages/expo-secure-store/CHANGELOG.md index 32ed271060d745..eba529b4b98f87 100644 --- a/packages/expo-secure-store/CHANGELOG.md +++ b/packages/expo-secure-store/CHANGELOG.md @@ -6,6 +6,8 @@ ### πŸŽ‰ New features +- Add an Android-only `requireConfirmation` option for authenticated reads and writes. + ### πŸ› Bug fixes ### πŸ’‘ Others diff --git a/packages/expo-secure-store/android/build.gradle b/packages/expo-secure-store/android/build.gradle index e6ea04d796e898..72c83e3728df0d 100644 --- a/packages/expo-secure-store/android/build.gradle +++ b/packages/expo-secure-store/android/build.gradle @@ -16,4 +16,6 @@ android { dependencies { implementation "androidx.biometric:biometric:1.1.0" + testImplementation 'junit:junit:4.13.2' + testImplementation "org.robolectric:robolectric:4.16" } diff --git a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt index a281b88ec7d9d1..b60705b7a50140 100644 --- a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt +++ b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt @@ -19,9 +19,9 @@ class AuthenticationHelper( ) { private var isAuthenticating = false - suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String): Cipher { - if (requiresAuthentication) { - return openAuthenticationPrompt(cipher, title).cryptoObject?.cipher + internal suspend fun authenticateCipher(cipher: Cipher, options: AuthenticationPromptOptions): Cipher { + if (options.requireAuthentication) { + return openAuthenticationPrompt(cipher, options).cryptoObject?.cipher ?: throw AuthenticationException("Couldn't get cipher from authentication result") } return cipher @@ -29,7 +29,7 @@ class AuthenticationHelper( private suspend fun openAuthenticationPrompt( cipher: Cipher, - title: String + options: AuthenticationPromptOptions ): BiometricPrompt.AuthenticationResult { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { throw AuthenticationException("Biometric authentication requires Android API 23") @@ -45,7 +45,12 @@ class AuthenticationHelper( val fragmentActivity = getCurrentActivity() as? FragmentActivity ?: throw AuthenticationException("Cannot display biometric prompt when the app is not in the foreground") - val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title) + val authenticationPrompt = AuthenticationPrompt( + fragmentActivity, + context, + options.authenticationPrompt, + options.requireConfirmation + ) return withContext(Dispatchers.Main.immediate) { return@withContext authenticationPrompt.authenticate(cipher) diff --git a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt index 7437374f164c7c..53085985622b87 100644 --- a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt +++ b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt @@ -11,12 +11,14 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine -class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String) { +class AuthenticationPrompt( + private val currentActivity: FragmentActivity, + context: Context, + title: String, + requireConfirmation: Boolean +) { private var executor: Executor = ContextCompat.getMainExecutor(context) - private var promptInfo = PromptInfo.Builder() - .setTitle(title) - .setNegativeButtonText(context.getString(android.R.string.cancel)) - .build() + private var promptInfo = buildAuthenticationPromptInfo(context, title, requireConfirmation) suspend fun authenticate(cipher: Cipher): BiometricPrompt.AuthenticationResult? = suspendCoroutine { continuation -> @@ -58,3 +60,13 @@ class AuthenticationPrompt(private val currentActivity: FragmentActivity, contex } } } + +internal fun buildAuthenticationPromptInfo( + context: Context, + title: String, + requireConfirmation: Boolean = true +): PromptInfo = PromptInfo.Builder() + .setTitle(title) + .setNegativeButtonText(context.getString(android.R.string.cancel)) + .setConfirmationRequired(requireConfirmation) + .build() diff --git a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt index 9909b0acf3b20a..9f8a60465c4563 100644 --- a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +++ b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt @@ -201,7 +201,12 @@ open class SecureStoreModule : Module() { back a value. */ val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) - val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper) + val encryptedItem = mAESEncryptor.createEncryptedItem( + value, + secretKeyEntry, + options, + authenticationHelper + ) encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) diff --git a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt index a8ec6f029efd8f..169973fb4c8242 100644 --- a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +++ b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt @@ -10,5 +10,20 @@ class SecureStoreOptions( // Prompt can't be an empty string @Field var authenticationPrompt: String = " ", @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, - @Field var requireAuthentication: Boolean = false + @Field var requireAuthentication: Boolean = false, + @Field var requireConfirmation: Boolean = true ) : Record, Serializable + +internal data class AuthenticationPromptOptions( + val authenticationPrompt: String, + val requireAuthentication: Boolean, + val requireConfirmation: Boolean +) + +internal fun SecureStoreOptions.toAuthenticationPromptOptions( + requireAuthentication: Boolean = this.requireAuthentication +) = AuthenticationPromptOptions( + authenticationPrompt = authenticationPrompt, + requireAuthentication = requireAuthentication, + requireConfirmation = requireConfirmation +) diff --git a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt index 3a12dc996a27c1..56aeaacb5945b1 100644 --- a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt +++ b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt @@ -8,6 +8,7 @@ import expo.modules.securestore.AuthenticationHelper import expo.modules.securestore.DecryptException import expo.modules.securestore.SecureStoreModule import expo.modules.securestore.SecureStoreOptions +import expo.modules.securestore.toAuthenticationPromptOptions import org.json.JSONException import org.json.JSONObject import java.nio.charset.StandardCharsets @@ -76,8 +77,7 @@ class AESEncryptor : KeyBasedEncryptor { override suspend fun createEncryptedItem( plaintextValue: String, keyStoreEntry: KeyStore.SecretKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, + options: SecureStoreOptions, authenticationHelper: AuthenticationHelper ): JSONObject { val secretKey = keyStoreEntry.secretKey @@ -85,7 +85,10 @@ class AESEncryptor : KeyBasedEncryptor { cipher.init(Cipher.ENCRYPT_MODE, secretKey) val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) - val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt) + val authenticatedCipher = authenticationHelper.authenticateCipher( + cipher, + options.toAuthenticationPromptOptions() + ) return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) } @@ -128,7 +131,10 @@ class AESEncryptor : KeyBasedEncryptor { throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) } cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) - val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt) + val unlockedCipher = authenticationHelper.authenticateCipher( + cipher, + options.toAuthenticationPromptOptions(requireAuthentication = requiresAuthentication) + ) return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) } diff --git a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt index fb425997015769..fb7ebf62572e9d 100644 --- a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt +++ b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt @@ -67,8 +67,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto override suspend fun createEncryptedItem( plaintextValue: String, keyStoreEntry: KeyStore.PrivateKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, + options: SecureStoreOptions, authenticationHelper: AuthenticationHelper ): JSONObject { // This should never be called after we dropped Android SDK 22 support. diff --git a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt index e49346707f05a1..d7f27100f53f35 100644 --- a/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt +++ b/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt @@ -23,8 +23,7 @@ interface KeyBasedEncryptor { suspend fun createEncryptedItem( plaintextValue: String, keyStoreEntry: E, - requireAuthentication: Boolean, - authenticationPrompt: String, + options: SecureStoreOptions, authenticationHelper: AuthenticationHelper ): JSONObject diff --git a/packages/expo-secure-store/android/src/test/java/expo/modules/securestore/AuthenticationPromptTest.kt b/packages/expo-secure-store/android/src/test/java/expo/modules/securestore/AuthenticationPromptTest.kt new file mode 100644 index 00000000000000..7378f0b8983a27 --- /dev/null +++ b/packages/expo-secure-store/android/src/test/java/expo/modules/securestore/AuthenticationPromptTest.kt @@ -0,0 +1,44 @@ +package expo.modules.securestore + +import android.content.Context +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AuthenticationPromptTest { + private val context: Context + get() = RuntimeEnvironment.getApplication() + + @Test + fun `requires confirmation by default`() { + val promptInfo = buildAuthenticationPromptInfo(context, "Authenticate") + + assertTrue(promptInfo.isConfirmationRequired) + } + + @Test + fun `can explicitly require confirmation`() { + val promptInfo = buildAuthenticationPromptInfo( + context, + "Authenticate", + requireConfirmation = true + ) + + assertTrue(promptInfo.isConfirmationRequired) + } + + @Test + fun `can explicitly allow implicit authentication`() { + val promptInfo = buildAuthenticationPromptInfo( + context, + "Authenticate", + requireConfirmation = false + ) + + assertFalse(promptInfo.isConfirmationRequired) + } +} diff --git a/packages/expo-secure-store/android/src/test/java/expo/modules/securestore/SecureStoreOptionsTest.kt b/packages/expo-secure-store/android/src/test/java/expo/modules/securestore/SecureStoreOptionsTest.kt new file mode 100644 index 00000000000000..daede358d3a43b --- /dev/null +++ b/packages/expo-secure-store/android/src/test/java/expo/modules/securestore/SecureStoreOptionsTest.kt @@ -0,0 +1,51 @@ +package expo.modules.securestore + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SecureStoreOptionsTest { + @Test + fun `confirmation is required by default`() { + assertTrue(SecureStoreOptions().requireConfirmation) + } + + @Test + fun `write prompt uses the current authentication options`() { + val options = SecureStoreOptions( + requireAuthentication = true, + requireConfirmation = false + ) + + val promptOptions = options.toAuthenticationPromptOptions() + + assertTrue(promptOptions.requireAuthentication) + assertFalse(promptOptions.requireConfirmation) + } + + @Test + fun `read prompt uses stored authentication and current confirmation options`() { + val options = SecureStoreOptions( + requireAuthentication = false, + requireConfirmation = false + ) + + val promptOptions = options.toAuthenticationPromptOptions(requireAuthentication = true) + + assertTrue(promptOptions.requireAuthentication) + assertFalse(promptOptions.requireConfirmation) + } + + @Test + fun `stored unauthenticated reads remain prompt-free`() { + val options = SecureStoreOptions( + requireAuthentication = true, + requireConfirmation = false + ) + + val promptOptions = options.toAuthenticationPromptOptions(requireAuthentication = false) + + assertFalse(promptOptions.requireAuthentication) + assertFalse(promptOptions.requireConfirmation) + } +} diff --git a/packages/expo-secure-store/src/SecureStore.ts b/packages/expo-secure-store/src/SecureStore.ts index b2187d65f5aefa..1f00d4c33c21fe 100644 --- a/packages/expo-secure-store/src/SecureStore.ts +++ b/packages/expo-secure-store/src/SecureStore.ts @@ -84,6 +84,15 @@ export type SecureStoreOptions = { * > **Note:** This library requires a real device for testing since emulators/simulators do not require biometric authentication when retrieving secrets, unlike real iOS devices. */ requireAuthentication?: boolean; + /** + * Sets a hint to the system for whether to require user confirmation after authentication. + * This may be ignored by the system if the user has disabled implicit authentication in Settings + * or if it does not apply to a particular biometric modality. Defaults to `true`. + * + * This option is only used when `requireAuthentication` is enabled. + * @platform android + */ + requireConfirmation?: boolean; /** * Custom message displayed to the user while `requireAuthentication` option is turned on. */ diff --git a/packages/expo-secure-store/src/__tests__/SecureStore-test.native.ts b/packages/expo-secure-store/src/__tests__/SecureStore-test.native.ts index 9af808a80417f7..aa05acd066b949 100644 --- a/packages/expo-secure-store/src/__tests__/SecureStore-test.native.ts +++ b/packages/expo-secure-store/src/__tests__/SecureStore-test.native.ts @@ -1,6 +1,65 @@ import ExpoSecureStore from '../ExpoSecureStore'; import * as SecureStore from '../SecureStore'; +beforeEach(() => { + jest.clearAllMocks(); +}); + +const confirmationCases: { + name: string; + options: SecureStore.SecureStoreOptions | undefined; + expectedOptions: SecureStore.SecureStoreOptions; +}[] = [ + { name: 'when omitted', options: undefined, expectedOptions: {} }, + { + name: 'when confirmation is required', + options: { requireConfirmation: true }, + expectedOptions: { requireConfirmation: true }, + }, + { + name: 'when confirmation is not required', + options: { requireConfirmation: false }, + expectedOptions: { requireConfirmation: false }, + }, +]; + +describe.each(confirmationCases)( + 'forwards confirmation options $name', + ({ options, expectedOptions }) => { + it('when setting a value asynchronously', async () => { + await SecureStore.setItemAsync('key', 'value', options); + + expect(ExpoSecureStore.setValueWithKeyAsync).toHaveBeenLastCalledWith( + 'value', + 'key', + expectedOptions + ); + }); + + it('when getting a value asynchronously', async () => { + await SecureStore.getItemAsync('key', options); + + expect(ExpoSecureStore.getValueWithKeyAsync).toHaveBeenLastCalledWith('key', expectedOptions); + }); + + it('when setting a value synchronously', () => { + SecureStore.setItem('key', 'value', options); + + expect(ExpoSecureStore.setValueWithKeySync).toHaveBeenLastCalledWith( + 'value', + 'key', + expectedOptions + ); + }); + + it('when getting a value synchronously', () => { + SecureStore.getItem('key', options); + + expect(ExpoSecureStore.getValueWithKeySync).toHaveBeenLastCalledWith('key', expectedOptions); + }); + } +); + it(`sets values`, async () => { const testKey = 'key-test_0.0'; const testValue = 'value `~!@#$%^&*();:\'"-_.,<>'; From 05ef306914720d47100e51d8c6df0185615825ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20=C5=BBelawski?= <40713406+tjzel@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:43:42 +0200 Subject: [PATCH 2/9] chore: replace deprecated Worklets APIs in Expo Modules Core (#48691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Łukasz Kosmaty --- packages/expo-modules-core/CHANGELOG.md | 1 + .../android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp | 5 +---- .../ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md index b10685aaa0cbfb..40b9837522509a 100644 --- a/packages/expo-modules-core/CHANGELOG.md +++ b/packages/expo-modules-core/CHANGELOG.md @@ -43,6 +43,7 @@ - [iOS] Added `SceneGeometry` for reading bounds, safe area, display scale and interface orientation from the scene a view belongs to. ([#48168](https://github.com/expo/expo/pull/48168) by [@alanjhughes](https://github.com/alanjhughes)) - [iOS] Added `SceneGeometry.foregroundScene()`, which returns nil when no scene is on screen so callers can avoid presenting UI into a background scene. ([#48318](https://github.com/expo/expo/pull/48318) by [@alanjhughes](https://github.com/alanjhughes)) - Removed Quick and Nimble in favor of Swift Testing. ([#48530](https://github.com/expo/expo/pull/48530) by [@tsapeta](https://github.com/tsapeta)) +- Migrated from deprecated react-native-worklets WorkletRuntime API `executeSync` to up-to-date `runSync`. `runSync` is available since 0.7.0. ([#48691](https://github.com/expo/expo/pull/48691) by [@tjzel](https://github.com/tjzel)) ## 57.0.8 - 2026-07-29 diff --git a/packages/expo-modules-core/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp b/packages/expo-modules-core/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp index 1eb5f0db7a7c55..f93ffcd4f8e994 100644 --- a/packages/expo-modules-core/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp +++ b/packages/expo-modules-core/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp @@ -22,9 +22,6 @@ namespace expo { return; } - workletRuntime->executeSync([func = std::move(func)](jsi::Runtime &rt) -> jsi::Value { - func(rt); - return jsi::Value::undefined(); - }); + workletRuntime->runSync(func); } } // namespace expo diff --git a/packages/expo-modules-core/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm b/packages/expo-modules-core/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm index 126545fae12dc3..d97163a2e9a9db 100644 --- a/packages/expo-modules-core/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm +++ b/packages/expo-modules-core/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm @@ -233,7 +233,7 @@ - (void)executeWorkletWithRuntimeHandle:(id)runtimeHandle return; } - workletRuntime->executeSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value { + workletRuntime->runSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value { return callWorklet(rt, worklet, arguments); }); } From 8b97552dd7785e10127bce79d5ee74c7d0cbd6e1 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:35:18 +0200 Subject: [PATCH 3/9] [router] Fix warnings in tests (#49069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why There are multiple warnings which pollute the test output in router # How 1. Solve suspense warnings by using `renderAsync`/`renderHookAsync` function from `testing-library` 2. Ignore `LogBoxNotificationContainer` warning 3. Add `renderRouterAsync` utility # Test Plan CI # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- Stack created with GitHub Stacks CLI β€’ Give Feedback πŸ’¬ --- .../__tests__/SuspenseFallback.test.ios.tsx | 18 +++--- .../src/hooks/__tests__/renderHook.tsx | 52 +++++++++++----- .../__tests__/useLoaderData.test.ios.tsx | 60 ++++++++++++------- .../native-stack/__tests__/index.test.ios.tsx | 3 + .../expo-router/src/testing-library/index.tsx | 27 +++++++++ 5 files changed, 116 insertions(+), 44 deletions(-) diff --git a/packages/expo-router/src/__tests__/SuspenseFallback.test.ios.tsx b/packages/expo-router/src/__tests__/SuspenseFallback.test.ios.tsx index 402d75da7a25f3..4e944b65f584b2 100644 --- a/packages/expo-router/src/__tests__/SuspenseFallback.test.ios.tsx +++ b/packages/expo-router/src/__tests__/SuspenseFallback.test.ios.tsx @@ -4,7 +4,7 @@ import { Text, View } from 'react-native'; import type { SuspenseFallbackProps } from '../exports'; import { Slot } from '../exports'; -import { renderRouter } from '../testing-library'; +import { renderRouterAsync } from '../testing-library'; const renderFallback = (route: string, testID = 'custom-fallback') => ( @@ -12,7 +12,7 @@ const renderFallback = (route: string, testID = 'custom-fallback') => ( ); -it('inherits `` from the nearest layout in sync mode', () => { +it('inherits `` from the nearest layout in sync mode', async () => { const pending = new Promise(() => {}); function SuspendingRoute() { @@ -24,7 +24,7 @@ it('inherits `` from the nearest layout in sync mode', () => { renderFallback(route, 'layout-fallback') ); - renderRouter( + await renderRouterAsync( { '(app)/_layout': { default: () => , @@ -41,7 +41,7 @@ it('inherits `` from the nearest layout in sync mode', () => { expect(LayoutFallback).toHaveBeenCalledTimes(1); }); -it('uses the nearest layout `` in sync mode', () => { +it('uses the nearest layout `` in sync mode', async () => { const pending = new Promise(() => {}); function SuspendingRoute() { @@ -54,7 +54,7 @@ it('uses the nearest layout `` in sync mode', () => { const NestedFallback = ({ route }: SuspenseFallbackProps) => renderFallback(route, 'nested-layout-fallback'); - renderRouter( + await renderRouterAsync( { _layout: { default: () => , @@ -75,7 +75,7 @@ it('uses the nearest layout `` in sync mode', () => { expect(screen.getByText('Loading ./(app)/profile/[id].js...')).toBeOnTheScreen(); }); -it('passes route params to layout-level ``', () => { +it('passes route params to layout-level ``', async () => { const pending = new Promise(() => {}); function SuspendingRoute() { @@ -91,7 +91,7 @@ it('passes route params to layout-level ``', () => { )); - renderRouter( + await renderRouterAsync( { '(app)/_layout': { default: () => , @@ -116,7 +116,7 @@ it('passes route params to layout-level ``', () => { ); }); -it('renders default `` when one is not available', () => { +it('renders default `` when one is not available', async () => { const pending = new Promise(() => {}); // Promise that never resolves function SuspendingRoute() { @@ -124,7 +124,7 @@ it('renders default `` when one is not available', () => { return {value}; } - renderRouter({ + await renderRouterAsync({ index: SuspendingRoute, }); diff --git a/packages/expo-router/src/hooks/__tests__/renderHook.tsx b/packages/expo-router/src/hooks/__tests__/renderHook.tsx index d2dd9b6ba0ea3b..b041cd1a2eb967 100644 --- a/packages/expo-router/src/hooks/__tests__/renderHook.tsx +++ b/packages/expo-router/src/hooks/__tests__/renderHook.tsx @@ -1,4 +1,7 @@ -import { renderHook as tlRenderHook } from '@testing-library/react-native'; +import { + renderHook as tlRenderHook, + renderHookAsync as tlRenderHookAsync, +} from '@testing-library/react-native'; import React from 'react'; import { ExpoRoot } from '../../exports'; @@ -18,24 +21,43 @@ export function renderHook( }: { initialUrl?: string; wrapper?: React.ComponentType<{ children: React.ReactNode }> } = {} ) { return tlRenderHook(renderCallback, { - wrapper: function Wrapper({ children }) { - const context: MemoryContext = {}; - for (const key of routes) { - context[key] = () => <>{children}; - } - - const root = ( - - ); + wrapper: createWrapper(routes, initialUrl, RootWrapper), + }); +} - return RootWrapper ? {root} : root; - }, +export function renderHookAsync( + renderCallback: () => T, + routes: string[] = ['index'], + { + initialUrl = '/', + wrapper: RootWrapper, + }: { initialUrl?: string; wrapper?: React.ComponentType<{ children: React.ReactNode }> } = {} +) { + // TODO: Remove `renderHookAsync` when we migrate to RNTL v14. + return tlRenderHookAsync(renderCallback, { + wrapper: createWrapper(routes, initialUrl, RootWrapper), }); } +function createWrapper( + routes: string[], + initialUrl: string, + RootWrapper?: React.ComponentType<{ children: React.ReactNode }> +) { + return function Wrapper({ children }: { children: React.ReactNode }) { + const context: MemoryContext = {}; + for (const key of routes) { + context[key] = () => <>{children}; + } + + const root = ( + + ); + + return RootWrapper ? {root} : root; + }; +} + export function renderHookOnce( renderCallback: () => T, routes?: string[], diff --git a/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx b/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx index 954db1c4f42062..c825a3f0c23f82 100644 --- a/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx +++ b/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx @@ -1,6 +1,6 @@ -import { act } from '@testing-library/react-native'; +import { act, fireEvent, screen } from '@testing-library/react-native'; import { expectTypeOf } from 'expect-type'; -import { type ReactNode, useLayoutEffect } from 'react'; +import { type ReactNode, useLayoutEffect, useState } from 'react'; import { Text } from 'react-native'; import { router, Slot } from '../../exports'; @@ -13,9 +13,9 @@ import { } from '../../loaders/LoaderContext'; import { ServerDataLoaderContext } from '../../loaders/ServerDataLoaderContext'; import { fetchLoader } from '../../loaders/utils'; -import { renderRouter } from '../../testing-library'; +import { renderRouter, renderRouterAsync } from '../../testing-library'; import { useLoaderData } from '../useLoaderData'; -import { renderHook } from './renderHook'; +import { renderHook, renderHookAsync } from './renderHook'; jest.mock('../../loaders/utils', () => ({ fetchLoader: jest.fn(), @@ -120,7 +120,8 @@ describe(useLoaderData, () => { await act(async () => {}); expect(ctx.store.get('/index')).toBeUndefined(); - renderHook(() => useLoaderData(), ['index'], { + // TODO: Remove `renderHookAsync` when we migrate to RNTL v14. + await renderHookAsync(() => useLoaderData(), ['index'], { initialUrl: '/', wrapper: LoaderWrapper, }); @@ -138,7 +139,8 @@ describe(useLoaderData, () => { const { ctx, LoaderWrapper } = createLoaderTestContext(); - renderHook(() => useLoaderData(), ['users/[id]'], { + // TODO: Remove `renderHookAsync` when we migrate to RNTL v14. + await renderHookAsync(() => useLoaderData(), ['users/[id]'], { initialUrl: '/users/123', wrapper: LoaderWrapper, }); @@ -154,7 +156,7 @@ describe(useLoaderData, () => { }); }); - it('retrieves settled data from the Suspense store without fetching', () => { + it('retrieves settled data from the Suspense store without fetching', async () => { globalThis.__EXPO_ROUTER_LOADER_DATA__ = { '/users/123': { fromHydration: true }, }; @@ -162,7 +164,8 @@ describe(useLoaderData, () => { const { ctx, LoaderWrapper } = createLoaderTestContext(); ctx.store.set('/users/123', { data: { fromStore: true } }); - const { result } = renderHook(() => useLoaderData(), ['users/[id]'], { + // TODO: Remove `renderHookAsync` when we migrate to RNTL v14. + const { result } = await renderHookAsync(() => useLoaderData(), ['users/[id]'], { initialUrl: '/users/123', wrapper: LoaderWrapper, }); @@ -200,24 +203,41 @@ describe(useLoaderData, () => { const { ctx, LoaderWrapper } = createLoaderTestContext(); ctx.store.seed('/index', { shared: true }); - const first = renderHook(() => useLoaderData(), ['index'], { - initialUrl: '/', - wrapper: LoaderWrapper, - }); - const second = renderHook(() => useLoaderData(), ['index'], { - initialUrl: '/', - wrapper: LoaderWrapper, - }); + function Reader({ testID }: { testID: string }) { + const data = useLoaderData(); + return {JSON.stringify(data)}; + } - first.unmount(); + function SiblingReaders() { + const [showFirst, setShowFirst] = useState(true); + return ( + <> + {showFirst && } + + setShowFirst(false)}>Remove first + + ); + } + + const root = await renderRouterAsync( + { + index: SiblingReaders, + }, + { wrapper: LoaderWrapper } + ); + jest.useRealTimers(); + + expect(screen.getByTestId('first-reader')).toHaveTextContent('{"shared":true}'); + fireEvent.press(screen.getByText('Remove first')); await act(async () => {}); - expect(second.result.current).toEqual({ shared: true }); + + expect(screen.queryByTestId('first-reader')).toBeNull(); + expect(screen.getByTestId('second-reader')).toHaveTextContent('{"shared":true}'); expect(ctx.store.get('/index')).toEqual({ data: { shared: true }, }); - second.unmount(); - await act(async () => {}); + await root.unmountAsync(); expect(ctx.store.get('/index')).toBeUndefined(); }); diff --git a/packages/expo-router/src/react-navigation/native-stack/__tests__/index.test.ios.tsx b/packages/expo-router/src/react-navigation/native-stack/__tests__/index.test.ios.tsx index 00078cec62e9e4..a2ca07ec8903f5 100644 --- a/packages/expo-router/src/react-navigation/native-stack/__tests__/index.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/native-stack/__tests__/index.test.ios.tsx @@ -6,6 +6,9 @@ import { NavigationContainer } from '../../../fork/NavigationContainer'; import { Text, useHeaderHeight } from '../../elements'; import { createNativeStackNavigator, type NativeStackScreenProps } from '../index'; +// The native screens debug container mounts React Native's unrelated LogBox subscription UI. +jest.mock('react-native/Libraries/LogBox/LogBoxNotificationContainer', () => () => null); + type StackParamList = { A: undefined; B: undefined; diff --git a/packages/expo-router/src/testing-library/index.tsx b/packages/expo-router/src/testing-library/index.tsx index 4430a0497209e5..4fc9dbfeafac6c 100644 --- a/packages/expo-router/src/testing-library/index.tsx +++ b/packages/expo-router/src/testing-library/index.tsx @@ -60,6 +60,12 @@ export type RenderRouterOptions = Parameters[1] linking?: Partial; }; +// TODO: Remove `renderAsync` when we migrate to RNTL v14. +export type RenderRouterAsyncOptions = Parameters[1] & { + initialUrl?: any; + linking?: Partial; +}; + type Result = ReturnType & { getPathname(): string; getPathnameWithParams(): string; @@ -115,6 +121,27 @@ export function renderRouter( }); } +export async function renderRouterAsync( + context: MockContextConfig = './app', + { initialUrl = '/', linking, ...options }: RenderRouterAsyncOptions = {} +): Promise>> { + const systemTime = Date.now(); + jest.useFakeTimers(); + try { + jest.setSystemTime(systemTime); + } catch { + // Legacy fake timers don't support `setSystemTime` (and don't mock the clock), so there's nothing to restore. + } + + process.env.EXPO_ROUTER_IMPORT_MODE = 'sync'; + + // TODO: Remove `renderAsync` when we migrate to RNTL v14. + return rnTestingLibrary.renderAsync( + , + options + ); +} + export const testRouter = { /** Navigate to the provided pathname and assert the pathname */ navigate(path: string) { From 592f8434238af03a7caae8664a9c0f77ff61cb85 Mon Sep 17 00:00:00 2001 From: Expo Bot <34669131+expo-bot@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:50:20 -0700 Subject: [PATCH 4/9] [expo-ui] Add a `contentPadding` prop to the universal `BottomSheet` (#48943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!WARNING] > **Agent-authored and NOT human-reviewed.** An automated `/verify --fix` run for #48902 wrote this change and checked it in a sandbox; the reasoning and evidence are in the outcome comment on that issue. Review it as you would any external contribution. Requested by @brentvatne Β· [investigation run](https://github.com/expo/expo/actions/runs/31815585821) Β· refs #48902 ## Why Reported in [#48902](https://github.com/expo/expo/issues/48902). The universal `BottomSheet` wraps `children` in a container of its own on every platform and hardcodes 16 units of padding on it, and `BottomSheetProps` has no field for that container β€” so sheet content can never reach the sheet's edges. No full-bleed row, image, divider or list separator is possible. The `modifiers` escape hatch does not give it back. On Android `modifiers` goes to `ModalBottomSheet`, not to the padded `Column` that holds the children ([index.android.tsx#L63](https://github.com/expo/expo/blob/bc467bc47f2ae58f54e75caa1363c4b59b3b38a5/packages/expo-ui/src/universal/BottomSheet/index.android.tsx#L63-L63)). On iOS it lands on the same `Group` but is appended *after* the hardcoded entry, so a second `padding` stacks onto the first instead of replacing it ([index.ios.tsx#L28-L38](https://github.com/expo/expo/blob/bc467bc47f2ae58f54e75caa1363c4b59b3b38a5/packages/expo-ui/src/universal/BottomSheet/index.ios.tsx#L28-L38)). Web never reads `modifiers` at all, and its inner `div` carries `padding: 16` ([index.tsx#L101-L104](https://github.com/expo/expo/blob/bc467bc47f2ae58f54e75caa1363c4b59b3b38a5/packages/expo-ui/src/universal/BottomSheet/index.tsx#L101-L104)). ## How Adds one optional prop, `contentPadding?: number | { top?, bottom?, left?, right? }`, applied to the content container on all three platforms. A shared `resolveContentPadding` helper resolves it against the inset each platform applies today, so **when the prop is omitted nothing changes**: iOS keeps `{ top: 16, leading: 16, trailing: 16 }`, Android keeps `padding(16, showDragIndicator ? 0 : 16, 16, 0)`, web keeps `padding: 16`. This is deliberately not a change to any default β€” existing sheets render identically, and `contentPadding={0}` is what unlocks full-bleed content. Per-edge values follow the universal layer's style-like naming (`left`/`right`, as `ScrollView` already maps `paddingLeft` β†’ `leading`), and an edge left out of the object is `0`, so `contentPadding` fully owns the container's padding rather than merging with the platform default. That is a deliberate semantic and worth a reviewer's eye: on web, whose default bottom inset is 16, `contentPadding={{ left: 0 }}` therefore clears the bottom inset too (measured, table below). Docs data was regenerated with `et gdad -p expo-ui/universal/bottomsheet`. ## Test Plan Repo tooling, in a full monorepo checkout at `bc467bc` with `pnpm install`: ``` packages/expo-ui $ pnpm run typecheck # clean packages/expo-ui $ pnpm run lint --max-warnings 0 # Found 0 warnings and 0 errors. packages/expo-ui $ pnpm test # Test Suites: 28 passed, Tests: 133 passed ``` Behavior, using the reporter's repro (`kilarsky/expo-ui-bottom-sheet-content-padding-repro`, `@expo/ui` 57.0.10, `expo` 57.0.12) with the change applied to the installed package and bundled by Metro: **iOS** β€” hosted iPhone simulator, Expo Go, SDK 57: | Arm | Result | | --- | --- | | Unpatched, no prop | blue bar inset 16pt from both sheet edges (the bug) | | Patched, prop omitted | inset unchanged β€” no default changed | | Patched, `contentPadding={0}` | bar spans the sheet edge to edge | | Patched, `contentPadding={{ top: 8, left: 40, right: 40 }}` | 40pt side inset, 8pt above the bar | The "prop omitted" arm is the guard against changing a default, and the code makes the same point more strongly than a screenshot can: iOS resolves to `{ top: 16, bottom: 0, leading: 16, trailing: 16 }`, and `PaddingModifier` already maps the previously-omitted `bottom` to `0` (`packages/expo-ui/ios/Modifiers/ViewModifierRegistry.swift`), giving identical `EdgeInsets`; Android's resolved call expands to literally the previous `padding(16, showDragIndicator ? 0 : 16, 16, 0)`. **Web** β€” the same repro served by Metro and measured in headless Chrome (`getBoundingClientRect` on the bar against the sheet, plus the content container's computed padding): | Arm | Content container padding | Bar gap left / right | | --- | --- | --- | | Unpatched, no prop | `16px 16px 16px 16px` | 16 / 16 | | Patched, prop omitted | `16px 16px 16px 16px` | 16 / 16 | | Patched, `contentPadding={0}` | `0px 0px 0px 0px` | 0 / 0 | | Patched, `contentPadding={{ left: 0 }}` | `0px 0px 0px 0px` | 0 / 0 | **Android** β€” not exercised on a device. An emulator session was started for this run and never became available, so the Android arm rests on the shared, unit-tested resolver and on code symmetry with the two arms that were measured; a reviewer with an Android device should confirm `contentPadding={0}` and the `showDragIndicator={false}` default there. Note also that Compose's `Modifier.padding` rejects negative values, so a negative `contentPadding` β€” which iOS and web accept β€” would throw on Android; the change does not validate it. ## Checklist - [x] `CHANGELOG.md` entry added. - [x] Type-checks, lints and tests via the package's own scripts in a real monorepo checkout. - [x] Documentation updated (`bottomsheet.mdx` usage section + regenerated API data). --------- Co-authored-by: expo-bot Co-authored-by: nishan (o^β–½^o) --- .../sdk/ui/universal/bottomsheet.mdx | 30 ++++++++++++++++ .../expo-ui/universal/bottomsheet.json | 2 +- packages/expo-ui/CHANGELOG.md | 1 + .../BottomSheet/__tests__/utils.test.ts | 33 +++++++++++++++++ .../universal/BottomSheet/index.android.tsx | 13 +++++-- .../src/universal/BottomSheet/index.ios.tsx | 10 +++++- .../src/universal/BottomSheet/index.tsx | 19 ++++++++-- .../src/universal/BottomSheet/types.ts | 24 +++++++++++++ .../src/universal/BottomSheet/utils.ts | 35 +++++++++++++++++++ 9 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 packages/expo-ui/src/universal/BottomSheet/__tests__/utils.test.ts create mode 100644 packages/expo-ui/src/universal/BottomSheet/utils.ts diff --git a/docs/pages/versions/unversioned/sdk/ui/universal/bottomsheet.mdx b/docs/pages/versions/unversioned/sdk/ui/universal/bottomsheet.mdx index 0ecf649e80db8c..4db33549fc229c 100644 --- a/docs/pages/versions/unversioned/sdk/ui/universal/bottomsheet.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/universal/bottomsheet.mdx @@ -68,6 +68,36 @@ export default function BottomSheetNoIndicatorExample() { } ``` +### Content padding + +The sheet insets its content by default. Pass [`contentPadding`](#contentpadding) to change that inset β€” `0` lets a row, image, or divider reach the sheet's edges. + +```tsx BottomSheetContentPaddingExample.tsx +import { useState } from 'react'; +import { Host, BottomSheet, Button, Column, Text } from '@expo/ui'; + +export default function BottomSheetContentPaddingExample() { + const [isPresented, setIsPresented] = useState(false); + + return ( + +