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 CleanShot 2026-08-17 at 11 33 55@2x # 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) => ( +