From e1b15cad5bee7033279015778452070db4a3c5c5 Mon Sep 17 00:00:00 2001
From: Vincent Ong <256906086+mvincentong@users.noreply.github.com>
Date: Mon, 17 Aug 2026 19:20:32 +0800
Subject: [PATCH 01/14] [file-system][legacy] Fix chunked UTF-8 file reads
(#45714)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
Fixes #20291. On Android, legacy `FileSystem.readAsStringAsync` ignored
`position` and `length` for UTF-8 reads and loaded the full input stream
before decoding. That defeated chunked reads for large JSON/text files
and could trigger OOM. iOS also read UTF-8 strings through
`String(contentsOfFile:)`, so the option docs did not match behavior
outside Base64 reads.
## How
Route legacy reads through byte-range helpers before encoding/decoding.
Android now shares the same byte-range path for UTF-8 and Base64 while
preserving existing Base64 behavior, and iOS now reads a byte range into
`Data` before converting to `String`. The scope is intentionally limited
to legacy `readAsStringAsync` and its `ReadingOptions` docs.
## Test Plan
- [x] `git diff --check upstream/main...HEAD`
- [x] `npx pnpm@10.33.0 --filter expo-file-system build`
- [x]
`/Users/mvincentong/.npm/_npx/00d0dcf00546c229/node_modules/.bin/oxlint
--config oxlint.config.mjs .` from `packages/expo-file-system`
- [x] `env ANDROID_HOME=/Users/mvincentong/Library/Android/sdk
ANDROID_SDK_ROOT=/Users/mvincentong/Library/Android/sdk
node_modules/.bin/et native-unit-tests --packages expo-file-system -p
android`
Additional check: `npx pnpm@10.33.0 --filter expo-file-system test`
currently fails in unchanged `src/__tests__/FileSystem-test.native.ts`
because `legacy.downloadAsync()` returns `undefined` in the legacy mock
test. The same targeted failure reproduces on clean `upstream/main` with
`npx pnpm@10.33.0 --filter expo-file-system test --
src/__tests__/FileSystem-test.native.ts --runInBand`, so I did not
change this PR for that unrelated failure.
## Risk
Seven-file `expo-file-system` diff: Android source/helper + Android unit
test, iOS source helper/module, TypeScript option docs, and changelog.
No dependency changes, no broad file-system API rewrite, and no
committed generated output on the current base.
---------
Co-authored-by: BartΕomiej Klocek
---
packages/expo-file-system/CHANGELOG.md | 1 +
.../legacy/FileSystemLegacyModule.kt | 50 ++-----------
.../legacy/FileSystemLegacyReader.kt | 57 +++++++++++++++
.../legacy/FileSystemLegacyReaderTest.kt | 70 +++++++++++++++++++
.../ios/Legacy/FileSystemHelpers.swift | 18 ++++-
.../ios/Legacy/FileSystemLegacyModule.swift | 10 +--
.../src/legacy/FileSystem.types.ts | 4 +-
7 files changed, 156 insertions(+), 54 deletions(-)
create mode 100644 packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyReader.kt
create mode 100644 packages/expo-file-system/android/src/test/java/expo/modules/filesystem/legacy/FileSystemLegacyReaderTest.kt
diff --git a/packages/expo-file-system/CHANGELOG.md b/packages/expo-file-system/CHANGELOG.md
index cba2bf58a5de17..d87cf639ecf997 100644
--- a/packages/expo-file-system/CHANGELOG.md
+++ b/packages/expo-file-system/CHANGELOG.md
@@ -19,6 +19,7 @@
- Added `./next` subpath to package `exports` field to resolve Metro bundler warning. ([#44793](https://github.com/expo/expo/pull/44793) by [@chang-in](https://github.com/chang-in))
- Fixed `FileHandle` security-scoped access, and non-SAF `content://` URI support. ([#47176](https://github.com/expo/expo/pull/47176) by [@barthap](https://github.com/barthap))
- Fixed potential file offset races when asynchronous and synchronous `FileHandle` operations overlap on Android and iOS. ([#47945](https://github.com/expo/expo/pull/47945) by [@wh201906](https://github.com/wh201906))
+- Fixed `readAsStringAsync` to respect `position` and `length` when reading UTF-8 strings. ([#20291](https://github.com/expo/expo/issues/20291) by [@mvincentong](https://github.com/mvincentong)) ([#45714](https://github.com/expo/expo/pull/45714) by [@mvincentong](https://github.com/mvincentong))
- [android] Fixed `rename()` storing an unencoded URI, so reading `.uri` afterwards threw for names containing a space. ([#48496](https://github.com/expo/expo/issues/48496) by [@yagiz2000](https://github.com/yagiz2000), [#48510](https://github.com/expo/expo/pull/48510) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
### π‘ Others
diff --git a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyModule.kt b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyModule.kt
index 0472011ffb8bf4..4626dac32067ec 100644
--- a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyModule.kt
+++ b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyModule.kt
@@ -43,7 +43,6 @@ import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import java.io.BufferedInputStream
-import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
@@ -183,29 +182,10 @@ open class FileSystemLegacyModule : Module() {
val uri = Uri.parse(slashifyFilePath(uriStr))
ensurePermission(uri, FilePermissionService.Permission.READ)
- // TODO:Bacon: Add more encoding types to match iOS
val encoding = options.encoding
- var contents: String?
- if (encoding == EncodingType.BASE64) {
- getInputStream(uri).use { inputStream ->
- contents = if (options.length != null && options.position != null) {
- val buffer = ByteArray(options.length)
- inputStream.skip(options.position.toLong())
- val bytesRead = inputStream.read(buffer, 0, options.length)
- Base64.encodeToString(buffer, 0, bytesRead, Base64.NO_WRAP)
- } else {
- val inputData = getInputStreamBytes(inputStream)
- Base64.encodeToString(inputData, Base64.NO_WRAP)
- }
- }
- } else {
- contents = when {
- uri.scheme == "file" -> IOUtils.toString(FileInputStream(uri.toFile()))
- uri.scheme == "asset" -> IOUtils.toString(openAssetInputStream(uri))
- uri.scheme == null -> IOUtils.toString(openResourceInputStream(uriStr))
- uri.isSAFUri -> IOUtils.toString(context.contentResolver.openInputStream(uri))
- else -> throw IOException("Unsupported scheme for location '$uri'.")
- }
+
+ val contents = getInputStream(uri, uriStr).use { inputStream ->
+ readInputStreamAsString(inputStream, encoding, options)
}
return@AsyncFunction contents
}
@@ -1069,9 +1049,10 @@ open class FileSystemLegacyModule : Module() {
}
@Throws(IOException::class)
- private fun getInputStream(uri: Uri) = when {
+ private fun getInputStream(uri: Uri, uriStr: String? = null) = when {
uri.scheme == "file" -> FileInputStream(uri.toFile())
uri.scheme == "asset" -> openAssetInputStream(uri)
+ uri.scheme == null && !uriStr.isNullOrEmpty() -> openResourceInputStream(uriStr)
uri.isSAFUri -> context.contentResolver.openInputStream(uri)!!
else -> throw IOException("Unsupported scheme for location '$uri'.")
}
@@ -1104,27 +1085,6 @@ open class FileSystemLegacyModule : Module() {
private fun parseFileUri(uriStr: String) = uriStr.substring(uriStr.indexOf(':') + 3)
- @Throws(IOException::class)
- private fun getInputStreamBytes(inputStream: InputStream): ByteArray {
- val bytesResult: ByteArray
- val byteBuffer = ByteArrayOutputStream()
- val bufferSize = 1024
- val buffer = ByteArray(bufferSize)
- try {
- var len: Int
- while (inputStream.read(buffer).also { len = it } != -1) {
- byteBuffer.write(buffer, 0, len)
- }
- bytesResult = byteBuffer.toByteArray()
- } finally {
- try {
- byteBuffer.close()
- } catch (ignored: IOException) {
- }
- }
- return bytesResult
- }
-
// Copied out of React Native's `NetworkingModule.java`
private fun translateHeaders(headers: Headers): Bundle {
val responseHeaders = Bundle()
diff --git a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyReader.kt b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyReader.kt
new file mode 100644
index 00000000000000..91c41295489e2a
--- /dev/null
+++ b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/legacy/FileSystemLegacyReader.kt
@@ -0,0 +1,57 @@
+package expo.modules.filesystem.legacy
+
+import android.util.Base64
+import java.io.InputStream
+
+// `encoding` is nullable because the record converter leaves fields absent from the JS options
+// object null instead of applying the `ReadingOptions` default. Anything that is not base64 is
+// read as UTF-8, which is what the module did before this function existed.
+internal fun readInputStreamAsString(
+ inputStream: InputStream,
+ encoding: EncodingType?,
+ options: ReadingOptions
+): String {
+ val bytes = inputStream.readBytes(options)
+
+ return if (encoding == EncodingType.BASE64) {
+ Base64.encodeToString(bytes, Base64.NO_WRAP)
+ } else {
+ String(bytes, Charsets.UTF_8)
+ }
+}
+
+private fun InputStream.readBytes(options: ReadingOptions): ByteArray {
+ if (options.length != null && options.position != null) {
+ skipBytes(options.position.toLong())
+ return readByteRange(options.length)
+ }
+ return readBytes()
+}
+
+private fun InputStream.skipBytes(position: Long) {
+ var remaining = position
+ while (remaining > 0) {
+ val skipped = skip(remaining)
+ if (skipped > 0) {
+ remaining -= skipped
+ continue
+ }
+ if (read() == -1) {
+ break
+ }
+ remaining--
+ }
+}
+
+private fun InputStream.readByteRange(length: Int): ByteArray {
+ val buffer = ByteArray(length)
+ var offset = 0
+ while (offset < length) {
+ val bytesRead = read(buffer, offset, length - offset)
+ if (bytesRead <= 0) {
+ break
+ }
+ offset += bytesRead
+ }
+ return buffer.copyOf(offset)
+}
diff --git a/packages/expo-file-system/android/src/test/java/expo/modules/filesystem/legacy/FileSystemLegacyReaderTest.kt b/packages/expo-file-system/android/src/test/java/expo/modules/filesystem/legacy/FileSystemLegacyReaderTest.kt
new file mode 100644
index 00000000000000..7584b743a0f377
--- /dev/null
+++ b/packages/expo-file-system/android/src/test/java/expo/modules/filesystem/legacy/FileSystemLegacyReaderTest.kt
@@ -0,0 +1,70 @@
+package expo.modules.filesystem.legacy
+
+import android.os.Build
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.ByteArrayInputStream
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.R])
+class FileSystemLegacyReaderTest {
+ @Test
+ fun readsUtf8ByteRange() {
+ val result = readInputStreamAsString(
+ inputStream = ByteArrayInputStream("alpha beta gamma".toByteArray(Charsets.UTF_8)),
+ encoding = EncodingType.UTF8,
+ options = ReadingOptions(encoding = EncodingType.UTF8, position = 6, length = 4)
+ )
+
+ assertEquals("beta", result)
+ }
+
+ @Test
+ fun readsFullUtf8StringWithoutByteRange() {
+ val result = readInputStreamAsString(
+ inputStream = ByteArrayInputStream("alpha beta".toByteArray(Charsets.UTF_8)),
+ encoding = EncodingType.UTF8,
+ options = ReadingOptions(encoding = EncodingType.UTF8, position = null, length = null)
+ )
+
+ assertEquals("alpha beta", result)
+ }
+
+ @Test
+ fun keepsBase64ByteRangeBehavior() {
+ val result = readInputStreamAsString(
+ inputStream = ByteArrayInputStream("alpha beta gamma".toByteArray(Charsets.UTF_8)),
+ encoding = EncodingType.BASE64,
+ options = ReadingOptions(encoding = EncodingType.BASE64, position = 6, length = 4)
+ )
+
+ assertEquals("YmV0YQ==", result)
+ }
+
+ @Test
+ fun readsUtf8WhenEncodingIsMissing() {
+ // `ReadingOptions` is built by the Expo modules record converter, which leaves fields absent
+ // from the JS object null instead of applying the Kotlin default, so `encoding` can be null.
+ val result = readInputStreamAsString(
+ inputStream = ByteArrayInputStream("alpha beta".toByteArray(Charsets.UTF_8)),
+ encoding = null,
+ options = ReadingOptions(encoding = EncodingType.UTF8, position = null, length = null)
+ )
+
+ assertEquals("alpha beta", result)
+ }
+
+ @Test
+ fun returnsEmptyStringWhenByteRangeStartsAfterEndOfStream() {
+ val result = readInputStreamAsString(
+ inputStream = ByteArrayInputStream("alpha".toByteArray(Charsets.UTF_8)),
+ encoding = EncodingType.UTF8,
+ options = ReadingOptions(encoding = EncodingType.UTF8, position = 10, length = 5)
+ )
+
+ assertEquals("", result)
+ }
+}
diff --git a/packages/expo-file-system/ios/Legacy/FileSystemHelpers.swift b/packages/expo-file-system/ios/Legacy/FileSystemHelpers.swift
index fc2fb06d5bab8b..79c37f1c19937f 100644
--- a/packages/expo-file-system/ios/Legacy/FileSystemHelpers.swift
+++ b/packages/expo-file-system/ios/Legacy/FileSystemHelpers.swift
@@ -14,19 +14,33 @@ internal func ensureFileDirectoryExists(_ fileUrl: URL) throws {
}
internal func readFileAsBase64(path: String, options: ReadingOptions) throws -> String {
+ return try readFileData(path: path, options: options).base64EncodedString(options: .endLineWithLineFeed)
+}
+
+internal func readFileAsString(path: String, encoding: String.Encoding, options: ReadingOptions) throws -> String {
+ guard let string = String(data: try readFileData(path: path, options: options), encoding: encoding) else {
+ throw FileNotReadableException(path)
+ }
+ return string
+}
+
+private func readFileData(path: String, options: ReadingOptions) throws -> Data {
let file = FileHandle(forReadingAtPath: path)
guard let file else {
throw FileNotExistsException(path)
}
+ defer {
+ try? file.close()
+ }
if let position = options.position, position != 0 {
// TODO: Handle these errors?
try? file.seek(toOffset: UInt64(position))
}
if let length = options.length {
- return file.readData(ofLength: length).base64EncodedString(options: .endLineWithLineFeed)
+ return file.readData(ofLength: length)
}
- return file.readDataToEndOfFile().base64EncodedString(options: .endLineWithLineFeed)
+ return file.readDataToEndOfFile()
}
internal func writeFileAsBase64(path: String, string: String) throws {
diff --git a/packages/expo-file-system/ios/Legacy/FileSystemLegacyModule.swift b/packages/expo-file-system/ios/Legacy/FileSystemLegacyModule.swift
index db3fe02758e987..0db6a3f9c25b03 100644
--- a/packages/expo-file-system/ios/Legacy/FileSystemLegacyModule.swift
+++ b/packages/expo-file-system/ios/Legacy/FileSystemLegacyModule.swift
@@ -75,11 +75,11 @@ public final class FileSystemLegacyModule: Module {
if options.encoding == .base64 {
return try readFileAsBase64(path: url.path, options: options)
}
- do {
- return try String(contentsOfFile: url.path, encoding: options.encoding.toStringEncoding() ?? .utf8)
- } catch {
- throw FileNotReadableException(url.path)
- }
+ return try readFileAsString(
+ path: url.path,
+ encoding: options.encoding.toStringEncoding() ?? .utf8,
+ options: options
+ )
}
AsyncFunction("writeAsStringAsync") { (url: URL, string: String, options: WritingOptions) in
diff --git a/packages/expo-file-system/src/legacy/FileSystem.types.ts b/packages/expo-file-system/src/legacy/FileSystem.types.ts
index 25c9c6c8eed3b0..a76145f4850635 100644
--- a/packages/expo-file-system/src/legacy/FileSystem.types.ts
+++ b/packages/expo-file-system/src/legacy/FileSystem.types.ts
@@ -254,11 +254,11 @@ export type ReadingOptions = {
*/
encoding?: EncodingType | 'utf8' | 'base64';
/**
- * Optional number of bytes to skip. This option is only used when `encoding: FileSystem.EncodingType.Base64` and `length` is defined.
+ * Optional number of bytes to skip before reading. This option is only used when `length` is defined.
* */
position?: number;
/**
- * Optional number of bytes to read. This option is only used when `encoding: FileSystem.EncodingType.Base64` and `position` is defined.
+ * Optional number of bytes to read. This option is only used when `position` is defined.
*/
length?: number;
};
From f63defb5124f38649dd0f80fde3f46ee60b03035 Mon Sep 17 00:00:00 2001
From: Hassan Khan
Date: Mon, 17 Aug 2026 12:54:37 +0100
Subject: [PATCH 02/14] [router] Remove old root entrypoints (#49001)
# Why
`expo-router` now uses [subpath
exports](https://nodejs.org/api/packages.html#subpath-exports) so these
files are unnecessary.
Additionally, we made a mistake when declaring the `split-view` subpath
in `expo-router/package.json`, it should have been
`unstable-split-view`; this has now been rectified.
# How
Removed all files in `expo-router`'s root directory that have a subpath
declaration in `package.json`.
# 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)
- [x] 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)
---
packages/expo-router/AGENTS.md | 14 --------------
packages/expo-router/CHANGELOG.md | 2 ++
packages/expo-router/package.json | 30 +-----------------------------
3 files changed, 3 insertions(+), 43 deletions(-)
diff --git a/packages/expo-router/AGENTS.md b/packages/expo-router/AGENTS.md
index 7cd69d6269eb59..191452ea0b3055 100644
--- a/packages/expo-router/AGENTS.md
+++ b/packages/expo-router/AGENTS.md
@@ -112,20 +112,6 @@ File-based routing library for React Native and web applications. It provides au
βββ android/ # Native Android code (Kotlin)
β βββ ExpoRouterModule.kt # Material 3 dynamic and static color resolution
βββ entry.js # Module entry point
-βββ head.js # Head/meta tags entrypoint - import Head from "expo-router/head"
-βββ server.js # Legacy root shim for expo-router/server - delegates to build/server (source: src/server/index.ts, re-exports loader utilities from expo-server)
-βββ server.d.ts # Legacy root shim types - delegates to build/server
-βββ drawer.js # Drawer navigator - import { Drawer } from "expo-router/drawer"
-βββ stack.js # Stack navigator - import { Stack } from "expo-router/stack"
-βββ js-stack.js # JS stack navigator - import { Stack } from "expo-router/js-stack"
-βββ tabs.js # JS tab navigator (deprecated) - import { Tabs } from "expo-router/tabs"
-βββ js-tabs.js # JS tab navigator - import { Tabs } from "expo-router/js-tabs"
-βββ js-top-tabs.js # JS top tab navigator - import { TopTabs } from "expo-router/js-top-tabs"
-βββ html.js # HTML document wrapper for web - import { Html } from "expo-router/html"
-βββ ui.js # Headless UI tabs components - import { Tabs } from "expo-router/ui"
-βββ unstable-native-tabs.js # Native bottom tabs - import { NativeTabs } from "expo-router/unstable-native-tabs"
-βββ unstable-split-view.js # Split view layout - import { SplitView } from "expo-router/unstable-split-view"
-βββ testing-library.js # Testing utilities - import { renderRouter } from "expo-router/testing-library"
βββ build/ # Compiled JavaScript output
```
diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md
index af0173ea9d6f1f..eb0cd450b107f5 100644
--- a/packages/expo-router/CHANGELOG.md
+++ b/packages/expo-router/CHANGELOG.md
@@ -56,6 +56,7 @@
- Fix missing subpath warning from Metro when importing from `expo-router/server` ([#48045](https://github.com/expo/expo/pull/48045) by [@hassankhan](https://github.com/hassankhan))
- Fix `replace` navigation in tabs leaving the replaced route in history. ([#48256](https://github.com/expo/expo/pull/48256) by [@Ubax](https://github.com/Ubax))
- Prevent `useLoaderData()` from re-rendering readers of unrelated loader paths ([#48523](https://github.com/expo/expo/pull/48523) by [@hassankhan](https://github.com/hassankhan))
+- Fix package export for `expo-router/unstable-split-view` ([#49001](https://github.com/expo/expo/pull/49001) by [@hassankhan](https://github.com/hassankhan))
### π‘ Others
@@ -71,6 +72,7 @@
- [Internal] Split `useLoaderData()` into a document cache and a per-mount Suspense store ([#47365](https://github.com/expo/expo/pull/47365) by [@hassankhan](https://github.com/hassankhan))
- [Internal] Read the development server URL from `expo/internal/bundle-origin` instead of duplicating its accessor ([#48278](https://github.com/expo/expo/pull/48278) by [@kitten](https://github.com/kitten))
- [Internal] Isolate the loader's Suspense store from `LoaderClient` ([#48563](https://github.com/expo/expo/pull/48563) by [@hassankhan](https://github.com/hassankhan))
+- [Internal] Remove legacy root entrypoint shims ([#49001](https://github.com/expo/expo/pull/49001) by [@hassankhan](https://github.com/hassankhan))
## 57.0.9 - 2026-07-29
diff --git a/packages/expo-router/package.json b/packages/expo-router/package.json
index bfe99c82ea32f9..14f2780ef2af46 100644
--- a/packages/expo-router/package.json
+++ b/packages/expo-router/package.json
@@ -32,42 +32,14 @@
"_error.js",
"app.plugin.js",
"babel.js",
- "drawer.d.ts",
- "drawer.js",
"entry-classic.js",
"entry.js",
"expo-module.config.json",
- "head.d.ts",
- "head.js",
- "html.d.ts",
- "html.js",
"index.d.ts",
"ios",
- "unstable-native-tabs.js",
- "unstable-native-tabs.d.ts",
- "unstable-split-view.js",
- "unstable-split-view.d.ts",
"node",
"plugin",
- "react-navigation.js",
- "react-navigation.d.ts",
"rsc",
- "server.d.ts",
- "server.js",
- "stack.d.ts",
- "stack.js",
- "js-tabs.js",
- "js-tabs.d.ts",
- "js-stack.js",
- "js-stack.d.ts",
- "js-top-tabs.js",
- "js-top-tabs.d.ts",
- "tabs.js",
- "tabs.d.ts",
- "ui.js",
- "ui.d.ts",
- "testing-library.js",
- "testing-library.d.ts",
"_async-server-import.js"
],
"sideEffects": [
@@ -230,7 +202,7 @@
"expo-source": "./src/native-tabs/index.ts",
"default": "./build/native-tabs/index.js"
},
- "./split-view": {
+ "./unstable-split-view": {
"types": {
"expo-source": "./src/split-view/index.ts",
"default": "./build/split-view/index.d.ts"
From e0e20628211819b460f49895593dc994e56cf80b Mon Sep 17 00:00:00 2001
From: Aman Mittal
Date: Mon, 17 Aug 2026 17:32:51 +0530
Subject: [PATCH 03/14] [docs] Skip the latest-version link when the page is
unversioned only (#49004)
# Why
Fix ENG-25994 https://github.com/expo/expo/issues/49002
# How
The unversioned page banner now hides its "latest version" link when
that page doesn't exist in the latest SDK.
# Test Plan
# 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)
---
docs/common/routes.test.ts | 64 ++++++++++++++++++++++++++-
docs/common/routes.ts | 27 +++++++++++
docs/components/DocumentationPage.tsx | 15 +++++--
3 files changed, 102 insertions(+), 4 deletions(-)
diff --git a/docs/common/routes.test.ts b/docs/common/routes.test.ts
index 1e1c02655c3a88..4f2c21402cffec 100644
--- a/docs/common/routes.test.ts
+++ b/docs/common/routes.test.ts
@@ -1,6 +1,6 @@
import type { NavigationRoute } from '~/types/common';
-import { getBreadcrumbTrail, isReferencePath } from './routes';
+import { getBreadcrumbTrail, getLatestVersionPath, isReferencePath } from './routes';
describe(isReferencePath, () => {
it('returns true for unversioned pathname', () => {
@@ -20,6 +20,68 @@ describe(isReferencePath, () => {
});
});
+describe(getLatestVersionPath, () => {
+ const latestRoutes: NavigationRoute[] = [
+ {
+ type: 'section',
+ name: 'Expo SDK',
+ href: '',
+ children: [
+ { type: 'page', name: 'Notifications', href: '/versions/latest/sdk/notifications' },
+ {
+ type: 'group',
+ name: 'Expo UI',
+ href: '',
+ children: [
+ {
+ type: 'page',
+ name: 'Jetpack Compose',
+ href: '/versions/latest/sdk/ui/jetpack-compose',
+ isIndex: true,
+ },
+ { type: 'page', name: 'Box', href: '/versions/latest/sdk/ui/jetpack-compose/box' },
+ ],
+ },
+ ],
+ },
+ ];
+
+ it('maps the pathname to latest when that page exists', () => {
+ expect(getLatestVersionPath(latestRoutes, '/versions/unversioned/sdk/notifications')).toBe(
+ '/versions/latest/sdk/notifications'
+ );
+ });
+
+ it('returns undefined when the page is missing from latest', () => {
+ expect(
+ getLatestVersionPath(latestRoutes, '/versions/unversioned/sdk/ui/jetpack-compose/image')
+ ).toBeUndefined();
+ });
+
+ it('does not match a section index for a missing child page', () => {
+ expect(
+ getLatestVersionPath(latestRoutes, '/versions/unversioned/sdk/ui/jetpack-compose/image/')
+ ).toBeUndefined();
+ });
+
+ it('ignores a trailing slash on the pathname', () => {
+ expect(
+ getLatestVersionPath(latestRoutes, '/versions/unversioned/sdk/ui/jetpack-compose/box/')
+ ).toBe('/versions/latest/sdk/ui/jetpack-compose/box');
+ });
+
+ it('skips null entries in route arrays', () => {
+ const routes = [
+ null,
+ { type: 'page', name: 'Box', href: '/versions/latest/sdk/ui/jetpack-compose/box' },
+ ] as unknown as NavigationRoute[];
+
+ expect(getLatestVersionPath(routes, '/versions/unversioned/sdk/ui/jetpack-compose/box')).toBe(
+ '/versions/latest/sdk/ui/jetpack-compose/box'
+ );
+ });
+});
+
describe(getBreadcrumbTrail, () => {
const mockRoutes: NavigationRoute[] = [
{
diff --git a/docs/common/routes.ts b/docs/common/routes.ts
index 115ee78898b048..eefeeb3c0a121a 100644
--- a/docs/common/routes.ts
+++ b/docs/common/routes.ts
@@ -102,6 +102,33 @@ export const getCanonicalUrl = (path: string) => {
}
};
+function collectPageHrefs(routes: NavigationRoute[], acc: Set) {
+ for (const route of routes) {
+ if (!route) {
+ continue;
+ }
+ if (route.type === 'page' && route.href) {
+ acc.add(route.href);
+ }
+ if (route.children) {
+ collectPageHrefs(route.children, acc);
+ }
+ }
+}
+
+/**
+ * Resolve the path shown in the banner on unversioned pages. Returns undefined when the page has no
+ * counterpart in `latest`, which is the case for pages added after an SDK cut.
+ */
+export function getLatestVersionPath(routes: NavigationRoute[], pathname: string) {
+ const path = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
+ const latestPath = Utilities.replaceVersionInUrl(path, 'latest');
+ const pages = new Set();
+ collectPageHrefs(routes, pages);
+
+ return pages.has(latestPath) ? latestPath : undefined;
+}
+
export const getMarkdownPath = (asPath: string) => {
const path = asPath.split('?')[0].split('#')[0];
if (path === '' || path === '/') {
diff --git a/docs/components/DocumentationPage.tsx b/docs/components/DocumentationPage.tsx
index f76b530535432f..cb0cadc62fe127 100644
--- a/docs/components/DocumentationPage.tsx
+++ b/docs/components/DocumentationPage.tsx
@@ -83,6 +83,10 @@ export default function DocumentationPage({
version !== 'unversioned' && !RoutesUtils.isInternalPath(pathname)
? RoutesUtils.getCanonicalUrl(pathname)
: undefined;
+ const latestVersionPath =
+ version === 'unversioned'
+ ? RoutesUtils.getLatestVersionPath(RoutesUtils.getRoutes(pathname, 'latest'), pathname)
+ : undefined;
const techArticleSchema =
title && canonicalUrl
? buildTechArticleSchema({ title, description, modificationDate, url: canonicalUrl })
@@ -380,9 +384,14 @@ export default function DocumentationPage({
{version && version === 'unversioned' && (
- This is documentation for the next SDK version. For up-to-date documentation, see the{' '}
- latest version (
- {versionToText(LATEST_VERSION)}).
+ This is documentation for the next SDK version.
+ {latestVersionPath && (
+ <>
+ {' '}
+ For up-to-date documentation, see the{' '}
+ latest version ({versionToText(LATEST_VERSION)}).
+ >
+ )}
)}
{title && (
From 1a9f3827cfe5b27816d82d19754fe17ad6c64952 Mon Sep 17 00:00:00 2001
From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com>
Date: Mon, 17 Aug 2026 13:30:20 +0100
Subject: [PATCH 04/14] [ios][expo-go] Improve the local network permission
flow (#48962)
# Why
The local network grant was only persisted when a dev server was
discovered, so users who allowed the permission with no server running
were re-prompted on every launch.
# How
Discovery now advertises its own Bonjour service, so a granted
permission always produces a browse result, and the grant is persisted
only when results arrive. The startup prompt is skipped until onboarding
has finished, arriving from an deep link marks onboarding as done, and a
banner on the home tab offers the permission flow whenever access is
missing.
# Test Plan
Clean install, complete onboarding, confirm the permission screen
appears on the next launch. Allow with no dev server running, relaunch,
and confirm no re-prompt. Deny, relaunch, and confirm the screen
returns. Open a project from a QR code on a fresh install and confirm
onboarding is skipped. With permission missing, tap the home banner and
complete the flow.
---
.../ios/Client/SwiftUI/HomeRootView.swift | 1 +
.../ios/Client/SwiftUI/HomeTabView.swift | 2 +
.../Services/DevelopmentServerService.swift | 49 ++++++++++++++---
.../Views/NetworkPermissionBanner.swift | 52 +++++++++++++++++++
.../Kernel/Services/EXKernelLinkingManager.m | 3 ++
5 files changed, 100 insertions(+), 7 deletions(-)
create mode 100644 apps/expo-go/ios/Client/SwiftUI/Views/NetworkPermissionBanner.swift
diff --git a/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift b/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift
index 1024c47b1b7581..10c814a71e7129 100644
--- a/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift
@@ -39,6 +39,7 @@ struct HomeRootView: View {
self.viewModel = viewModel
let shouldSkip = DevelopmentServerService.isSimulator
|| UserDefaults.standard.bool(forKey: DevelopmentServerService.networkPermissionGrantedKey)
+ || !UserDefaults.standard.bool(forKey: "ExpoGoOnboardingFinished")
_hasCompletedPermissionFlow = State(initialValue: shouldSkip)
}
diff --git a/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift b/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift
index 37bfd44f6a0a3a..178185b2d5da35 100644
--- a/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift
@@ -24,6 +24,8 @@ struct HomeTabView: View {
UpgradeWarningView()
+ NetworkPermissionBanner(serverService: viewModel.serverService)
+
DevServersSection()
if !viewModel.recentlyOpenedApps.isEmpty {
diff --git a/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift b/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift
index 73060250245d25..9b0572cd9a29c0 100644
--- a/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/Services/DevelopmentServerService.swift
@@ -26,6 +26,8 @@ class DevelopmentServerService: ObservableObject {
private var sessionSecret: String?
private var remoteRefreshTask: Task?
private var browser: NWBrowser?
+ private var probeListener: NWListener?
+ private let probeServiceName = "expo-go-permission-probe"
private var pingTask: Task?
private var isFetchingRemote = false
@@ -59,7 +61,20 @@ class DevelopmentServerService: ObservableObject {
permissionStatus = .granted
}
+ func markNetworkPermissionDenied() {
+ UserDefaults.standard.set(false, forKey: Self.networkPermissionGrantedKey)
+ permissionStatus = .denied
+ }
+
func checkLocalNetworkAccess() async -> Bool {
+ let granted = await probeLocalNetworkAccess()
+ if granted {
+ markNetworkPermissionGranted()
+ }
+ return granted
+ }
+
+ private func probeLocalNetworkAccess() async -> Bool {
let serviceType = bonjourType
let queue = DispatchQueue(label: "expo.go.permissioncheck")
@@ -326,11 +341,11 @@ class DevelopmentServerService: ObservableObject {
switch state {
case .waiting(let error):
if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied {
- self.permissionStatus = .denied
+ self.markNetworkPermissionDenied()
}
case .failed(let error):
if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied {
- self.permissionStatus = .denied
+ self.markNetworkPermissionDenied()
}
default:
break
@@ -342,28 +357,48 @@ class DevelopmentServerService: ObservableObject {
guard let self else { return }
Task { @MainActor [weak self, results] in
guard let self else { return }
+ // Results only flow once the permission is truly granted, so this is the reliable signal.
self.markNetworkPermissionGranted()
+ self.probeListener?.cancel()
+ self.probeListener = nil
self.pingTask?.cancel()
self.pingTask = Task {
defer { self.pingTask = nil }
- await self.pingDiscoveryResults(results.map { result in
- DiscoveryResult(
- name: NetworkUtilities.getNWBrowserResultName(result),
- endpoint: result.endpoint
- )
+ await self.pingDiscoveryResults(results.compactMap { result in
+ let name = NetworkUtilities.getNWBrowserResultName(result)
+ if name?.hasPrefix(self.probeServiceName) == true {
+ return nil
+ }
+ return DiscoveryResult(name: name, endpoint: result.endpoint)
})
}
}
}
+ startProbeListener()
browser?.start(queue: DispatchQueue(label: "expo.go.bonjour.discovery"))
}
+ // Advertise our own service so a granted permission always produces at least one browse
+ // result, even when no dev servers are running.
+ private func startProbeListener() {
+ guard let listener = try? NWListener(using: .tcp, on: .any) else {
+ return
+ }
+ listener.service = NWListener.Service(name: probeServiceName, type: bonjourType)
+ listener.stateUpdateHandler = { _ in }
+ listener.newConnectionHandler = { $0.cancel() }
+ listener.start(queue: DispatchQueue(label: "expo.go.bonjour.probe"))
+ probeListener = listener
+ }
+
private func stopBonjourBrowser() {
pingTask?.cancel()
browser?.cancel()
+ probeListener?.cancel()
pingTask = nil
browser = nil
+ probeListener = nil
}
private func pingDiscoveryResults(_ results: [DiscoveryResult]) async {
diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/NetworkPermissionBanner.swift b/apps/expo-go/ios/Client/SwiftUI/Views/NetworkPermissionBanner.swift
new file mode 100644
index 00000000000000..77177ddea5d1ab
--- /dev/null
+++ b/apps/expo-go/ios/Client/SwiftUI/Views/NetworkPermissionBanner.swift
@@ -0,0 +1,52 @@
+// Copyright Β© 2025 650 Industries. All rights reserved.
+
+import SwiftUI
+
+struct NetworkPermissionBanner: View {
+ @ObservedObject var serverService: DevelopmentServerService
+ @State private var showingPermissionFlow = false
+
+ var body: some View {
+ Group {
+ // `showingPermissionFlow` keeps the banner alive while its sheet is up: the grant is detected the
+ // moment the system prompt appears, and hiding the banner then would tear the sheet down with it.
+ if showingPermissionFlow
+ || (!DevelopmentServerService.isSimulator
+ && !serverService.hasGrantedNetworkPermission
+ && serverService.permissionStatus != .granted) {
+ Button {
+ showingPermissionFlow = true
+ } label: {
+ HStack {
+ Image(systemName: "wifi.exclamationmark")
+ .font(.title2)
+ .foregroundColor(.orange)
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Local Network Access Needed")
+ .font(.subheadline)
+ .fontWeight(.semibold)
+ .foregroundColor(.primary)
+ Text("Projects running on your computer can't be discovered. Tap to enable access.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.leading)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ }
+ .buttonStyle(PlainButtonStyle())
+ .background(Color.expoSecondarySystemBackground)
+ .cornerRadius(18)
+ }
+ }
+ .sheet(isPresented: $showingPermissionFlow) {
+ LocalNetworkPermissionView(serverService: serverService) {
+ serverService.startDiscovery()
+ showingPermissionFlow = false
+ }
+ }
+ }
+}
diff --git a/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m b/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m
index 645c7ee756fc4c..5af88838b35c43 100644
--- a/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m
+++ b/apps/expo-go/ios/Exponent/Kernel/Services/EXKernelLinkingManager.m
@@ -33,6 +33,9 @@ - (void)openUrl:(NSString *)urlString isUniversalLink:(BOOL)isUniversalLink
DDLogInfo(@"Tried to route invalid url: %@", urlString);
return;
}
+ // An external link means the user already has a project to open, so never gate them behind onboarding.
+ [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"ExpoGoOnboardingFinished"];
+
EXKernelAppRegistry *appRegistry = [EXKernel sharedInstance].appRegistry;
EXKernelAppRecord *destinationApp = nil;
NSURL *urlToRoute = [[self class] uriTransformedForLinking:url isUniversalLink:isUniversalLink];
From 13068ce95081a25486731b1927814ac4ab704e91 Mon Sep 17 00:00:00 2001
From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com>
Date: Mon, 17 Aug 2026 13:30:20 +0100
Subject: [PATCH 05/14] [ios][expo-go] Keep list content during pull-to-refresh
and ignore cancelled requests (#48963)
# Why
Pull-to-refresh on the Projects and Snacks screens showed an immediate
"Network error: cancelled" alert. Clearing the list before fetching
swapped the rows, which cancelled the refreshable task and surfaced the
cancellation as an error.
# How
Refresh keeps the current rows until fresh data replaces them
# Test Plan
Pull to refresh on Projects and Snacks. Works as expected
---
apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift | 9 +++++++++
.../ios/Client/SwiftUI/Views/BranchDetailsView.swift | 3 +++
.../ios/Client/SwiftUI/Views/ProjectsListView.swift | 4 +++-
.../ios/Client/SwiftUI/Views/SnacksListView.swift | 4 +++-
4 files changed, 18 insertions(+), 2 deletions(-)
diff --git a/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift b/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift
index 60915a40ad4bce..a1a67222d3efb9 100644
--- a/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift
@@ -27,6 +27,15 @@ enum APIError: LocalizedError {
}
}
+ // Cancellation happens whenever SwiftUI tears down a refreshable or task modifier; it is
+ // never worth surfacing to the user.
+ var isCancellation: Bool {
+ if case .networkError(let error) = self {
+ return error is CancellationError || (error as? URLError)?.code == .cancelled
+ }
+ return false
+ }
+
var isAuthenticationError: Bool {
if case .httpError(let statusCode, _) = self, statusCode == 401 {
return true
diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift b/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift
index 1adf4a406ed69e..ba9b9bafb1ecbb 100644
--- a/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift
@@ -171,6 +171,9 @@ class BranchDetailsViewModel: ObservableObject {
branch = response.data.app.byId.updateBranchByName
hasLoadedRemote = true
} catch {
+ if (error as? APIError)?.isCancellation == true {
+ return
+ }
self.error = error
}
}
diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift b/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift
index b3fed8cc83019c..e4d82bfb3914d1 100644
--- a/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/Views/ProjectsListView.swift
@@ -93,7 +93,6 @@ class ProjectsListViewModel: ObservableObject {
func refresh() async {
currentOffset = 0
- projects = []
await fetchProjects()
}
@@ -129,6 +128,9 @@ class ProjectsListViewModel: ObservableObject {
hasMore = projects.count < totalCount
} catch {
+ if (error as? APIError)?.isCancellation == true {
+ return
+ }
self.error = error
self.showingError = true
}
diff --git a/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift b/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift
index 8979f52ee577d0..25bb4f4644e6cd 100644
--- a/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/Views/SnacksListView.swift
@@ -92,7 +92,6 @@ class SnacksListViewModel: ObservableObject {
func refresh() async {
currentOffset = 0
- snacks = []
await fetchSnacks()
}
@@ -126,6 +125,9 @@ class SnacksListViewModel: ObservableObject {
hasMore = newSnacks.count >= pageSize
} catch {
+ if (error as? APIError)?.isCancellation == true {
+ return
+ }
self.error = error
self.showingError = true
}
From 522549c7d4908bf8a9e3f5039544fd4acd5b4669 Mon Sep 17 00:00:00 2001
From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com>
Date: Mon, 17 Aug 2026 13:30:21 +0100
Subject: [PATCH 06/14] [ios][expo-go] Deduplicate recently opened entries for
published updates (#48964)
# Why
Every published update has a unique manifest link, so opening successive
updates of the same project filled Recently Opened with duplicate rows
that all showed the same name.
# How
Entries are also treated as duplicates when both URLs are update links
and the display name matches.
# Test Plan
Publish two updates to the same branch and open each. Recently Opened
shows a single row.
---
apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift | 11 ++++++++---
apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift | 7 +++++++
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift b/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift
index 34c5b4b6dd8f23..613f838e942a56 100644
--- a/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift
@@ -147,9 +147,14 @@ class HomeViewModel: ObservableObject {
func addToRecentlyOpened(url: String, name: String, iconUrl: String? = nil) {
let normalizedUrl = normalizeUrl(url)
- if let existingIndex = recentlyOpenedApps.firstIndex(where: {
+ // Update permalinks are unique per published update, so entries for the same app are
+ // matched by name instead of URL to avoid one row per update.
+ let isDuplicate: (RecentlyOpenedApp) -> Bool = {
normalizeUrl($0.url) == normalizedUrl
- }) {
+ || (isUpdatePermalink($0.url) && isUpdatePermalink(url) && $0.name == name)
+ }
+
+ if let existingIndex = recentlyOpenedApps.firstIndex(where: isDuplicate) {
let existingApp = recentlyOpenedApps[existingIndex]
if existingApp.name == name && iconUrl != nil && existingApp.iconUrl == nil {
@@ -160,7 +165,7 @@ class HomeViewModel: ObservableObject {
return
}
- recentlyOpenedApps.remove(at: existingIndex)
+ recentlyOpenedApps.removeAll(where: isDuplicate)
}
let newApp = RecentlyOpenedApp(
diff --git a/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift b/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift
index 0eac35a7e3ccaa..3f69addeac5368 100644
--- a/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift
+++ b/apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift
@@ -23,6 +23,13 @@ func normalizeUrl(_ url: String) -> String {
return components.joined()
}
+func isUpdatePermalink(_ url: String) -> Bool {
+ guard let components = URLComponents(string: url) else {
+ return false
+ }
+ return components.host == "u.expo.dev" && components.path.hasPrefix("/update/")
+}
+
func sanitizeUrlString(_ urlString: String) -> String? {
var sanitizedUrl = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
From 724ec56f2a0834175886f3e8436376e154c43930 Mon Sep 17 00:00:00 2001
From: Gabriel Donadel Dall'Agnol
Date: Mon, 17 Aug 2026 10:28:24 -0300
Subject: [PATCH 07/14] [brownfield][ios] Fix ARG_MAX build failure with
multipleFrameworks (#47999)
# Why
Sometimes building a brownfield app configured with `multipleFrameworks:
true` will fail with a `ARG_MAX` error due to the way we mangle
Objective-C symbols and append the list of symbols to each pod's
`GCC_PREPROCESSOR_DEFINITIONS`
# How
Instead of inlining the renames as `-D` flags, we now emit them as
`#define` lines in a generated header and force-include that header
(`-include "$(MANGLING_HEADER)"` on `OTHER_CFLAGS`).
# Test Plan
- Added unit tests for the symbol transform covering the new filtering,
selector distinctiveness, setter/ivar symmetry, and the
`__OBJC__`-guarded header output.
- Manual: prebuild and `pod install` an app configured with
`multipleFrameworks: true`, then build for the simulator.
# 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)
---------
Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com>
---
packages/expo-brownfield/CHANGELOG.md | 1 +
.../expo-brownfield/cli/src/utils/mangle.ts | 183 +++++++++++++++---
.../e2e/cli/__tests__/mangle.test.ts | 71 +++++++
3 files changed, 223 insertions(+), 32 deletions(-)
create mode 100644 packages/expo-brownfield/e2e/cli/__tests__/mangle.test.ts
diff --git a/packages/expo-brownfield/CHANGELOG.md b/packages/expo-brownfield/CHANGELOG.md
index 15087c2f6d6f1a..4fe49a8636ddbd 100644
--- a/packages/expo-brownfield/CHANGELOG.md
+++ b/packages/expo-brownfield/CHANGELOG.md
@@ -9,6 +9,7 @@
### π Bug fixes
- [android] Fix `brownfield.fused.strip-packages` corrupting the generated `ExpoModulesPackageList.kt` when given a broad prefix (e.g. `expo.modules`). ([@gabrieldonadel](https://github.com/gabrieldonadel)) ([#48118](https://github.com/expo/expo/pull/48118) by [@gabrieldonadel](https://github.com/gabrieldonadel))
+- [iOS] Fix `ARG_MAX` error when using `multipleFrameworks`. ([#47999](https://github.com/expo/expo/pull/47999) by [@gabrieldonadel](https://github.com/gabrieldonadel))
### π‘ Others
diff --git a/packages/expo-brownfield/cli/src/utils/mangle.ts b/packages/expo-brownfield/cli/src/utils/mangle.ts
index bd9f5a2ea485dd..f27c0b033cd759 100644
--- a/packages/expo-brownfield/cli/src/utils/mangle.ts
+++ b/packages/expo-brownfield/cli/src/utils/mangle.ts
@@ -11,8 +11,9 @@ export interface MangleContext {
specsChecksum: string;
}
-const MANGLING_DEFINES_KEY = 'MANGLING_DEFINES';
+const MANGLING_HEADER_KEY = 'MANGLING_HEADER';
const MANGLED_SPECS_CHECKSUM_KEY = 'MANGLED_SPECS_CHECKSUM';
+const MANGLING_HEADER_FILE_NAME = 'expo-brownfield-mangle.h';
const BUILD_DIR_NAME = 'build';
const BUILT_PRODUCTS_SUBDIR = path.join('build', 'Release-iphonesimulator');
@@ -40,6 +41,7 @@ const SWIFT_SYMBOL_PATTERNS: RegExp[] = [
/_\w+_swiftoverride_/,
/_Z\w+swift/,
/get_witness_table /,
+ /get_type_metadata /,
];
const isSwiftSymbol = (line: string): boolean => SWIFT_SYMBOL_PATTERNS.some((re) => re.test(line));
@@ -179,9 +181,27 @@ const extractConstants = (lines: string[]): string[] => {
.filter((line) => !/__destroy_helper_block.*/.test(line))
.map((line) => line.replace(/^.* _/, ''));
- return Array.from(new Set([...sConsts, ...tConsts]));
+ // Drop Itanium C++ mangled names (`_Z...`) and any symbol with whitespace:
+ // a `#define` can't rewrite them (mangled names never appear as source
+ // tokens; a name with spaces is invalid and collapses symbols onto one
+ // macro).
+ return Array.from(new Set([...sConsts, ...tConsts])).filter(
+ (sym) => !sym.startsWith('_Z') && !/\s/.test(sym)
+ );
};
+/**
+ * A `#define` for a bare word like `props` or `load` would also rewrite
+ * unrelated C++ identifiers (`std::atomic::load`) in the force-included header,
+ * breaking compilation. Requiring an uppercase letter or underscore keeps
+ * camelCase/prefixed selectors (`reactTag`, `sd_extendedObject`) and drops the
+ * generic single words.
+ */
+const isDistinctiveSelector = (selector: string): boolean => /[A-Z_]/.test(selector);
+
+const capitalize = (value: string): string =>
+ value ? value[0]!.toUpperCase() + value.slice(1) : value;
+
/**
* Category selectors are emitted as ` t -[Class(Category) selector]` lines
* by `nm`. We skip selectors on classes that are themselves being mangled
@@ -219,13 +239,20 @@ const prefixSymbols = (prefix: string, symbols: string[]): string[] =>
symbols.map((sym) => `${sym}=${prefix}${sym}`);
/**
- * Property setter/getter pairs need symmetric handling so that `setFoo:` β
- * `setFoo:` and `foo` β `foo` both round-trip. Lifted from
- * `CocoapodsMangle::Defines.prefix_selectors` in the gem.
+ * Turn Objective-C category selectors into `#define`s. Beyond the naive
+ * `name=prefixname` rename, a property `foo` also needs:
+ *
+ * - its setter mapped to `set` + capitalize(`foo`) to match how the
+ * compiler derives `setFoo:` (not `setfoo`);
+ * - its synthesized ivar renamed (`_foo=_foo`) for code that touches it
+ * directly (`_reactSubviews`);
+ * - to be dropped as a whole (getter + setter) when not distinctive, so it's
+ * either fully renamed or not at all.
*/
const prefixSelectors = (prefix: string, selectors: string[]): string[] => {
const remaining = new Set(selectors);
const defines: string[] = [];
+ const ivarNames: string[] = [];
const setters = selectors.filter((sel) => /^set[A-Z]/.test(sel));
for (const setter of setters) {
@@ -240,27 +267,59 @@ const prefixSelectors = (prefix: string, selectors: string[]): string[] => {
}
remaining.delete(setter);
remaining.delete(getter);
- defines.push(`${setter}=set${prefix}${getter}`);
- defines.push(`${getter}=${prefix}${getter}`);
+ // Drop the whole property when its name isn't distinctive, so the getter
+ // and setter never disagree about whether they were renamed.
+ if (!isDistinctiveSelector(getter)) {
+ continue;
+ }
+ const mangledGetter = `${prefix}${getter}`;
+ defines.push(`${getter}=${mangledGetter}`);
+ defines.push(`${setter}=set${capitalize(mangledGetter)}`);
+ ivarNames.push(getter);
}
- defines.push(...prefixSymbols(prefix, Array.from(remaining)));
+ const plain = Array.from(remaining).filter(isDistinctiveSelector);
+ defines.push(...prefixSymbols(prefix, plain));
+ // A plain getter-shaped selector may also back an ivar; setters never do.
+ ivarNames.push(...plain.filter((sel) => !/^set[A-Z]/.test(sel)));
+ defines.push(...ivarNames.map((name) => `_${name}=_${prefix}${name}`));
+
return defines;
};
-const buildManglingDefines = async (prefix: string, binaries: string[]): Promise => {
+interface ManglingDefines {
+ /** Plain C/C++ symbols β renamed in every language for link safety. */
+ constantDefines: string[];
+ /** Objective-C classes, selectors, and backing ivars β guarded by __OBJC__. */
+ objcDefines: string[];
+}
+
+const buildManglingDefines = async (
+ prefix: string,
+ binaries: string[]
+): Promise => {
const allSymbolsGU = await runNm(binaries, '-gU');
const allSymbolsU = await runNm(binaries, '-U');
const classes = extractClasses(allSymbolsGU);
- const constants = extractConstants(allSymbolsGU);
const categorySelectors = extractCategorySelectors(allSymbolsU, classes);
- return [
- ...prefixSymbols(prefix, classes),
- ...prefixSymbols(prefix, constants),
- ...prefixSelectors(prefix, categorySelectors),
- ];
+ // Only selectors are mangled. Two other symbol kinds can't work via a
+ // `#define` in a Swift + clang-modules graph, because `-include` doesn't
+ // reach Swift source or a module's build context:
+ //
+ // - C/C++ symbols (Yoga's `YGConfigNew`): declared through a module, so the
+ // call site is renamed but the declaration isn't -> undeclared function.
+ // - ObjC class link-symbols (`_OBJC_CLASS_$_RCTView`): defined textually but
+ // referenced from Swift / across modules -> undefined symbol at link.
+ //
+ // Selectors are message-send names, and every source that uses them gets the
+ // same `-include`, so renaming them consistently is always safe. `classes` is
+ // still extracted so extractCategorySelectors can skip categories on them.
+ return {
+ constantDefines: [],
+ objcDefines: prefixSelectors(prefix, categorySelectors),
+ };
};
/** Read the existing xcconfig (if any) and return its `MANGLED_SPECS_CHECKSUM` value. */
@@ -273,28 +332,73 @@ const readExistingChecksum = (xcconfigPath: string): string | null => {
return match?.[1] ?? null;
};
+/**
+ * Render the renames as `#define OLD NEW` lines in a header (force-included via
+ * `-include`) rather than `-D` flags on `GCC_PREPROCESSOR_DEFINITIONS`. Xcode
+ * exports every build setting into each script phase's environment, so a
+ * megabyte-scale defines list overflows `kern.argmax` (1 MB) and any mangled
+ * target with a script phase fails with "Argument list too long". The header
+ * keeps the command line to one short `-include` flag.
+ *
+ * ObjC renames are wrapped in `__OBJC__` so they never touch pure C/C++
+ * translation units (`std::atomic::load`, folly/hermes internals).
+ */
+const buildManglingHeader = (constantDefines: string[], objcDefines: string[]): string => {
+ const toLines = (defines: string[]): string =>
+ defines
+ .map((define) => {
+ const separator = define.indexOf('=');
+ return `#define ${define.slice(0, separator)} ${define.slice(separator + 1)}`;
+ })
+ .join('\n');
+
+ return `// This file is automatically generated by expo-brownfield any time the
+// pod dependency graph changes. Commit it alongside Podfile.lock.
+#ifndef EXPO_BROWNFIELD_MANGLE_H
+#define EXPO_BROWNFIELD_MANGLE_H
+
+// C / C++ / Objective-C symbols β renamed in every language for link safety.
+${toLines(constantDefines)}
+
+// Objective-C classes, selectors, and backing ivars. Guarded so pure C/C++
+// translation units are never rewritten.
+#ifdef __OBJC__
+${toLines(objcDefines)}
+#endif
+
+#endif
+`;
+};
+
const writeManglingXcconfig = (
xcconfigPath: string,
- defines: string[],
+ constantDefines: string[],
+ objcDefines: string[],
specsChecksum: string
): void => {
+ // Write the header to the sandbox root (the `Pods` dir), whose path has no
+ // spaces, so the `-include` flag needs no fragile shell quoting β unlike the
+ // xcconfig's own "Target Support Files" directory.
+ const headerPath = path.join(path.dirname(path.dirname(xcconfigPath)), MANGLING_HEADER_FILE_NAME);
+
const contents = `// This config file is automatically generated by expo-brownfield any time the
// pod dependency graph changes. Commit it alongside Podfile.lock.
-
-${MANGLING_DEFINES_KEY} = ${defines.join(' ')}
-
+${MANGLING_HEADER_KEY} = ${headerPath}
// Used to skip rebuilding the mangling defines when the dependency graph hasn't changed.
${MANGLED_SPECS_CHECKSUM_KEY} = ${specsChecksum}
`;
+
fs.mkdirSync(path.dirname(xcconfigPath), { recursive: true });
fs.writeFileSync(xcconfigPath, contents);
+ fs.writeFileSync(headerPath, buildManglingHeader(constantDefines, objcDefines));
};
/**
- * Patch a per-pod xcconfig so it (1) `#include`s our mangling xcconfig and
- * (2) appends `$(MANGLING_DEFINES)` to its `GCC_PREPROCESSOR_DEFINITIONS`.
- * The transform is idempotent: re-running on an already-patched file leaves
- * it unchanged.
+ * Patch a per-pod xcconfig so it (1) `#include`s our mangling xcconfig (which
+ * defines `MANGLING_HEADER`) and (2) force-includes that header via
+ * `OTHER_CFLAGS`. Only C-family flags are touched β matching the original
+ * `GCC_PREPROCESSOR_DEFINITIONS` scope and deliberately leaving Swift's clang
+ * importer alone. Idempotent: re-running on an already-patched file is a no-op.
*/
const patchPodXcconfig = (podXcconfigPath: string, manglingXcconfigPath: string): void => {
if (!fs.existsSync(podXcconfigPath)) {
@@ -307,17 +411,27 @@ const patchPodXcconfig = (podXcconfigPath: string, manglingXcconfigPath: string)
contents = `${includeLine}\n${contents}`;
}
- const definesRefRe = new RegExp(`\\$\\(${MANGLING_DEFINES_KEY}\\)`);
- if (!definesRefRe.test(contents)) {
- contents = contents.replace(
- /^(GCC_PREPROCESSOR_DEFINITIONS\s*=\s*[^\n]*)$/m,
- `$1 $(${MANGLING_DEFINES_KEY})`
- );
- }
+ contents = appendToSetting(contents, 'OTHER_CFLAGS', `-include "$(${MANGLING_HEADER_KEY})"`);
fs.writeFileSync(podXcconfigPath, contents);
};
+/**
+ * Append `tokens` to an existing `KEY = β¦` line, or add a fresh
+ * `KEY = $(inherited) tokens` line when the setting isn't present. No-op if the
+ * tokens are already there, so re-running `pod install` stays idempotent.
+ */
+const appendToSetting = (contents: string, key: string, tokens: string): string => {
+ if (contents.includes(tokens)) {
+ return contents;
+ }
+ const settingRe = new RegExp(`^(${key}\\s*=\\s*[^\n]*)$`, 'm');
+ if (settingRe.test(contents)) {
+ return contents.replace(settingRe, `$1 ${tokens}`);
+ }
+ return `${contents}\n${key} = $(inherited) ${tokens}\n`;
+};
+
/**
* Entry point invoked by the Ruby shim during `pod install`. Responsibilities:
* 1. Build the pod targets to iphonesimulator so we have binaries to scan.
@@ -338,9 +452,12 @@ export const runMangle = async (
);
const binaries = findBinariesToMangle(builtProductsDir);
- const defines = await buildManglingDefines(context.manglePrefix, binaries);
+ const { constantDefines, objcDefines } = await buildManglingDefines(
+ context.manglePrefix,
+ binaries
+ );
- writeManglingXcconfig(context.xcconfigPath, defines, context.specsChecksum);
+ writeManglingXcconfig(context.xcconfigPath, constantDefines, objcDefines, context.specsChecksum);
for (const podXcconfig of context.podXcconfigPaths) {
patchPodXcconfig(podXcconfig, context.xcconfigPath);
@@ -356,4 +473,6 @@ export const __testing = {
extractConstants,
extractCategorySelectors,
prefixSelectors,
+ isDistinctiveSelector,
+ buildManglingHeader,
};
diff --git a/packages/expo-brownfield/e2e/cli/__tests__/mangle.test.ts b/packages/expo-brownfield/e2e/cli/__tests__/mangle.test.ts
new file mode 100644
index 00000000000000..6eee5e7f5c4023
--- /dev/null
+++ b/packages/expo-brownfield/e2e/cli/__tests__/mangle.test.ts
@@ -0,0 +1,71 @@
+import { __testing } from '../../../cli/src/utils/mangle';
+
+const { extractConstants, prefixSelectors, isDistinctiveSelector, buildManglingHeader } = __testing;
+
+/**
+ * Unit tests for the symbol-mangling transform. These cover the parts of the
+ * ARG_MAX fix that changed how symbols are turned into `#define`s: dropping
+ * symbols that can never fire (or would break) a preprocessor rename, keeping
+ * only distinctive selectors, symmetric setter/ivar handling, and rendering the
+ * force-included header.
+ */
+describe('extractConstants', () => {
+ it('keeps plain C constants but drops Itanium C++ (`_Zβ¦`) and whitespace symbols', () => {
+ const lines = [
+ '0000000000000001 S _GoodConst',
+ '0000000000000002 T __ZN3fooEv', // Itanium mangled -> `_ZN3fooEv`
+ '0000000000000003 S _foo bar', // survived a Swift filter with whitespace
+ ];
+ expect(extractConstants(lines)).toEqual(['GoodConst']);
+ });
+});
+
+describe('isDistinctiveSelector', () => {
+ it('keeps camelCase and prefixed selectors, drops generic single words', () => {
+ expect(isDistinctiveSelector('reactTag')).toBe(true);
+ expect(isDistinctiveSelector('sd_extendedObject')).toBe(true);
+ expect(isDistinctiveSelector('props')).toBe(false);
+ expect(isDistinctiveSelector('load')).toBe(false);
+ });
+});
+
+describe('prefixSelectors', () => {
+ it('renames a setter/getter pair symmetrically and its backing ivar', () => {
+ const defines = prefixSelectors('Exp_', ['reactTag', 'setReactTag']);
+ expect(defines).toEqual(
+ expect.arrayContaining([
+ 'reactTag=Exp_reactTag',
+ 'setReactTag=setExp_reactTag',
+ '_reactTag=_Exp_reactTag',
+ ])
+ );
+ });
+
+ it('drops a whole property (getter + setter) when the getter is not distinctive', () => {
+ expect(prefixSelectors('Exp_', ['props', 'setProps'])).toEqual([]);
+ });
+
+ it('renames a plain getter-shaped selector and its ivar, drops non-distinctive plains', () => {
+ const defines = prefixSelectors('Exp_', ['reactTag', 'load']);
+ expect(defines).toEqual(
+ expect.arrayContaining(['reactTag=Exp_reactTag', '_reactTag=_Exp_reactTag'])
+ );
+ expect(defines.some((d) => d.startsWith('load'))).toBe(false);
+ });
+});
+
+describe('buildManglingHeader', () => {
+ it('guards Objective-C renames behind __OBJC__ and emits `#define OLD NEW`', () => {
+ const header = buildManglingHeader(['CFoo=Exp_CFoo'], ['reactTag=Exp_reactTag']);
+ expect(header).toContain('#ifndef EXPO_BROWNFIELD_MANGLE_H');
+ expect(header).toContain('#define CFoo Exp_CFoo');
+
+ const objcStart = header.indexOf('#ifdef __OBJC__');
+ const objcEnd = header.indexOf('#endif', objcStart);
+ expect(objcStart).toBeGreaterThan(-1);
+ // The selector define lives inside the __OBJC__ guard; the C define does not.
+ expect(header.indexOf('#define reactTag Exp_reactTag')).toBeGreaterThan(objcStart);
+ expect(header.indexOf('#define reactTag Exp_reactTag')).toBeLessThan(objcEnd);
+ expect(header.indexOf('#define CFoo Exp_CFoo')).toBeLessThan(objcStart);
+ });
+});
From d2c8eb291e5c6f89a0a327519afd40f50b273a72 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?=
Date: Mon, 17 Aug 2026 19:48:43 +0530
Subject: [PATCH 08/14] [CI][ios] Fix the microphone permissions test not
compiling (#49027)
# Why
`iOS Unit Tests` has been red on `main` since #48840 merged. The whole
`xcodebuild` invocation fails to compile, so every scheduled package is
reported as failed, not just `expo-audio`.
```
error: value of optional type 'Int?' must be unwrapped to a value of type 'Int'
#expect(permissions["status"] as? Int == EXPermissionStatusDenied.rawValue)
```
`EXPermissionStatus` is a plain C enum, not `NS_ENUM`:
```objc
// packages/expo-modules-core/ios/Interfaces/Permissions/EXPermissionsInterface.h
typedef enum EXPermissionStatus { ... } EXPermissionStatus;
```
Swift imports a plain C enum as a `RawRepresentable` struct whose
`RawValue` is `UInt32`, so `EXPermissionStatusDenied.rawValue` is
`UInt32` while `permissions["status"] as? Int` is `Int?`. The comparison
never typechecks. The diagnostic blames the optional, but the real
mismatch is `Int` vs `UInt32`.
# How
Cast to `UInt32` instead of `Int`.
`Int(EXPermissionStatusDenied.rawValue)` would also compile, but the
test would then always fail: the dictionary stores a real `UInt32`, and
`as? Int` on it returns `nil`.
# Test Plan
iOS Unit tests CI should pass
---
packages/expo-audio/CHANGELOG.md | 1 +
.../expo-audio/ios/Tests/AudioRecordingRequesterTests.swift | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/expo-audio/CHANGELOG.md b/packages/expo-audio/CHANGELOG.md
index 777e8e01c457f3..0082b01abd86d2 100644
--- a/packages/expo-audio/CHANGELOG.md
+++ b/packages/expo-audio/CHANGELOG.md
@@ -27,6 +27,7 @@
### π‘ Others
- [Android] Removed outdated ExoPlayer changelog references and aligned Android media dependencies with AndroidX Media3 (`1.9.1`). ([#45368](https://github.com/expo/expo/pull/45368) by [@saisreelasyaappali](https://github.com/saisreelasyaappali))
+- [iOS] Fix the microphone permissions test not compiling. ([#49027](https://github.com/expo/expo/pull/49027) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
## 57.0.3 - 2026-07-22
diff --git a/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift b/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift
index 649dc8eed3f3c6..a024f734c9a399 100644
--- a/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift
+++ b/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift
@@ -11,7 +11,7 @@ struct AudioRecordingRequesterTests {
func `reports denied when the usage description is missing`() {
let permissions = AudioRecordingRequester.permissions(systemStatus: .granted, usageDescription: nil)
- #expect(permissions["status"] as? Int == EXPermissionStatusDenied.rawValue)
+ #expect(permissions["status"] as? UInt32 == EXPermissionStatusDenied.rawValue)
}
@Test(arguments: [
@@ -28,7 +28,7 @@ struct AudioRecordingRequesterTests {
usageDescription: "Allow $(PRODUCT_NAME) to access your microphone"
)
- #expect(permissions["status"] as? Int == expected.rawValue)
+ #expect(permissions["status"] as? UInt32 == expected.rawValue)
}
}
#endif
From c2088fa4a1e5826ac3ac20416aeafda002410cf8 Mon Sep 17 00:00:00 2001
From: Kevin
Date: Mon, 17 Aug 2026 13:40:20 -0400
Subject: [PATCH 09/14] [age-range] Add fake age signals for testing the
Android age gate (#48909)
---
.../src/screens/AgeRangeScreen.tsx | 80 +++++++++++--
.../versions/unversioned/sdk/age-range.mdx | 45 ++++++-
packages/expo-age-range/CHANGELOG.md | 2 +
.../modules/agerange/AgeRangeExceptions.kt | 12 ++
.../expo/modules/agerange/AgeRangeModule.kt | 31 ++++-
.../expo/modules/agerange/AgeRangeRecords.kt | 110 +++++++++++++-----
.../expo/modules/agerange/FakeAgeSignals.kt | 86 ++++++++++++++
.../modules/agerange/AgeSignalsManagerTest.kt | 10 +-
.../modules/agerange/FakeAgeSignalsTest.kt | 54 +++++++++
packages/expo-age-range/mocks/ExpoAgeRange.ts | 3 +
packages/expo-age-range/src/AgeRange.ts | 32 +++++
packages/expo-age-range/src/AgeRange.web.ts | 5 +
.../expo-age-range/src/ExpoAgeRange.types.ts | 37 ++++++
.../src/__tests__/ExpoAgeRange-test.ts | 5 +
packages/expo-age-range/src/index.ts | 1 +
15 files changed, 462 insertions(+), 51 deletions(-)
create mode 100644 packages/expo-age-range/android/src/main/java/expo/modules/agerange/FakeAgeSignals.kt
create mode 100644 packages/expo-age-range/android/src/test/java/expo/modules/agerange/FakeAgeSignalsTest.kt
diff --git a/apps/native-component-list/src/screens/AgeRangeScreen.tsx b/apps/native-component-list/src/screens/AgeRangeScreen.tsx
index bed2e58699b05b..1197a6151f856b 100644
--- a/apps/native-component-list/src/screens/AgeRangeScreen.tsx
+++ b/apps/native-component-list/src/screens/AgeRangeScreen.tsx
@@ -8,6 +8,29 @@ import HeadingText from '../components/HeadingText';
import MonoText from '../components/MonoText';
import Colors from '../constants/Colors';
+const FAKE_SIGNALS: Record = {
+ 'supervised 13 to 15 year old': {
+ ageSignalsStatus: 'SHARED',
+ lowerBound: 13,
+ upperBound: 15,
+ ageRangeSource: 'TIER_B',
+ significantChangeStatus: 'PENDING',
+ },
+ adult: {
+ ageSignalsStatus: 'SHARED',
+ lowerBound: 18,
+ ageRangeSource: 'TIER_D',
+ },
+ 'signals not shared': {
+ ageSignalsStatus: 'NOT_SHARED',
+ },
+ // -4 is PLAY_SERVICES_NOT_FOUND. See
+ // https://developer.android.com/google/play/age-signals/handle-errors
+ 'error code -4': {
+ errorCode: -4,
+ },
+};
+
export default function AgeRangeScreen() {
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
@@ -121,10 +144,36 @@ export default function AgeRangeScreen() {
}
};
+ const applyFakeSignals = (name: string | null) => {
+ setError(null);
+ setResult(null);
+
+ try {
+ AgeRange.setFakeAgeSignals(name === null ? null : FAKE_SIGNALS[name]);
+ } catch (err: any) {
+ setError(err.message || 'Unknown error occurred');
+ Alert.alert('Error', err.message || 'Unknown error occurred');
+ }
+ };
+
return (
Age Range API
+ {result && (
+
+ Result:
+ {result}
+
+ )}
+
+ {error && (
+
+ Error:
+ {error}
+
+ )}
+
Request the user's age range with directly configurable (iOS) thresholds. This example uses
thresholds at 13, 16, and 18 years old.
@@ -164,19 +213,26 @@ export default function AgeRangeScreen() {
style={styles.button}
/>
- {result && (
-
- Result:
- {result}
-
- )}
+ Fake age signals (Android)
- {error && (
-
- Error:
- {error}
-
- )}
+
+ Play only reports age signals to accounts it has enabled, so pick a fake below to test the
+ buttons above against another age range. The requests do not change, only what they report.
+
+
+ {Object.keys(FAKE_SIGNALS).map((name) => (
+
);
}
diff --git a/docs/pages/versions/unversioned/sdk/age-range.mdx b/docs/pages/versions/unversioned/sdk/age-range.mdx
index 45128181ef5b23..3000e2b094ec0f 100644
--- a/docs/pages/versions/unversioned/sdk/age-range.mdx
+++ b/docs/pages/versions/unversioned/sdk/age-range.mdx
@@ -118,6 +118,37 @@ const styles = StyleSheet.create({
+## Testing age signals on Android
+
+Google Play only reports age signals to accounts it has enabled, so your test accounts may not have the age range you need. Use [`setFakeAgeSignals`](#agerangesetfakeagesignalsfake) to fake them instead, through Google Play's [`FakeAgeSignalsManager`](https://developer.android.com/google/play/age-signals/test-age-signals-api).
+
+Only debuggable builds can fake signals, because faked signals would let any code in the app bypass your age gating. Passing anything other than `null` in a build that is not debuggable throws `ERR_AGE_RANGE_FAKE_SIGNALS_NOT_DEBUGGABLE`.
+
+Your requests do not change, only what they report:
+
+```ts
+// Report a supervised 13 to 15 year old with a change waiting for approval.
+AgeRange.setFakeAgeSignals({
+ ageSignalsStatus: 'SHARED',
+ lowerBound: 13,
+ upperBound: 15,
+ ageRangeSource: 'TIER_B',
+ significantChangeStatus: 'PENDING',
+});
+
+const status = await AgeRange.requestAgeSignalsAccessAsync();
+const { lowerBound } = await AgeRange.requestAgeRangeAsync({ threshold1: 18 });
+
+// Report the real signals again.
+AgeRange.setFakeAgeSignals(null);
+```
+
+To fake a failure, pass an [error code](https://developer.android.com/google/play/age-signals/handle-errors) instead of signals:
+
+```ts
+AgeRange.setFakeAgeSignals({ errorCode: -4 });
+```
+
## Additional resources
- [Play Age Signals API](https://developer.android.com/google/play/age-signals/use-age-signals-api): Android documentation for age signals
@@ -135,9 +166,11 @@ import * as AgeRange from 'expo-age-range';
Available in the `code` property of any error thrown by the native module. For Android-specific error codes, see the "Error code reference" in [Use Play Age Signals API docs](https://developer.android.com/google/play/age-signals/handle-errors).
-| Code | Platform | Description |
-| ------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
-| `ERR_AGE_RANGE_USER_DECLINED` | | User declined to share their age range. |
-| `ERR_AGE_RANGE_NOT_AVAILABLE` | | Age range not available. The most likely cause is that user is not signed in to their Apple account on the device. |
-| `ERR_AGE_RANGE_INVALID_REQUEST` | | The provided params were invalid. The age ranges need to be minimum 2 years apart. |
-| `ERR_AGE_RANGE_TASK_CANCELLED` | | The user dismissed the Play Age Signals age sharing consent screen. |
+| Code | Platform | Description |
+| ------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `ERR_AGE_RANGE_USER_DECLINED` | | User declined to share their age range. |
+| `ERR_AGE_RANGE_NOT_AVAILABLE` | | Age range not available. The most likely cause is that user is not signed in to their Apple account on the device. |
+| `ERR_AGE_RANGE_INVALID_REQUEST` | | The provided params were invalid. The age ranges need to be minimum 2 years apart. |
+| `ERR_AGE_RANGE_TASK_CANCELLED` | | The user dismissed the Play Age Signals age sharing consent screen. |
+| `ERR_AGE_RANGE_FAKE_SIGNALS_CONFLICT` | | `setFakeAgeSignals` was passed both an `errorCode` and age signals. |
+| `ERR_AGE_RANGE_FAKE_SIGNALS_NOT_DEBUGGABLE` | | `setFakeAgeSignals` was asked to fake signals in a build that is not debuggable. |
diff --git a/packages/expo-age-range/CHANGELOG.md b/packages/expo-age-range/CHANGELOG.md
index 679533759df6b6..38c985a42c3bf7 100644
--- a/packages/expo-age-range/CHANGELOG.md
+++ b/packages/expo-age-range/CHANGELOG.md
@@ -8,6 +8,8 @@
### π New features
+- [Android] Add `setFakeAgeSignals` to fake age signals from JS, through Google Play's `FakeAgeSignalsManager`. ([#48909](https://github.com/expo/expo/pull/48909) by [@kmadden84](https://github.com/kmadden84))
+
### π Bug fixes
- [iOS] Report `ageRangeDeclaration: 'confirmed'` for the six system-verified cases that iOS 26.2 added and iOS 26.5 deprecated, instead of reporting them as `'selfDeclared'`. ([#48486](https://github.com/expo/expo/pull/48486) by [@vonovak](https://github.com/vonovak))
diff --git a/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeExceptions.kt b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeExceptions.kt
index 2b18f0effe66a7..57f0e8779c0ddc 100644
--- a/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeExceptions.kt
+++ b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeExceptions.kt
@@ -7,3 +7,15 @@ internal class AgeRangeTaskCancelledException : CodedException(
"Age range task cancelled.",
null
)
+
+internal class FakeAgeSignalsConflictException : CodedException(
+ "ERR_AGE_RANGE_FAKE_SIGNALS_CONFLICT",
+ "Cannot fake an error and a response at the same time. Set `errorCode` or the age signals, not both.",
+ null
+)
+
+internal class FakeAgeSignalsNotDebuggableException : CodedException(
+ "ERR_AGE_RANGE_FAKE_SIGNALS_NOT_DEBUGGABLE",
+ "Cannot fake age signals in a build that is not debuggable, because faked signals would let any code in the app bypass your age gating. Test other age ranges in a debug build, and remove the `setFakeAgeSignals` call from the code you release.",
+ null
+)
diff --git a/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeModule.kt b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeModule.kt
index 37b7e9bda9fe4f..babc72594b0089 100644
--- a/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeModule.kt
+++ b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeModule.kt
@@ -2,6 +2,7 @@ package expo.modules.agerange
import android.app.Activity
import android.content.Context
+import android.content.pm.ApplicationInfo
import com.google.android.play.agesignals.AgeSignalsAccessRequest
import com.google.android.play.agesignals.AgeSignalsException
import com.google.android.play.agesignals.AgeSignalsManager
@@ -20,12 +21,27 @@ class AgeRangeModule : Module() {
private val ageSignalsManager by lazy { AgeSignalsManagerFactory.create(context.applicationContext) }
+ private var fakeAgeSignals: FakeAgeSignals? = null
+
+ /**
+ * Whether the app is debuggable, which its `android:debuggable` manifest flag decides.
+ */
+ private val isDebuggable: Boolean
+ get() = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
+
+ /**
+ * A [FakeAgeSignalsManager][com.google.android.play.agesignals.testing.FakeAgeSignalsManager] once
+ * the app has set fake signals, and the real manager otherwise.
+ */
+ private val currentAgeSignalsManager: AgeSignalsManager
+ get() = fakeAgeSignals?.manager ?: ageSignalsManager
+
override fun definition() = ModuleDefinition {
Name("ExpoAgeRange")
AsyncFunction("requestAgeRangeAsync") { _: Any, promise: Promise ->
requestAgeRange(
- ageSignalsManager = ageSignalsManager,
+ ageSignalsManager = currentAgeSignalsManager,
onSuccess = { result -> promise.resolve(result) },
onError = { exception -> promise.reject(exception) },
onCancelled = { promise.reject(AgeRangeTaskCancelledException()) }
@@ -38,13 +54,22 @@ class AgeRangeModule : Module() {
AsyncFunction("requestAgeSignalsAccessAsync") { promise: Promise ->
requestAgeSignalsAccess(
- ageSignalsManager = ageSignalsManager,
+ ageSignalsManager = currentAgeSignalsManager,
activity = appContext.throwingActivity,
onSuccess = { status -> promise.resolve(status) },
onError = { exception -> promise.reject(exception) },
onCancelled = { promise.reject(AgeRangeTaskCancelledException()) }
)
}
+
+ Function("setFakeAgeSignals") { options: FakeAgeSignalsOptions? ->
+ // Going back to the real signals stays allowed everywhere, so cleanup code can call this
+ // without checking the build first.
+ if (options != null && !isDebuggable) {
+ throw FakeAgeSignalsNotDebuggableException()
+ }
+ fakeAgeSignals = options?.let(::FakeAgeSignals)
+ }
}
}
@@ -86,7 +111,7 @@ fun requestAgeSignalsAccess(
onError(processAgeSignalsError(exception))
}
.addOnSuccessListener { accessResult ->
- onSuccess(ageSignalsStatusToString(accessResult.ageSignalsStatus()))
+ onSuccess(AgeSignalsStatusValue.fromPlayValue(accessResult.ageSignalsStatus())?.value)
}
}
diff --git a/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeRecords.kt b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeRecords.kt
index 86b7effe4a47bc..0bedf8ec5b42fd 100644
--- a/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeRecords.kt
+++ b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/AgeRangeRecords.kt
@@ -7,6 +7,7 @@ import com.google.android.play.agesignals.model.AgeSignalsStatus
import com.google.android.play.agesignals.model.SignificantChangeStatus
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
+import expo.modules.kotlin.types.Enumerable
import expo.modules.kotlin.types.OptimizedRecord
internal const val TAG = "expo-age-range"
@@ -34,43 +35,98 @@ data class AgeRangeResult(
lowerBound = result.ageLower(),
upperBound = result.ageUpper(),
installId = result.installId(),
- ageRangeSource = ageRangeSourceToString(result.ageRangeSource()),
- significantChangeStatus = significantChangeStatusToString(result.significantChangeStatus()),
+ ageRangeSource = AgeRangeSourceValue.fromPlayValue(result.ageRangeSource())?.value,
+ significantChangeStatus = SignificantChangeStatusValue
+ .fromPlayValue(result.significantChangeStatus())?.value,
significantChangeApprovalDate = result.significantChangeApprovalDate()?.time,
mostRecentApprovalDate = result.significantChangeApprovalDate()?.time
)
}
-internal fun ageRangeSourceToString(source: Int?): String? = when (source) {
- AgeRangeSource.TIER_A -> "TIER_A"
- AgeRangeSource.TIER_B -> "TIER_B"
- AgeRangeSource.TIER_C -> "TIER_C"
- AgeRangeSource.TIER_D -> "TIER_D"
- AgeRangeSource.UNSPECIFIED, null -> null
- else -> {
- Log.e(TAG, "Unhandled AgeRangeSource value: $source, returning null as fallback. Report this at github.com/expo/expo/issues.")
- null
+/**
+ * A value JS knows as [value] and Google Play Age Signals knows as [playValue].
+ */
+internal interface PlayValue {
+ val value: String
+ val playValue: Int
+}
+
+/**
+ * `null`, with a log, for a value Google Play added after this was written.
+ */
+private fun unhandledPlayValue(label: String, playValue: Int?): E? {
+ Log.e(TAG, "Unhandled $label value: $playValue, returning null as fallback. Report this at github.com/expo/expo/issues.")
+ return null
+}
+
+internal enum class AgeRangeSourceValue(override val value: String) : Enumerable, PlayValue {
+ TIER_A("TIER_A"),
+ TIER_B("TIER_B"),
+ TIER_C("TIER_C"),
+ TIER_D("TIER_D");
+
+ override val playValue: Int
+ get() = when (this) {
+ TIER_A -> AgeRangeSource.TIER_A
+ TIER_B -> AgeRangeSource.TIER_B
+ TIER_C -> AgeRangeSource.TIER_C
+ TIER_D -> AgeRangeSource.TIER_D
+ }
+
+ companion object {
+ fun fromPlayValue(playValue: Int?): AgeRangeSourceValue? = when (playValue) {
+ AgeRangeSource.TIER_A -> TIER_A
+ AgeRangeSource.TIER_B -> TIER_B
+ AgeRangeSource.TIER_C -> TIER_C
+ AgeRangeSource.TIER_D -> TIER_D
+ AgeRangeSource.UNSPECIFIED, null -> null
+ else -> unhandledPlayValue("AgeRangeSource", playValue)
+ }
}
}
-internal fun significantChangeStatusToString(status: Int?): String? = when (status) {
- SignificantChangeStatus.APPROVED -> "APPROVED"
- SignificantChangeStatus.PENDING -> "PENDING"
- SignificantChangeStatus.DECLINED -> "DECLINED"
- SignificantChangeStatus.UNSPECIFIED, null -> null
- else -> {
- Log.e(TAG, "Unhandled SignificantChangeStatus value: $status, returning null as fallback. Report this at github.com/expo/expo/issues.")
- null
+internal enum class SignificantChangeStatusValue(override val value: String) : Enumerable, PlayValue {
+ APPROVED("APPROVED"),
+ PENDING("PENDING"),
+ DECLINED("DECLINED");
+
+ override val playValue: Int
+ get() = when (this) {
+ APPROVED -> SignificantChangeStatus.APPROVED
+ PENDING -> SignificantChangeStatus.PENDING
+ DECLINED -> SignificantChangeStatus.DECLINED
+ }
+
+ companion object {
+ fun fromPlayValue(playValue: Int?): SignificantChangeStatusValue? = when (playValue) {
+ SignificantChangeStatus.APPROVED -> APPROVED
+ SignificantChangeStatus.PENDING -> PENDING
+ SignificantChangeStatus.DECLINED -> DECLINED
+ SignificantChangeStatus.UNSPECIFIED, null -> null
+ else -> unhandledPlayValue("SignificantChangeStatus", playValue)
+ }
}
}
-internal fun ageSignalsStatusToString(status: Int?): String? = when (status) {
- AgeSignalsStatus.SHARED -> "SHARED"
- AgeSignalsStatus.NOT_SHARED -> "NOT_SHARED"
- AgeSignalsStatus.VERIFICATION_REQUIRED -> "VERIFICATION_REQUIRED"
- AgeSignalsStatus.UNSPECIFIED, null -> null
- else -> {
- Log.e(TAG, "Unhandled AgeSignalsStatus value: $status, returning null as fallback. Report this at github.com/expo/expo/issues.")
- null
+internal enum class AgeSignalsStatusValue(override val value: String) : Enumerable, PlayValue {
+ SHARED("SHARED"),
+ NOT_SHARED("NOT_SHARED"),
+ VERIFICATION_REQUIRED("VERIFICATION_REQUIRED");
+
+ override val playValue: Int
+ get() = when (this) {
+ SHARED -> AgeSignalsStatus.SHARED
+ NOT_SHARED -> AgeSignalsStatus.NOT_SHARED
+ VERIFICATION_REQUIRED -> AgeSignalsStatus.VERIFICATION_REQUIRED
+ }
+
+ companion object {
+ fun fromPlayValue(playValue: Int?): AgeSignalsStatusValue? = when (playValue) {
+ AgeSignalsStatus.SHARED -> SHARED
+ AgeSignalsStatus.NOT_SHARED -> NOT_SHARED
+ AgeSignalsStatus.VERIFICATION_REQUIRED -> VERIFICATION_REQUIRED
+ AgeSignalsStatus.UNSPECIFIED, null -> null
+ else -> unhandledPlayValue("AgeSignalsStatus", playValue)
+ }
}
}
diff --git a/packages/expo-age-range/android/src/main/java/expo/modules/agerange/FakeAgeSignals.kt b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/FakeAgeSignals.kt
new file mode 100644
index 00000000000000..d4ba8c5995ee02
--- /dev/null
+++ b/packages/expo-age-range/android/src/main/java/expo/modules/agerange/FakeAgeSignals.kt
@@ -0,0 +1,86 @@
+package expo.modules.agerange
+
+import com.google.android.play.agesignals.AgeSignalsAccessResult
+import com.google.android.play.agesignals.AgeSignalsException
+import com.google.android.play.agesignals.AgeSignalsManager
+import com.google.android.play.agesignals.AgeSignalsResult
+import com.google.android.play.agesignals.testing.FakeAgeSignalsManager
+import expo.modules.kotlin.records.Field
+import expo.modules.kotlin.records.Record
+import java.util.Date
+
+internal class FakeAgeSignalsOptions : Record {
+ @Field
+ var lowerBound: Int? = null
+
+ @Field
+ var upperBound: Int? = null
+
+ @Field
+ var installId: String? = null
+
+ @Field
+ var ageRangeSource: AgeRangeSourceValue? = null
+
+ @Field
+ var significantChangeStatus: SignificantChangeStatusValue? = null
+
+ @Field
+ var significantChangeApprovalDate: Long? = null
+
+ @Field
+ var ageSignalsStatus: AgeSignalsStatusValue? = null
+
+ @Field
+ var errorCode: Int? = null
+}
+
+private fun FakeAgeSignalsOptions.hasSignals(): Boolean = listOfNotNull(
+ lowerBound,
+ upperBound,
+ installId,
+ ageRangeSource,
+ significantChangeStatus,
+ significantChangeApprovalDate,
+ ageSignalsStatus
+).isNotEmpty()
+
+/**
+ * Fake signals for [FakeAgeSignalsManager] to report, either a response or an error.
+ */
+internal class FakeAgeSignals(options: FakeAgeSignalsOptions) {
+ init {
+ if (options.errorCode != null && options.hasSignals()) {
+ throw FakeAgeSignalsConflictException()
+ }
+ }
+
+ private val exception = options.errorCode?.let(::AgeSignalsException)
+
+ private val ageSignalsResult: AgeSignalsResult = AgeSignalsResult.builder()
+ .setAgeLower(options.lowerBound)
+ .setAgeUpper(options.upperBound)
+ .setInstallId(options.installId)
+ .setAgeRangeSource(options.ageRangeSource?.playValue)
+ .setSignificantChangeStatus(options.significantChangeStatus?.playValue)
+ .setSignificantChangeApprovalDate(options.significantChangeApprovalDate?.let { Date(it) })
+ .build()
+
+ private val ageSignalsAccessResult: AgeSignalsAccessResult = AgeSignalsAccessResult.builder()
+ .setAgeSignalsStatus(options.ageSignalsStatus?.playValue)
+ .build()
+
+ /**
+ * One manager serves every request, because a `setNext*` call influences all future responses.
+ */
+ val manager: AgeSignalsManager = FakeAgeSignalsManager().apply {
+ if (exception != null) {
+ setNextAgeSignalsException(exception)
+ setNextRequestAgeSignalsAccessException(exception)
+ return@apply
+ }
+
+ setNextAgeSignalsResult(ageSignalsResult)
+ setNextAgeSignalsAccessResult(ageSignalsAccessResult)
+ }
+}
diff --git a/packages/expo-age-range/android/src/test/java/expo/modules/agerange/AgeSignalsManagerTest.kt b/packages/expo-age-range/android/src/test/java/expo/modules/agerange/AgeSignalsManagerTest.kt
index c9221a105d2c7d..a4cb5fabc2e22b 100644
--- a/packages/expo-age-range/android/src/test/java/expo/modules/agerange/AgeSignalsManagerTest.kt
+++ b/packages/expo-age-range/android/src/test/java/expo/modules/agerange/AgeSignalsManagerTest.kt
@@ -89,9 +89,13 @@ class AgeSignalsManagerTest {
// ones and null. Whatever it means, we don't leak it to JS, so it
// maps to null like an absent or unrecognised value does, in every mapping.
assertEquals(null, requestAccessAndAwait(AgeSignalsStatus.UNSPECIFIED))
- assertEquals(null, ageSignalsStatusToString(null))
- assertEquals(null, ageRangeSourceToString(AgeRangeSource.UNSPECIFIED))
- assertEquals(null, significantChangeStatusToString(99))
+ assertEquals(
+ AgeRangeSourceValue.TIER_B,
+ AgeRangeSourceValue.fromPlayValue(AgeRangeSource.TIER_B)
+ )
+ assertEquals(null, AgeSignalsStatusValue.fromPlayValue(null))
+ assertEquals(null, AgeRangeSourceValue.fromPlayValue(AgeRangeSource.UNSPECIFIED))
+ assertEquals(null, SignificantChangeStatusValue.fromPlayValue(99))
}
@Test
diff --git a/packages/expo-age-range/android/src/test/java/expo/modules/agerange/FakeAgeSignalsTest.kt b/packages/expo-age-range/android/src/test/java/expo/modules/agerange/FakeAgeSignalsTest.kt
new file mode 100644
index 00000000000000..555007db12927d
--- /dev/null
+++ b/packages/expo-age-range/android/src/test/java/expo/modules/agerange/FakeAgeSignalsTest.kt
@@ -0,0 +1,54 @@
+package expo.modules.agerange
+
+import android.os.Looper
+import com.google.android.play.agesignals.model.AgeSignalsErrorCode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(RobolectricTestRunner::class)
+class FakeAgeSignalsTest {
+
+ @Test
+ fun `fake signals are reported as an age range`() {
+ val fake = FakeAgeSignals(
+ FakeAgeSignalsOptions().apply {
+ lowerBound = 13
+ upperBound = 15
+ ageRangeSource = AgeRangeSourceValue.TIER_B
+ significantChangeStatus = SignificantChangeStatusValue.PENDING
+ }
+ )
+
+ var result: AgeRangeResult? = null
+ requestAgeRange(
+ ageSignalsManager = fake.manager,
+ onSuccess = { result = it },
+ onError = { throw AssertionError("Unexpected error: $it") },
+ onCancelled = { throw AssertionError("Unexpected cancellation") }
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertNotNull("Expected success callback to be called", result)
+ assertEquals(13, result!!.lowerBound)
+ assertEquals(15, result.upperBound)
+ assertEquals("TIER_B", result.ageRangeSource)
+ assertEquals("PENDING", result.significantChangeStatus)
+ }
+
+ @Test
+ fun `an error and a response cannot be faked at once`() {
+ assertThrows(FakeAgeSignalsConflictException::class.java) {
+ FakeAgeSignals(
+ FakeAgeSignalsOptions().apply {
+ errorCode = AgeSignalsErrorCode.APP_NOT_OWNED
+ lowerBound = 18
+ }
+ )
+ }
+ }
+}
diff --git a/packages/expo-age-range/mocks/ExpoAgeRange.ts b/packages/expo-age-range/mocks/ExpoAgeRange.ts
index af46166549dc21..28ed4d8f11cf9f 100644
--- a/packages/expo-age-range/mocks/ExpoAgeRange.ts
+++ b/packages/expo-age-range/mocks/ExpoAgeRange.ts
@@ -9,6 +9,7 @@ import type {
AgeRangeResponse,
AgeRangeRegulatoryFeature,
AgeSignalsStatus,
+ FakeAgeSignals,
} from '../src/index';
export async function requestAgeRangeAsync(opts: AgeRangeRequest): Promise {
@@ -41,3 +42,5 @@ export async function getRequiredRegulatoryFeaturesAsync(): Promise<
export async function requestAgeSignalsAccessAsync(): Promise {
return null;
}
+
+export function setFakeAgeSignals(_fake: FakeAgeSignals | null): void {}
diff --git a/packages/expo-age-range/src/AgeRange.ts b/packages/expo-age-range/src/AgeRange.ts
index 8674a1396f94ae..31a92378e58a03 100644
--- a/packages/expo-age-range/src/AgeRange.ts
+++ b/packages/expo-age-range/src/AgeRange.ts
@@ -6,6 +6,7 @@ import type {
AgeRangeResponse,
AgeRangeRegulatoryFeature,
AgeSignalsStatus,
+ FakeAgeSignals,
} from './ExpoAgeRange.types';
/**
@@ -129,3 +130,34 @@ export async function requestAgeSignalsAccessAsync(): Promise {
@@ -28,3 +29,7 @@ export async function getRequiredRegulatoryFeaturesAsync(): Promise<
export async function requestAgeSignalsAccessAsync(): Promise {
return null;
}
+
+export function setFakeAgeSignals(_fake: FakeAgeSignals | null): void {
+ // no-op on web
+}
diff --git a/packages/expo-age-range/src/ExpoAgeRange.types.ts b/packages/expo-age-range/src/ExpoAgeRange.types.ts
index 2022e71e2c8ad4..b802b3a094b06a 100644
--- a/packages/expo-age-range/src/ExpoAgeRange.types.ts
+++ b/packages/expo-age-range/src/ExpoAgeRange.types.ts
@@ -97,6 +97,42 @@ export type AgeRangeResponse = {
*/
export type AgeSignalsStatus = 'SHARED' | 'NOT_SHARED' | 'VERIFICATION_REQUIRED';
+/**
+ * Fake age signals for [`setFakeAgeSignals`](#agerangesetfakeagesignalsfake): either a response or
+ * an error, never both.
+ *
+ * The response fields match [`AgeRangeResponse`](#agerangeresponse), with `ageSignalsStatus` for
+ * [`requestAgeSignalsAccessAsync`](#agerangerequestagesignalsaccessasync). Omitted fields are
+ * reported as `null`.
+ *
+ * @platform android
+ */
+export type FakeAgeSignals =
+ | {
+ lowerBound?: number | null;
+ upperBound?: number | null;
+ installId?: string | null;
+ ageRangeSource?: 'TIER_A' | 'TIER_B' | 'TIER_C' | 'TIER_D' | null;
+ significantChangeStatus?: 'APPROVED' | 'PENDING' | 'DECLINED' | null;
+ significantChangeApprovalDate?: number | null;
+ ageSignalsStatus?: AgeSignalsStatus | null;
+ errorCode?: never;
+ }
+ | {
+ /**
+ * The [Google Play Age Signals error code](https://developer.android.com/google/play/age-signals/handle-errors)
+ * to fail both requests with.
+ */
+ errorCode: number;
+ lowerBound?: never;
+ upperBound?: never;
+ installId?: never;
+ ageRangeSource?: never;
+ significantChangeStatus?: never;
+ significantChangeApprovalDate?: never;
+ ageSignalsStatus?: never;
+ };
+
/**
* A regulatory feature that your app may need to support for the current user.
*
@@ -115,4 +151,5 @@ export interface ExpoAgeRangeModule extends NativeModule {
showSignificantUpdateAcknowledgmentAsync(updateDescription: string): Promise;
getRequiredRegulatoryFeaturesAsync(): Promise;
requestAgeSignalsAccessAsync(): Promise;
+ setFakeAgeSignals(fake: FakeAgeSignals | null): void;
}
diff --git a/packages/expo-age-range/src/__tests__/ExpoAgeRange-test.ts b/packages/expo-age-range/src/__tests__/ExpoAgeRange-test.ts
index 07ff4926e63210..eb40265d8b21c3 100644
--- a/packages/expo-age-range/src/__tests__/ExpoAgeRange-test.ts
+++ b/packages/expo-age-range/src/__tests__/ExpoAgeRange-test.ts
@@ -32,4 +32,9 @@ describe('ExpoAgeRange', () => {
// The mock represents the unsupported case (iOS and web).
await expect(ExpoAgeRange.requestAgeSignalsAccessAsync()).resolves.toBeNull();
});
+
+ it(`invokes setFakeAgeSignals`, () => {
+ expect(ExpoAgeRange.setFakeAgeSignals({ lowerBound: 18 })).toBeUndefined();
+ expect(ExpoAgeRange.setFakeAgeSignals(null)).toBeUndefined();
+ });
});
diff --git a/packages/expo-age-range/src/index.ts b/packages/expo-age-range/src/index.ts
index d0695c7289205b..d3e0e789bdb559 100644
--- a/packages/expo-age-range/src/index.ts
+++ b/packages/expo-age-range/src/index.ts
@@ -4,4 +4,5 @@ export type {
AgeRangeResponse,
AgeRangeRegulatoryFeature,
AgeSignalsStatus,
+ FakeAgeSignals,
} from './ExpoAgeRange.types';
From de065f37d320ffdbc25c1295e02f66c5825e049c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=A6n=20Hansen?=
Date: Mon, 17 Aug 2026 21:00:06 +0200
Subject: [PATCH 10/14] [jsi] Fix ambiguous `abs` in date TimeClip guard under
C++ interop (#49039)
---
packages/expo-modules-jsi/CHANGELOG.md | 1 +
.../Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/expo-modules-jsi/CHANGELOG.md b/packages/expo-modules-jsi/CHANGELOG.md
index 55bc526af457a5..c64195ab1712eb 100644
--- a/packages/expo-modules-jsi/CHANGELOG.md
+++ b/packages/expo-modules-jsi/CHANGELOG.md
@@ -16,6 +16,7 @@
### π Bug fixes
+- [iOS] Fixed `dateFromMilliseconds` failing to compile with "type of expression is ambiguous" under newer toolchains: the unqualified `abs(_:)` in the `Double` overflow guard is ambiguous once C++ interop brings the C `abs` overloads into scope, so use `Double.magnitude` instead. ([#49039](https://github.com/expo/expo/pull/49039) by [@kraenhansen](https://github.com/kraenhansen))
- [iOS] Fixed `JavaScriptPropNameID(_:string:)` and the array's string-keyed subscript truncating non-ASCII property keys: they passed `String.count` (the grapheme-cluster count) as the UTF-8 byte length to `PropNameID::forUtf8`, so keys like `"cafΓ©"` or `"π"` were built from mangled bytes and no longer matched the intended property. ([#48329](https://github.com/expo/expo/pull/48329) by [@tsapeta](https://github.com/tsapeta))
- [iOS] Fixed a use-after-free when a non-owning `JavaScriptRuntime` wrapper outlives its runtime (e.g. it is captured by a task abandoned on reload): its cached `jsi::PropNameID`s were destroyed against the freed runtime when the wrapper deallocated. The teardown sweep now flushes the cache on the JavaScript thread while the runtime is still valid. ([#47927](https://github.com/expo/expo/pull/47927) by [@tsapeta](https://github.com/tsapeta))
- [iOS] `JavaScriptPromise` no longer traps when a resolve or reject call throws, which can realistically only happen against a runtime that is being torn down: a failed resolver call rejects the promise instead and a failed rejecter call is dropped. ([#47862](https://github.com/expo/expo/pull/47862) by [@tsapeta](https://github.com/tsapeta))
diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift
index eb1cc942ba5ec3..1d6d3ab9fb7c3b 100644
--- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift
+++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift
@@ -50,7 +50,7 @@ let maxJavaScriptDateMilliseconds: Double = 8_640_000_000_000_000
/// faithful to `new Date(number)`; the `Date`/string branches pass an already-clipped `getTime()` through.
@usableFromInline
func dateFromMilliseconds(_ milliseconds: Double) throws -> Date {
- guard milliseconds.isFinite, abs(milliseconds) <= maxJavaScriptDateMilliseconds else {
+ guard milliseconds.isFinite, milliseconds.magnitude <= maxJavaScriptDateMilliseconds else {
throw InvalidDateException()
}
return Date(timeIntervalSince1970: milliseconds.rounded(.towardZero) / 1000.0)
From 47fcb586363ea2dd151cb6990ebd9db06236fd26 Mon Sep 17 00:00:00 2001
From: Ramon Claudio <153027766+ramonclaudio@users.noreply.github.com>
Date: Mon, 17 Aug 2026 15:04:50 -0400
Subject: [PATCH 11/14] feat(env): Add internal config mode helper (#48938)
# Why
The EAS changes in [#4180](https://github.com/expo/eas-cli/pull/4180)
tell Expo to use development or production mode when loading app config,
so we needed a temporary internal environment variable that Expo reads
and removes before app config loads.
# How
I added `__EXPO_CONFIG_MODE` as an internal handoff that `@expo/env`
reads and removes before Expo loads the dotenv files and app config. I
blocked `.env` files from setting the handoff and updated
`getOriginalEnv()` and `getOriginalEnvValue()` to exclude dotenv values
inherited from a parent process. We still use `EAS_BUILD` as the
production fallback for older EAS versions.
# Test Plan
Tests and package checks pass.
# 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)
- [ ] 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)
---
packages/@expo/cli/CHANGELOG.md | 1 +
.../cli/src/config/__tests__/index-test.ts | 2 +-
.../cli/src/prebuild/__tests__/index-test.ts | 2 +-
.../cli/src/utils/__tests__/nodeEnv-test.ts | 40 ++++-----
packages/@expo/cli/src/utils/env.ts | 5 --
packages/@expo/cli/src/utils/nodeEnv.ts | 81 ++++++++-----------
packages/@expo/env/CHANGELOG.md | 2 +
.../@expo/env/src/__tests__/index.test.ts | 59 ++++++++++++++
packages/@expo/env/src/constants.ts | 1 +
packages/@expo/env/src/index.ts | 64 ++++++++++++++-
10 files changed, 179 insertions(+), 78 deletions(-)
diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md
index 0905db476382ff..0bc36795cae9a5 100644
--- a/packages/@expo/cli/CHANGELOG.md
+++ b/packages/@expo/cli/CHANGELOG.md
@@ -38,6 +38,7 @@
### π‘ Others
+- [Internal] Use `@expo/env` to read the EAS config mode and keep older EAS Build versions in production mode. ([#48938](https://github.com/expo/expo/pull/48938) by [@ramonclaudio](https://github.com/ramonclaudio))
- Add sandbox detection to telemetry context ([#47928](https://github.com/expo/expo/pull/47928) by [@davidmokos](https://github.com/davidmokos))
- [Internal] Remove the unreachable port fallbacks and increase consistency in port selection logic ([#47771](https://github.com/expo/expo/pull/47771) by [@kitten](https://github.com/kitten))
- Add experimental `tvos` and `macos` autolinking gated by `expriments.outOfTreePlatforms` ([#46344](https://github.com/expo/expo/pull/46344) by [@kitten](https://github.com/kitten))
diff --git a/packages/@expo/cli/src/config/__tests__/index-test.ts b/packages/@expo/cli/src/config/__tests__/index-test.ts
index 8b6f7e5746ca2b..ab96373696f330 100644
--- a/packages/@expo/cli/src/config/__tests__/index-test.ts
+++ b/packages/@expo/cli/src/config/__tests__/index-test.ts
@@ -45,7 +45,7 @@ describe('config mode', () => {
);
});
- it('uses the production mode from EXPO_CONFIG_MODE', async () => {
+ it('uses the production mode from __EXPO_CONFIG_MODE', async () => {
getConfigEnvMode.mockReturnValue('production');
await expoConfig([]);
diff --git a/packages/@expo/cli/src/prebuild/__tests__/index-test.ts b/packages/@expo/cli/src/prebuild/__tests__/index-test.ts
index 7972f7925e226c..7963c07d7ccc3d 100644
--- a/packages/@expo/cli/src/prebuild/__tests__/index-test.ts
+++ b/packages/@expo/cli/src/prebuild/__tests__/index-test.ts
@@ -46,7 +46,7 @@ it('loads development env files before prebuild', async () => {
);
});
-it('uses the production mode from EXPO_CONFIG_MODE', async () => {
+it('uses the production mode from __EXPO_CONFIG_MODE', async () => {
getConfigEnvMode.mockReturnValue('production');
await expoPrebuild([]);
diff --git a/packages/@expo/cli/src/utils/__tests__/nodeEnv-test.ts b/packages/@expo/cli/src/utils/__tests__/nodeEnv-test.ts
index 2ae7efe2f3e85d..412c1389311602 100644
--- a/packages/@expo/cli/src/utils/__tests__/nodeEnv-test.ts
+++ b/packages/@expo/cli/src/utils/__tests__/nodeEnv-test.ts
@@ -9,7 +9,8 @@ describe('Node environment', () => {
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.__EXPO_ENV_LOADED;
- delete process.env.EXPO_CONFIG_MODE;
+ delete process.env.EAS_BUILD;
+ delete process.env.__EXPO_CONFIG_MODE;
delete process.env.EXPO_PUBLIC_VALUE;
vol.reset();
});
@@ -18,22 +19,28 @@ describe('Node environment', () => {
process.env = originalEnv;
});
- it('reads and removes EXPO_CONFIG_MODE', () => {
- process.env.EXPO_CONFIG_MODE = 'production';
+ it('reads and removes __EXPO_CONFIG_MODE', () => {
+ process.env.__EXPO_CONFIG_MODE = 'production';
expect(getConfigEnvMode()).toBe('production');
- expect(process.env.EXPO_CONFIG_MODE).toBeUndefined();
+ expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined();
});
- it('uses development when EXPO_CONFIG_MODE is empty', () => {
- process.env.EXPO_CONFIG_MODE = '';
+ it('uses development when __EXPO_CONFIG_MODE is empty', () => {
+ process.env.__EXPO_CONFIG_MODE = '';
expect(getConfigEnvMode()).toBe('development');
- expect(process.env.EXPO_CONFIG_MODE).toBeUndefined();
+ expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined();
});
- it('rejects an invalid EXPO_CONFIG_MODE value', () => {
- process.env.EXPO_CONFIG_MODE = 'staging';
+ it('uses production in EAS Build when __EXPO_CONFIG_MODE is not set', () => {
+ process.env.EAS_BUILD = 'true';
+
+ expect(getConfigEnvMode()).toBe('production');
+ });
+
+ it('rejects an invalid __EXPO_CONFIG_MODE value', () => {
+ process.env.__EXPO_CONFIG_MODE = 'staging';
try {
getConfigEnvMode();
@@ -42,18 +49,16 @@ describe('Node environment', () => {
expect(error).toBeInstanceOf(CommandError);
expect(error).toMatchObject({
code: 'BAD_ARGS',
- message: 'Invalid EXPO_CONFIG_MODE value: "staging". Use "development" or "production".',
+ message: 'Invalid __EXPO_CONFIG_MODE value: "staging". Use "development" or "production".',
});
}
- expect(process.env.EXPO_CONFIG_MODE).toBeUndefined();
+ expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined();
});
it('uses production mode when loading and reloading env files', () => {
vol.fromJSON(
{
- '.env.production': ['EXPO_CONFIG_MODE=development', 'EXPO_PUBLIC_VALUE=production-v1'].join(
- '\n'
- ),
+ '.env.production': 'EXPO_PUBLIC_VALUE=production-v1',
'.env.development': 'EXPO_PUBLIC_VALUE=development',
},
'/app'
@@ -64,20 +69,15 @@ describe('Node environment', () => {
loadEnvFiles('/app', { mode, silent: true });
expect(process.env.NODE_ENV).toBe('production');
- expect(process.env.EXPO_CONFIG_MODE).toBeUndefined();
expect(process.env.EXPO_PUBLIC_VALUE).toBe('production-v1');
expect(getEnvFiles('/app', mode)).toContain('/app/.env.production');
expect(getEnvFiles('/app', mode)).not.toContain('/app/.env.development');
process.env.NODE_ENV = 'development';
- vol.writeFileSync(
- '/app/.env.production',
- ['EXPO_CONFIG_MODE=development', 'EXPO_PUBLIC_VALUE=production-v2'].join('\n')
- );
+ vol.writeFileSync('/app/.env.production', 'EXPO_PUBLIC_VALUE=production-v2');
reloadEnvFiles('/app', mode);
expect(process.env.NODE_ENV).toBe('production');
- expect(process.env.EXPO_CONFIG_MODE).toBeUndefined();
expect(process.env.EXPO_PUBLIC_VALUE).toBe('production-v2');
});
});
diff --git a/packages/@expo/cli/src/utils/env.ts b/packages/@expo/cli/src/utils/env.ts
index 923ed291e8260a..d58eb7684fa2d3 100644
--- a/packages/@expo/cli/src/utils/env.ts
+++ b/packages/@expo/cli/src/utils/env.ts
@@ -245,11 +245,6 @@ class Env {
return getOriginalEnvValue('__EXPO_EAGER_BUNDLE_OPTIONS') || '';
}
- /** @internal Mode passed to `expo config` or `expo prebuild` by another tool. */
- get EXPO_CONFIG_MODE(): string | undefined {
- return getOriginalEnvValue('EXPO_CONFIG_MODE') || undefined;
- }
-
/** Disable server deployment during production builds (during `expo export:embed`). This is useful for testing API routes and server components against a local server. */
get EXPO_NO_DEPLOY(): boolean {
return boolish('EXPO_NO_DEPLOY', false);
diff --git a/packages/@expo/cli/src/utils/nodeEnv.ts b/packages/@expo/cli/src/utils/nodeEnv.ts
index dbfbe2154d856f..8f68a778268398 100644
--- a/packages/@expo/cli/src/utils/nodeEnv.ts
+++ b/packages/@expo/cli/src/utils/nodeEnv.ts
@@ -51,19 +51,15 @@ export function setNodeEnv(mode: EnvironmentMode) {
}
export function getConfigEnvMode(): EnvironmentMode {
- const mode = cliEnv.EXPO_CONFIG_MODE;
- delete process.env.EXPO_CONFIG_MODE;
-
- if (!mode) {
- return 'development';
- }
- if (mode !== 'development' && mode !== 'production') {
+ try {
+ // Older EAS Build versions do not pass __EXPO_CONFIG_MODE.
+ return env.consumeConfigEnvMode() ?? (cliEnv.EAS_BUILD ? 'production' : 'development');
+ } catch (error) {
throw new CommandError(
'BAD_ARGS',
- `Invalid EXPO_CONFIG_MODE value: "${mode}". Use "development" or "production".`
+ error instanceof Error ? error.message : 'Invalid __EXPO_CONFIG_MODE value.'
);
}
- return mode;
}
interface LoadEnvFilesOptions {
@@ -85,12 +81,7 @@ export function loadEnvFiles(projectRoot: string, options: LoadEnvFilesOptions)
systemEnv: process.env,
};
- let envInfo: ReturnType;
- try {
- envInfo = env.loadProjectEnv(projectRoot, params);
- } finally {
- delete process.env.EXPO_CONFIG_MODE;
- }
+ const envInfo = env.loadProjectEnv(projectRoot, params);
const envOutput: EnvOutput = {};
if (envInfo.result === 'loaded') {
prevEnvKeys = new Set();
@@ -121,40 +112,36 @@ export function getEnvFiles(projectRoot: string, mode: EnvironmentMode) {
export function reloadEnvFiles(projectRoot: string, mode: EnvironmentMode) {
setNodeEnv(mode);
- try {
- const isEnabled = env.isEnabled();
- if (isEnabled) {
- const params = {
- force: true,
- silent: true,
- mode,
- systemEnv: process.env,
- };
-
- // We use a global tracker to allow overwrites of env vars we set ourselves
- const envInfo = env.parseProjectEnv(projectRoot, params);
- const envOutput: EnvOutput = {};
- for (const key in envInfo.env) {
- const value = envInfo.env[key];
- if (process.env[key] !== value) {
- if (
- typeof process.env[key] === 'undefined' ||
- ((!prevEnvKeys || prevEnvKeys.has(key)) && process.env[key] !== value)
- ) {
- (prevEnvKeys ||= new Set()).add(key);
- process.env[key] = envInfo.env[key];
- envOutput[key] = value ?? undefined;
- }
+ const isEnabled = env.isEnabled();
+ if (isEnabled) {
+ const params = {
+ force: true,
+ silent: true,
+ mode,
+ systemEnv: process.env,
+ };
+
+ // We use a global tracker to allow overwrites of env vars we set ourselves
+ const envInfo = env.parseProjectEnv(projectRoot, params);
+ const envOutput: EnvOutput = {};
+ for (const key in envInfo.env) {
+ const value = envInfo.env[key];
+ if (process.env[key] !== value) {
+ if (
+ typeof process.env[key] === 'undefined' ||
+ ((!prevEnvKeys || prevEnvKeys.has(key)) && process.env[key] !== value)
+ ) {
+ (prevEnvKeys ||= new Set()).add(key);
+ process.env[key] = envInfo.env[key];
+ envOutput[key] = value ?? undefined;
}
}
-
- event('load', {
- mode: params.mode,
- files: relativeFiles(envInfo.files),
- keys: Object.keys(envOutput),
- });
}
- } finally {
- delete process.env.EXPO_CONFIG_MODE;
+
+ event('load', {
+ mode: params.mode,
+ files: relativeFiles(envInfo.files),
+ keys: Object.keys(envOutput),
+ });
}
}
diff --git a/packages/@expo/env/CHANGELOG.md b/packages/@expo/env/CHANGELOG.md
index 9de0836af55e5b..bddcf52ba37569 100644
--- a/packages/@expo/env/CHANGELOG.md
+++ b/packages/@expo/env/CHANGELOG.md
@@ -14,6 +14,8 @@
### π‘ Others
+- Pass the config mode through an internal variable and remove inherited dotenv values from Expo subprocesses. ([#48938](https://github.com/expo/expo/pull/48938) by [@ramonclaudio](https://github.com/ramonclaudio))
+
## 2.4.2 - 2026-07-15
_This version does not introduce any user-facing changes._
diff --git a/packages/@expo/env/src/__tests__/index.test.ts b/packages/@expo/env/src/__tests__/index.test.ts
index de3981d0c87815..9a79379589a2b2 100644
--- a/packages/@expo/env/src/__tests__/index.test.ts
+++ b/packages/@expo/env/src/__tests__/index.test.ts
@@ -5,6 +5,7 @@ import { stripVTControlCharacters } from 'node:util';
import type { loadEnvFiles } from '../';
import {
+ consumeConfigEnvMode,
getEnvFiles,
getOriginalEnv,
getOriginalEnvValue,
@@ -61,6 +62,24 @@ describe(setNodeEnv, () => {
});
});
+describe(consumeConfigEnvMode, () => {
+ it('reads and removes __EXPO_CONFIG_MODE', () => {
+ const systemEnv = { __EXPO_CONFIG_MODE: 'production' };
+
+ expect(consumeConfigEnvMode({ systemEnv })).toBe('production');
+ expect(systemEnv.__EXPO_CONFIG_MODE).toBeUndefined();
+ });
+
+ it('removes an invalid __EXPO_CONFIG_MODE value', () => {
+ process.env.__EXPO_CONFIG_MODE = 'staging';
+
+ expect(() => consumeConfigEnvMode()).toThrow(
+ 'Invalid __EXPO_CONFIG_MODE value: "staging". Use "development" or "production".'
+ );
+ expect(process.env.__EXPO_CONFIG_MODE).toBeUndefined();
+ });
+});
+
describe(getEnvFiles, () => {
it(`gets development files`, () => {
expect(getEnvFiles({ mode: 'development' })).toEqual([
@@ -498,6 +517,28 @@ describe(getOriginalEnv, () => {
expect(getOriginalEnv()[LOADED_ENV_NAME]).toBeUndefined();
});
+ it('removes dotenv values inherited from a parent process', () => {
+ const inheritedEnv = {
+ FOO: 'from-parent-dotenv',
+ PRE_EXISTING: 'original',
+ [LOADED_ENV_NAME]: JSON.stringify(['FOO']),
+ };
+
+ expect(getOriginalEnv(inheritedEnv)).toEqual({ PRE_EXISTING: 'original' });
+ });
+
+ it('removes inherited dotenv values after a forced local load', () => {
+ const inheritedEnv = {
+ FOO: 'from-parent-dotenv',
+ [LOADED_ENV_NAME]: JSON.stringify(['FOO']),
+ };
+ vol.fromJSON({ '.env': 'BAR=from-current-dotenv' }, '/');
+
+ loadProjectEnv('/', { force: true, systemEnv: inheritedEnv });
+
+ expect(getOriginalEnv(inheritedEnv)).toEqual({});
+ });
+
it('preserves the pre-load value when loadProjectEnv skipped the assignment', () => {
process.env.FOO = 'shell-provided';
vol.fromJSON({ '.env': 'FOO=from-env' }, '/');
@@ -595,6 +636,18 @@ describe(getOriginalEnvValue, () => {
expect(getOriginalEnvValue('FOO')).toBeUndefined();
});
+ it('returns undefined for a dotenv value inherited from a parent process', () => {
+ const inheritedEnv = {
+ FOO: 'from-parent-dotenv',
+ PRE_EXISTING: 'original',
+ [LOADED_ENV_NAME]: JSON.stringify(['FOO']),
+ };
+
+ expect(getOriginalEnvValue('FOO', inheritedEnv)).toBeUndefined();
+ expect(getOriginalEnvValue('PRE_EXISTING', inheritedEnv)).toBe('original');
+ expect(getOriginalEnvValue(LOADED_ENV_NAME, inheritedEnv)).toBeUndefined();
+ });
+
it('falls through to systemEnv for keys @expo/env never touched', () => {
process.env.UNRELATED = 'unrelated-value';
delete process.env.FOO;
@@ -760,6 +813,12 @@ describe('isLocalEnvKey policy', () => {
expect(() => parseProjectEnv('/', { systemEnv: {} })).toThrow(/EXPO_UNSAFE_DOTENV_KEYS/);
});
+ it('blocks __EXPO_CONFIG_MODE in env files', () => {
+ vol.fromJSON({ '.env': '__EXPO_CONFIG_MODE=production' }, '/');
+
+ expect(() => parseProjectEnv('/', { systemEnv: {} })).toThrow(/__EXPO_CONFIG_MODE/);
+ });
+
it('combines both violation classes into a single thrown error', () => {
process.env.NODE_ENV = 'development';
delete process.env.ANDROID_HOME;
diff --git a/packages/@expo/env/src/constants.ts b/packages/@expo/env/src/constants.ts
index ecf2f7bbf53a05..0fd7caac31cb94 100644
--- a/packages/@expo/env/src/constants.ts
+++ b/packages/@expo/env/src/constants.ts
@@ -25,6 +25,7 @@ export function isIgnoredEnvKey(name: string) {
switch (name) {
// NOTE: Expo internal env vars
case '__EXPO_ENV_LOADED':
+ case '__EXPO_CONFIG_MODE':
case 'EXPO_NO_DOTENV':
case 'EXPO_UNSAFE_DOTENV_KEYS':
return true;
diff --git a/packages/@expo/env/src/index.ts b/packages/@expo/env/src/index.ts
index e47bd3328a1a75..ebcec202ce5274 100644
--- a/packages/@expo/env/src/index.ts
+++ b/packages/@expo/env/src/index.ts
@@ -39,6 +39,21 @@ export const KNOWN_MODES = ['development', 'test', 'production'];
/** The environment variable name to use when marking the environment as loaded */
export const LOADED_ENV_NAME = '__EXPO_ENV_LOADED';
+function getLoadedEnvKeys(loadedEnvMarker: string | undefined): string[] {
+ if (!loadedEnvMarker) {
+ return [];
+ }
+
+ try {
+ const loadedKeys = JSON.parse(loadedEnvMarker);
+ return Array.isArray(loadedKeys)
+ ? loadedKeys.filter((key): key is string => typeof key === 'string')
+ : [];
+ } catch {
+ return [];
+ }
+}
+
/** Modes used by Expo commands and tools. */
export type EnvMode = 'development' | 'production';
@@ -56,6 +71,24 @@ export function setNodeEnv(
return systemEnv;
}
+/** @internal Read and remove the config mode passed by a parent Expo tool. */
+export function consumeConfigEnvMode({ systemEnv = process.env }: { systemEnv?: EnvOutput } = {}):
+ | EnvMode
+ | undefined {
+ const mode = systemEnv.__EXPO_CONFIG_MODE;
+ delete systemEnv.__EXPO_CONFIG_MODE;
+
+ if (!mode) {
+ return undefined;
+ }
+ if (mode !== 'development' && mode !== 'production') {
+ throw new Error(
+ `Invalid __EXPO_CONFIG_MODE value: "${mode}". Use "development" or "production".`
+ );
+ }
+ return mode;
+}
+
/**
* Get a list of all `.env*` files based on the `NODE_ENV` mode.
* This returns a list of files, in order of highest priority to lowest priority.
@@ -313,8 +346,9 @@ export function loadProjectEnv(
* `.env*` files were loaded β for example, when resolving SDK tooling paths
* that should not be influenced by project-controlled `.env` values.
*
- * Allocates lazily: nothing is held until this function is called, and each
- * call returns a new object so callers may mutate it freely.
+ * An inherited `__EXPO_ENV_LOADED` marker identifies dotenv values loaded by a parent process.
+ *
+ * Each call returns a new object so callers may mutate it freely.
*
* @param systemEnv The env to revert against; defaults to `process.env`.
*/
@@ -330,14 +364,26 @@ export function getOriginalEnv(systemEnv: EnvOutput = process.env): EnvOutput {
}
}
}
+
+ // A new process cannot access its parent's in-memory backup, so use the inherited marker to
+ // remove the parent's dotenv values.
+ for (const key of getLoadedEnvKeys(result[LOADED_ENV_NAME])) {
+ if (!isUnsafeAllowedEnvKey(key)) {
+ delete result[key];
+ }
+ }
+ delete result[LOADED_ENV_NAME];
+
return result;
}
/**
* Get the pre-load value of a single environment variable as recorded by
* `@expo/env`. Falls through to the value in `systemEnv` for keys that
- * `@expo/env` never touched. O(1) and allocation-free, intended for read-sites
- * that resolve filesystem paths or executables from a single env var.
+ * `@expo/env` never touched. Intended for read-sites that resolve filesystem
+ * paths or executables from a single env var.
+ *
+ * An inherited `__EXPO_ENV_LOADED` marker identifies dotenv values loaded by a parent process.
*
* Honors `EXPO_UNSAFE_DOTENV_KEYS`: keys the caller has explicitly opted into
* via the escape hatch return their currently loaded value, not the original.
@@ -350,6 +396,16 @@ export function getOriginalEnvValue(
systemEnv: EnvOutput = process.env
): string | undefined {
const backup = originalEnvBackup.get(systemEnv);
+ const marker = backup?.has(LOADED_ENV_NAME)
+ ? backup.get(LOADED_ENV_NAME)
+ : systemEnv[LOADED_ENV_NAME];
+
+ if (
+ key === LOADED_ENV_NAME ||
+ (!isUnsafeAllowedEnvKey(key) && getLoadedEnvKeys(marker).includes(key))
+ ) {
+ return undefined;
+ }
if (backup && backup.has(key)) {
return backup.get(key);
}
From 24a61a78e3a8477d29f088625e8908351bda249e Mon Sep 17 00:00:00 2001
From: Brent Vatne
Date: Mon, 17 Aug 2026 12:40:43 -0700
Subject: [PATCH 12/14] [github] Allow manual dispatch of the AI review command
workflow
Adds workflow_dispatch with pr/agents/config-from-checkout inputs so stale-base PRs (merge base predating .expo-code-review/) can still be reviewed.
---
.../workflows/expo-code-review-command.yml | 81 ++++++++++++++++---
1 file changed, 72 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/expo-code-review-command.yml b/.github/workflows/expo-code-review-command.yml
index f7fc62e403dfde..d15d8268f0cba9 100644
--- a/.github/workflows/expo-code-review-command.yml
+++ b/.github/workflows/expo-code-review-command.yml
@@ -12,10 +12,34 @@ name: AI code review (command)
# This never changes configuration. CONTINUOUS review is configured in
# expo-code-review.yml (the `pull_request` workflow) via the `review.trigger`
# policy in .expo-code-review/config.jsonc and the `ai-review:skip` label.
+#
+# Also dispatchable manually (Actions β this workflow β Run workflow) with a PR
+# number. Dispatch requires write access, so it carries the same maintainers-only
+# gate as the comment path. The `config-from-checkout` input exists for PRs whose
+# base commit predates .expo-code-review/ β `ecr ci` loads configuration from the
+# PR's trusted base commit and fails closed when that commit has no config, so
+# such PRs can only be reviewed by trusting this base-ref checkout's config
+# (still never the PR head).
on:
issue_comment:
types: [created]
+ workflow_dispatch:
+ inputs:
+ pr:
+ description: PR number to review
+ type: number
+ required: true
+ agents:
+ description: "Agent ids (comma-separated), 'all' for every agent, or empty to let the router pick"
+ type: string
+ required: false
+ default: ''
+ config-from-checkout:
+ description: Load reviewer config from this checkout instead of the PR's base commit (for PRs whose base predates .expo-code-review/)
+ type: boolean
+ required: false
+ default: false
# Comment-only: read the repo, write PR comments (issue comments API).
permissions:
@@ -25,11 +49,12 @@ permissions:
jobs:
command:
- # Only PR comments starting with /review, /expo-review, @expo-bot review,
- # or @expo-bot check.
+ # Manual dispatch (write access required), or PR comments starting with
+ # /review, /expo-review, @expo-bot review, or @expo-bot check.
# @ref LLP 0009#workflow-security-posture [implements] β gate controls who triggers, not what code runs
if: >-
- github.event.issue.pull_request != null &&
+ github.event_name == 'workflow_dispatch' ||
+ (github.event.issue.pull_request != null &&
github.event.comment.user.login != 'expo-bot' &&
(github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
@@ -39,12 +64,12 @@ jobs:
startsWith(github.event.comment.body, '@expo-bot review ') ||
github.event.comment.body == '@expo-bot check' ||
startsWith(github.event.comment.body, '@expo-bot check ')) &&
- contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
+ contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest
# Job-level so a prose comment that the gate rejects cannot claim this
# group at workflow creation and cancel a real review already in flight.
concurrency:
- group: ai-code-review-cmd-${{ github.event.issue.number }}
+ group: ai-code-review-cmd-${{ github.event.issue.number || inputs.pr }}
cancel-in-progress: true
# Bound the run so a slow/stalled review fails fast rather than hanging. Keep it
# above the passes budget (budget.totalPassesMinutes, 55m) + coordinator (10m) +
@@ -59,7 +84,26 @@ jobs:
env:
# Via env (never inline ${{ }}) so an untrusted comment can't inject shell.
COMMENT: ${{ github.event.comment.body }}
+ DISPATCH: ${{ github.event_name == 'workflow_dispatch' }}
+ DISPATCH_AGENTS: ${{ inputs.agents }}
run: |
+ # Manual dispatch: agent selection comes from inputs, with the same
+ # sanitization as the comment path below.
+ if [ "$DISPATCH" = "true" ]; then
+ agents=""
+ route=false
+ if [ -z "$DISPATCH_AGENTS" ]; then
+ route=true
+ elif [ "$DISPATCH_AGENTS" != "all" ]; then
+ agents=$(printf '%s' "$DISPATCH_AGENTS" | tr ' ' ',' | tr -cd 'a-zA-Z0-9,_-')
+ fi
+ {
+ echo "run=true"
+ echo "agents=$agents"
+ echo "route=$route"
+ } >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
line=$(printf '%s' "$COMMENT" | head -n1 | tr -d '\r')
first=$(printf '%s' "$line" | awk '{print tolower($1)}')
second=$(printf '%s' "$line" | awk '{print tolower($2)}')
@@ -102,7 +146,8 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Acknowledge
- if: steps.cmd.outputs.run == 'true'
+ # Comment path only β a manual dispatch has no comment to react to.
+ if: steps.cmd.outputs.run == 'true' && github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
run: gh api -X POST "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes
@@ -182,7 +227,7 @@ jobs:
if: always() && steps.cmd.outputs.run == 'true' && steps.model-env.outcome == 'failure'
env:
GH_TOKEN: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
- PR: ${{ github.event.issue.number }}
+ PR: ${{ github.event.issue.number || inputs.pr }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# See the matching step in expo-code-review.yml: pick.sh writes this
@@ -214,6 +259,11 @@ jobs:
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.cmd.outputs.agents }}
ROUTE: ${{ steps.cmd.outputs.route }}
+ # Manual dispatch has no PR number in its event payload; the CLI's
+ # documented fallback reads it from GITHUB_REF (refs/pull//β¦).
+ GITHUB_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/merge', inputs.pr) || github.ref }}
+ DISPATCH: ${{ github.event_name == 'workflow_dispatch' }}
+ CONFIG_FROM_CHECKOUT: ${{ inputs.config-from-checkout }}
# NOTE: running via `issue_comment` makes this a manual review command, which
# the CLI detects (GITHUB_EVENT_NAME=issue_comment) and treats as a trigger-gate bypass
# β it reviews even when the config trigger policy or an `ai-review:skip` label
@@ -229,18 +279,31 @@ jobs:
elif [ "$ROUTE" = "true" ]; then
ARGS=(--route)
fi
+ if [ "$DISPATCH" = "true" ]; then
+ # A dispatch is a manual command like /review, but the CLI only
+ # infers the trigger-gate bypass from GITHUB_EVENT_NAME=issue_comment,
+ # so pass --force explicitly.
+ ARGS+=(--force)
+ if [ "$CONFIG_FROM_CHECKOUT" = "true" ]; then
+ # Operator trust decision (absolute path): PRs whose base commit
+ # predates .expo-code-review/ have no trusted-base config to load,
+ # so trust this base-ref checkout's config β still never the PR head.
+ ARGS+=(--config-dir "$GITHUB_WORKSPACE/.expo-code-review")
+ fi
+ fi
./scripts/expo-code-review ecr ci "${ARGS[@]}"
# Same ephemeral per-run log as the pull_request workflow β a review command
# runs the full `ecr ci`, whose .expo-code-review/.runs/ log is gone when the
# runner tears down. always() captures it even on error, gated on run=='true'
# (an unrelated comment writes no log); issue.number IS the PR number here
- # (issue_comment context has no pull_request.number).
+ # (issue_comment context has no pull_request.number), and inputs.pr covers
+ # the manual-dispatch path.
- name: Upload review run log
if: always() && steps.cmd.outputs.run == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: review-run-log-pr${{ github.event.issue.number }}
+ name: review-run-log-pr${{ github.event.issue.number || inputs.pr }}
path: .expo-code-review/.runs/reviews.jsonl
if-no-files-found: ignore
retention-days: 14
From eed581c2e53a5cd8be7c6bbfcee3b82c31d2d039 Mon Sep 17 00:00:00 2001
From: Brent Vatne
Date: Mon, 17 Aug 2026 12:49:10 -0700
Subject: [PATCH 13/14] [github] Fix PR number resolution for dispatched AI
reviews
The runner forbids overriding GITHUB_REF via step env; export it inside the script instead.
---
.github/workflows/expo-code-review-command.yml | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/expo-code-review-command.yml b/.github/workflows/expo-code-review-command.yml
index d15d8268f0cba9..542c8269e4c8e1 100644
--- a/.github/workflows/expo-code-review-command.yml
+++ b/.github/workflows/expo-code-review-command.yml
@@ -259,10 +259,8 @@ jobs:
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.cmd.outputs.agents }}
ROUTE: ${{ steps.cmd.outputs.route }}
- # Manual dispatch has no PR number in its event payload; the CLI's
- # documented fallback reads it from GITHUB_REF (refs/pull//β¦).
- GITHUB_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/merge', inputs.pr) || github.ref }}
DISPATCH: ${{ github.event_name == 'workflow_dispatch' }}
+ PR_NUMBER: ${{ inputs.pr }}
CONFIG_FROM_CHECKOUT: ${{ inputs.config-from-checkout }}
# NOTE: running via `issue_comment` makes this a manual review command, which
# the CLI detects (GITHUB_EVENT_NAME=issue_comment) and treats as a trigger-gate bypass
@@ -280,6 +278,14 @@ jobs:
ARGS=(--route)
fi
if [ "$DISPATCH" = "true" ]; then
+ # A dispatch's event payload has no PR number for the CLI to read, and
+ # the runner forbids overriding GITHUB_* defaults via step `env:` β so
+ # export the CLI's documented GITHUB_REF fallback from inside the
+ # script, where the runner can't overwrite it.
+ case "$PR_NUMBER" in
+ ''|*[!0-9]*) echo "invalid pr input: $PR_NUMBER" >&2; exit 1 ;;
+ esac
+ export GITHUB_REF="refs/pull/${PR_NUMBER}/merge"
# A dispatch is a manual command like /review, but the CLI only
# infers the trigger-gate bypass from GITHUB_EVENT_NAME=issue_comment,
# so pass --force explicitly.
From e93c4a167db37e62569f52cf744cf52fb05f9581 Mon Sep 17 00:00:00 2001
From: Marc Shilling
Date: Mon, 17 Aug 2026 16:10:08 -0400
Subject: [PATCH 14/14] [ios][image] Support the accessibilityElementsHidden
prop (#46105)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: nishan (o^β½^o)
---
packages/expo-image/CHANGELOG.md | 1 +
packages/expo-image/ios/ImageModule.swift | 5 +++++
packages/expo-image/src/Image.types.ts | 8 ++++++++
3 files changed, 14 insertions(+)
diff --git a/packages/expo-image/CHANGELOG.md b/packages/expo-image/CHANGELOG.md
index 5dd8ebb6efa1fa..038fc109fccc31 100644
--- a/packages/expo-image/CHANGELOG.md
+++ b/packages/expo-image/CHANGELOG.md
@@ -61,6 +61,7 @@ _This version does not introduce any user-facing changes._
- Fix an ES module import error in the typed config plugin. ([#46089](https://github.com/expo/expo/pull/46089) by [@zoontek](https://github.com/zoontek))
- [Android] Fixed `useImage` crashing on SVG sources, and made `maxWidth`/`maxHeight` preserve the SVG's aspect ratio. ([#46077](https://github.com/expo/expo/pull/46077) by [@nishan](https://github.com/intergalacticspacehighway))
+- [iOS] Support the `accessibilityElementsHidden` prop. ([#46105](https://github.com/expo/expo/pull/46105) by [@marcshilling](https://github.com/marcshilling))
## 56.0.7 β 2026-05-21
diff --git a/packages/expo-image/ios/ImageModule.swift b/packages/expo-image/ios/ImageModule.swift
index 1bf9bc9c541092..18cb72ab247773 100644
--- a/packages/expo-image/ios/ImageModule.swift
+++ b/packages/expo-image/ios/ImageModule.swift
@@ -104,6 +104,11 @@ public final class ImageModule: Module {
view.sdImageView.accessibilityLabel = label
}
+ Prop("accessibilityElementsHidden") { (view, hidden: Bool?) in
+ view.accessibilityElementsHidden = hidden ?? false
+ view.sdImageView.accessibilityElementsHidden = hidden ?? false
+ }
+
Prop("recyclingKey") { (view, key: String?) in
view.recyclingKey = key
}
diff --git a/packages/expo-image/src/Image.types.ts b/packages/expo-image/src/Image.types.ts
index 9e0975fcf0c2fb..a801dd5e0f7717 100644
--- a/packages/expo-image/src/Image.types.ts
+++ b/packages/expo-image/src/Image.types.ts
@@ -387,6 +387,14 @@ export interface ImageProps extends Omit {
*/
accessible?: boolean;
+ /**
+ * A Boolean value indicating whether the accessibility elements contained within the image
+ * are hidden from the screen reader.
+ * @default false
+ * @platform ios
+ */
+ accessibilityElementsHidden?: boolean;
+
/**
* The text that's read by the screen reader when the user interacts with the image. Sets the `alt` tag on web which is used for web crawlers and link traversal.
* @default undefined