Skip to content

[pull] main from expo:main - #1187

Merged
pull[bot] merged 17 commits into
code:mainfrom
expo:main
Aug 26, 2026
Merged

[pull] main from expo:main#1187
pull[bot] merged 17 commits into
code:mainfrom
expo:main

Conversation

@pull

@pull pull Bot commented Aug 26, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

OlegBezr and others added 17 commits August 26, 2026 12:18
…th without read access (#49362)

# Why

Picking a video with `launchImageLibraryAsync` terminates apps that ship
no `NSPhotoLibraryUsageDescription` in their `Info.plist`.

`PHPickerViewController` requires **no** photo library authorization —
that is its whole design purpose: the user picks out-of-process and the
app receives only the chosen items. `expo-image-picker` builds its
picker with `PHPickerConfiguration(photoLibrary:
PHPhotoLibrary.shared())`, which is fine on its own: it just makes the
results Photos-backed so `assetIdentifier` is populated.

However, the passthrough fast path added in #37569 calls
`PHAsset.fetchAssets(withLocalIdentifiers:)`. That is a photo library
**read**, so it trips a `kTCCServicePhotos` authorization check. For an
app that ships no `NSPhotoLibraryUsageDescription` — perfectly legal,
and the recommended configuration for an app that only uses the system
picker — that check does not merely fail: iOS terminates the app. `tccd`
logs:

```
Refusing authorization request for service kTCCServicePhotos ... without NSPhotoLibraryUsageDescription key
```

The termination happens after the user taps "Done" in the picker, so it
looks like a picker bug rather than a permission bug, and no crash
report is written to `DiagnosticReports`, which makes it hard to
diagnose.

This is a regression: before #37569, the video path used only
`loadFileRepresentation` on the item provider, which needs no
authorization.

The effect today is that `expo-image-picker` forces every app that picks
videos to request full read access to the photo library, purely to keep
an optimization that only matters for *adjusted* assets — even though
the config plugin exposes `photosPermission: false` for exactly the case
of an app that doesn't want that permission.

# How

Only take the fast path when the app actually holds the read access that
path requires:

```swift
let photoLibraryReadStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
let hasPhotoLibraryReadAccess = photoLibraryReadStatus == .authorized || photoLibraryReadStatus == .limited

if options.videoExportPreset == .passthrough, hasPhotoLibraryReadAccess, let assetId = selectedVideo.assetIdentifier {
```

- Apps holding read access keep the optimization unchanged.
- Apps without it fall through to the pre-existing
`loadVideoRepresentation` path — the behavior before the fast path was
introduced. That path still preserves the original bytes under
`.passthrough`, and still returns `selectedVideo.assetIdentifier` as
`assetId`, so the resolved asset is unchanged apart from timing.
- `PHPhotoLibrary.authorizationStatus(for:)` does not prompt and is safe
to call without a usage-description key.

`.limited` is included because in limited mode `fetchAssets`
legitimately succeeds for assets in the user's granted set, and
gracefully returns empty (falling through) for ones outside it — so the
optimization is preserved there too.

# Test Plan

I do not have a self-contained repro app to attach; the following was
verified downstream by the reporter in a real Expo/React Native app
(NavigateAI), against `expo-image-picker@57.0.8` patched with exactly
this change, on an iOS 18.6 simulator:

- With all photo permissions revoked and no
`NSPhotoLibraryUsageDescription` in the built `Info.plist`, picking a
video from the library previously terminated the app on "Done". With
this change the pick completes and resolves an asset whose `assetId` is
populated.
- With full read access granted, the fast path is still taken and
behavior is unchanged.

I have not run the expo repo's own test-suites for this package beyond
confirming the change is a compile-level no-op for callers (no public
API change, no JS/TS change).

# 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)
— changelog entry added; there is no JS/TS source to rebuild, this is an
iOS-only change.
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin). — not applicable, no config plugin
change.
- [ ] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
— not applicable, no docs change.

---------

Co-authored-by: Vojtech Novak <vonovak@gmail.com>
…s page (#49350)

# Why

<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

Fix ENG-26175

# How

<!--
How did you build this feature or fix this bug and why?
-->

Add a section and reference to audit logs command on audit logs page.

# Test Plan

<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

<img width="2370" height="808" alt="CleanShot 2026-08-25 at 22 43 10@2x"
src="https://github.com/user-attachments/assets/2eb2b7b2-a22e-419a-939d-9c2ac7be64e1"
/>


# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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)
# Why

<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

Fix ENG-26176

# How

<!--
How did you build this feature or fix this bug and why?
-->

Remove unused EAS Hosting shoutout banner.

# Test Plan

<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

N/A

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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)
# Why

<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

Fix ENG-26172

# How

<!--
How did you build this feature or fix this bug and why?
-->

- Pick light and dark images in CSS instead of JavaScript in all image
based components. When images are picked by JavaScript, they happen
after the first hydration which is why, previously, there was a frame
where light mode images were picked first before dark mode images when
dark mode theme was selected.
- Remove unused `prefersDarkTheme` code.

# Test Plan

<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

**Examples after fix of the image loading:**



https://github.com/user-attachments/assets/a2ed08fd-0e54-467b-81c5-261414f8184f



https://github.com/user-attachments/assets/f14ada33-d192-4d85-a945-5430c4665912

**How to test manually:**
- Go to https://pr-49347.expo-docs.pages.dev/eas/observe/introduction/
- Either switch to dark mode theme
- Hard refresh the page and ensure that light mode image doesn't load

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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)
# Why

Fixes #49365.
closes #44276

On iOS, ScreenOrientationRegistry can enter a circular wait that
permanently blocks the main thread:
1. A controller notification runs on expo.screenorientationregistry.
2. ScreenOrientationModule.screenOrientationDidChange reads
currentOrientationMask, which synchronously waits for the main thread.
3. UIKit queries supportedInterfaceOrientations on the main thread.
4. This reaches requiredOrientationMask(), which synchronously waits for
expo.screenorientationregistry.
The registry queue and main thread then wait for each other, causing an
app hang. We observed this frequently in production after upgrading from
expo-screen-orientation 9.0.8 to 57.0.1.

# How

Controller notifications are now delivered on a dedicated notification
queue rather than the registry's state-protection queue.
screenOrientationDidChange snapshots the registered controllers while
holding a barrier on the registry queue, then releases that queue before
notifying them. This prevents controller callbacks that synchronously
access the main thread from blocking registry state reads made by the
main thread.
Controller registration and removal now also use barrier writes,
ensuring mutations cannot race with the controller snapshot.

# Test Plan

Test Plan
The issue includes a minimal iOS development-build reproduction that
deterministically arranges the production wait cycle.
1. Clone the reproduction linked in
#49365.
2. Run npm install.
3. Run npm run ios.
4. Wait three seconds or press Trigger now.
5. Without this fix, the app remains on TESTING... and stops responding.
6. Pause the process in Xcode and inspect the main thread. It is blocked
in ScreenOrientationRegistry.requiredOrientationMask().
7. Apply this change to expo-screen-orientation and rebuild the native
app.
8. The status changes from TESTING... to RESPONSIVE, and the app
continues handling touches.
The equivalent change was also tested locally as a patch-package patch
against expo-screen-orientation@57.0.1. The reproduction remained
responsive after the deterministic trigger.
Expo Doctor output for the reproduction:
Running 21 checks on your project...
21/21 checks passed. No issues detected!

# Checklist

- [x] I added a `changelog.md` entry and rebuilt the package sources
according to [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [x] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).
- [x] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)

---------

Co-authored-by: Vojtech Novak <vonovak@gmail.com>
# Why

<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

Replaces the single `lodash/partition` call in
`common/code-utilities.ts` with a six-line local helper. It was the only
lodash import that reaches the browser bundle, and `_app` loads on every
page.

# How

<!--
How did you build this feature or fix this bug and why?
-->

Saves ~7 KB transferred per page (measured on
`/versions/latest/sdk/calendar/`: 1363 KB → 1356 KB, median of 5 local
Lighthouse runs).

# Test Plan

<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

N/A

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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)
…d of per 8 KiB segment (#49206)

# Why

On Android, uploading a file picked through the Storage Access Framework
with `File.upload()` is around 100x slower than it should be.

`CountingSink.write` calls `requestBody.contentLength()` on every write
to report progress, and okio hands it one 8 KiB segment at a time. For a
`content://` URI the request body's `contentLength()` is
`SAFDocumentFile.length()`, which is a `ContentResolver` query into the
providing app. So the cost of an upload is dominated by one binder round
trip per 8 KiB, not by the transfer itself.

Measured with `File.upload()` on a 294 MB document picked from the
system picker, on an API 29 emulator: 94.4 s before, 0.86 s after
(numbers and method below). `MULTIPART` was never affected —
`MultipartBody` caches its own `contentLength()` — so this is
`BINARY_CONTENT` only, which is the default.

iOS is unaffected: it uploads via `URLSession.uploadTask(fromFile:)`.

# How

`contentLength()` is constant for the lifetime of a `RequestBody`, so
there is no reason to re-query it:

- `CountingRequestBody` resolves the delegate's length once into a
`lazy` field, and passes that `Long` to `CountingSink` instead of
passing the request body and letting the sink call back into it.
`CountingSink` no longer holds a `RequestBody` at all, which makes its
dependency on a constant length explicit rather than incidental.
- The `UnifiedFileInterface.asRequestBody` body resolves `length()`
once, also into a `lazy` field, for the same reason — that call is the
SAF query itself. Keeping it lazy rather than eager matters:
`ContentProviderFile.length()` and `AssetFile.length()` can fall back to
reading the whole stream, and this way that work still happens on the
OkHttp thread when OkHttp asks for the header, not on the caller's queue
while the request is being built.

No public API changes. Progress callbacks report the same values.

# Test Plan

Measured on device, on this branch versus its merge base, with the
`expo-file-system` Android sources built from source so the Kotlin under
test is the one in this diff.

**Correction to the original description:** the numbers in the first
version of this PR were produced by `File.upload()`, not by
`FileSystem.uploadAsync`. Thanks to @expo-bot for catching the wrong API
name — the legacy call converts its URI to a `java.io.File` and never
reaches this file, so it could not have exercised the change. Everything
below is a fresh measurement through `File.upload()`.

**Setup.** Pixel 7a AVD, API 29, arm64. `apps/minimal-tester`, built
with `npx expo run:android --variant release` on each arm. A 294 MB file
(293,940,775 bytes) pushed to `/sdcard/Download` and picked with
`File.pickFileAsync()`, which yields
`content://com.android.providers.downloads.documents/document/msf%3A26`
and so resolves to `SAFDocumentFile`. A Node HTTP sink on the host
counts the bytes it receives and discards them, reached over `adb
reverse tcp:8099 tcp:8099`. The app calls:

```ts
const result = await file.upload('http://127.0.0.1:8099/upload', {
  httpMethod: 'PUT',
  uploadType, // 0 = BINARY_CONTENT, 1 = MULTIPART
  headers: { 'Content-Type': 'application/octet-stream' },
  onProgress: ({ bytesSent, totalBytes }) => { /* count events, collect distinct totals */ },
});
```

**Results.** Each row is the wall clock of `file.upload()` measured in
JS, three consecutive runs where three are given.

| source | upload type | before | after |
|---|---|---|---|
| SAF `content://` | `BINARY_CONTENT` | 91.8 / 94.4 / 105.5 s | 1.02 /
0.85 / 0.86 s |
| SAF `content://` | `MULTIPART` | 1.02 s | 0.64 s |
| `file://` (same file copied into the cache dir) | `BINARY_CONTENT` |
1.24 / 0.98 s | 1.04 / 0.78 s |

**Correctness, identical in both arms and in every run:** the sink
received exactly 293,940,775 bytes and the request declared the same
`Content-Length`; the last progress event was `(293940775, 293940775)`;
and the progress stream carried exactly one distinct `totalBytes` value.
The event *count* drops from ~900–1000 to ~10 only because
`emitProgress` throttles to one event per 100 ms and the upload is now
two orders of magnitude shorter.

**Cause, confirmed on device.** Eight `debuggerd -j` dumps taken about a
second apart during a "before" upload caught the OkHttp thread in the
same place 8 times out of 8, and never in a socket write:

```
at android.os.BinderProxy.transactNative(Native method)
at android.content.ContentProviderProxy.query(ContentProviderNative.java:421)
at android.content.ContentResolver.query(ContentResolver.java:944)
at androidx.documentfile.provider.DocumentsContractApi19.queryForLong(DocumentsContractApi19.java:181)
at androidx.documentfile.provider.SingleDocumentFile.length(SingleDocumentFile.java:83)
at expo.modules.filesystem.unifiedfile.SAFDocumentFile.length(SAFDocumentFile.kt:90)
at expo.modules.filesystem.FileSystemUploadTaskKt$asRequestBody$1.contentLength(FileSystemUploadTask.kt:261)
at expo.modules.filesystem.CountingRequestBody.contentLength(FileSystemUploadTask.kt:231)
at expo.modules.filesystem.CountingSink.write(FileSystemUploadTask.kt:254)
at okio.RealBufferedSink.emitCompleteSegments(RealBufferedSink.kt:256)
at okio.RealBufferedSink.writeAll(RealBufferedSink.kt:195)
at okhttp3.internal.http.CallServerInterceptor.intercept(CallServerInterceptor.kt:62)
```

After the change the same upload finishes in under a second, and a
SAF-backed upload costs about the same as a `file://` one — which is
what it should cost, since `JavaFile.length()` was only ever a `stat`.

This was first found in a production app (CoMapeo), where importing a
custom offline map picked from the document picker ran at 1.8 MB/s and
looked like a hang on low-end phones; the same import with this change
lands in ~3 s.

# Checklist

- [x] I added a `changelog.md` entry and rebuilt the package sources
according to [this short
guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [x] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).
- [x] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)

---------

Co-authored-by: Bartłomiej Klocek <barthap10@gmail.com>
…#48914)

# Why

Creating a `VideoPlayer` via `useVideoPlayer()` on Android throws when
the current `Activity` is briefly unavailable — for example during a
cold start with an immediate background/recreation of the Activity:

```
Call to function 'VideoPlayer.constructor' has been rejected.
→ Caused by: The current activity is no longer available
```

The constructor obtained the application context through
`appContext.throwingActivity.applicationContext`, so it required an
`Activity` it doesn't actually need — the application context does not
depend on any Activity. This is the same failure family as #42939
(player creation while the app is backgrounded).

# How

Replaced `appContext.throwingActivity.applicationContext` with
`appContext.reactContext?.applicationContext` in the `VideoPlayer`
constructor (`VideoModule.kt`), following the pattern already used
elsewhere in the repo (e.g. `VideoManager.onModuleCreated`). If the
react context is gone, it now throws the typed
`Exceptions.ReactContextLost()` instead of an NPE.

Other `throwingActivity` usages were left untouched
(`isPictureInPictureSupported`, fullscreen entry in `VideoView`) since
those genuinely require an Activity.

# Test Plan

- `./gradlew :expo-video:compileDebugKotlin` — builds successfully.
- `./gradlew :expo-video:spotlessCheck` — passes.
- Manual: player creation no longer throws `MissingActivity` when the
Activity is destroyed/recreating; playback works as before when the
Activity is available.

# Checklist

- [x] Documentation is up to date to reflect these changes (eg:
https://docs.expo.dev and README.md).
- [x] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
- [x] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).

---------

Co-authored-by: Vojtech Novak <vonovak@gmail.com>
# Why

In order to remove `useSyncExternalStoreWithSelector` from
`useNavigationState`.

# How

1. Add `NavigatorStateContext` and use it to pass current navigator
slice down in the tree
2. Remove `NavigationStateListenerProvider` and
`useSyncExternalStoreWithSelector` usage

# Test Plan

CI

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
# Why

In order to remove `useSyncExternalStore` from `useIsFocused`

# How

Remove `useSyncExternalStore` fallback from `useIsFocused`

# Test Plan

CI

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
> [!WARNING]
> **Agent-authored and NOT human-reviewed.** An automated `/verify
--fix` run for #49058 wrote this change and checked it in a sandbox; the
reasoning and evidence are in the outcome comment on that issue. Review
it as you would any external contribution.

Requested by @brentvatne · [investigation run]((no run url)) · refs
#49058

Two sibling route groups that hold the same child path share one URL,
because a group segment is not part of the URL. In-app navigation keeps
the group you are in. A cold link (page reload, new tab, bookmark, deep
link) has no current group, so it renders the first alphabetical match.
The shared routes page covered this in one sentence about page reloads,
so users read the difference as a routing defect (#49058).

This change rewrites that sentence in
`docs/pages/router/advanced/shared-routes.mdx`: every shared route
matches the same URL, in-app navigation keeps the current group, a cold
link falls back to the first alphabetical match, and one URL can
therefore render two different screens. It also warns against using
shared routes for role-based screens. No runtime behaviour changes.

I measured that behaviour with the router's own code at this commit, in
the Web jest project. Vale, `oxfmt --check` and `pnpm test` in `docs/`
all pass (620 tests, 58 suites).

<details><summary>Cause</summary>

A group segment compiles to an optional regex group, so one URL matches
every group that holds the path:
[`getStateFromPath-forks.ts#L173-L178`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/getStateFromPath-forks.ts#L173-L178).

`getStateFromPath` takes the current segments as a third argument
([`getStateFromPath.ts#L70-L82`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/getStateFromPath.ts#L70-L82)),
and the config sorter prefers the candidate that shares more group
segments with the current route
([`getStateFromPath-forks.ts#L329-L346`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/getStateFromPath-forks.ts#L329-L346)).

In-app navigation passes those segments
([`getNavigationAction.ts#L43-L49`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/global-state/getNavigationAction.ts#L43-L49)).
A cold link passes none
([`useLinking.ts#L86`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/fork/useLinking.ts#L86),
[`useStore.ts#L141`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/global-state/useStore.ts#L141)).
The sorter then ties, the sort is stable, and file order decides. File
order is `require.context` key order. Metro sorts the file list before
it builds the context module, so the first group in alphabetical order
wins: `files.slice().sort()` in `createFileMap`,
`metro/src/lib/contextModuleTemplates.js` (line 36 in `metro@0.83.3`,
which `@expo/metro@54.2.0` re-exports; the same line in `metro@0.84.5`,
which this checkout resolves). That last step is a source read, not an
end-to-end measurement.

Protected routes do not take part in this. Guards are render-time only,
so a URL that lands in a guarded group is redirected to the navigator
anchor, not to the same path in the sibling group
([`useScreens.tsx#L364-L373`](https://github.com/expo/expo/blob/2edd383762349718ffb2b570e3b9cc248cf93a85/packages/expo-router/src/useScreens.tsx#L364-L373)).

</details>

<details><summary>Verification</summary>

Route tree used for every arm:

```
app/index.tsx
app/(creator)/_layout.tsx
app/(creator)/dashboard.tsx
app/(creator)/campaign-details/[id].tsx
app/(guest)/_layout.tsx
app/(guest)/browse.tsx
app/(guest)/campaign-details/[id].tsx
```

I called the real `getStateFromPath(path, config, previousSegments)`
with a config built by the router's own `getMockConfig`, and read the
result back with `getRouteInfoFromState`.

| Case | Current group | File order | URL | Resolved group |
| --- | --- | --- | --- | --- |
| A | none (cold link) | creator first | `/campaign-details/123` |
`(creator)` |
| D | none (cold link) | guest first | `/campaign-details/123` |
`(guest)` |
| B | `(guest)/browse` | creator first | `/campaign-details/123` |
`(guest)` |
| C | `(creator)/dashboard` | creator first | `/campaign-details/123` |
`(creator)` |
| G | `(guest)/browse` | guest first | `/campaign-details/123` |
`(guest)` |
| H | `(creator)/dashboard` | guest first | `/campaign-details/123` |
`(creator)` |
| E | none (cold link) | creator first | `/(guest)/campaign-details/123`
| `(guest)` |

A against D shows that a cold link follows file order. B, C, G and H
show that in-app navigation keeps the current group, whatever the file
order is. E shows that naming the group in the link pins the group.

I then rendered the same tree with the router's own `renderRouter`,
under the Web jest project in a `jsdom` environment. `initialUrl` is the
cold link, and `router.push` is in-app navigation:

```
PASS Web src/__tests__/issue-49058.test.web.tsx
  ✓ cold link renders the first group in file order (147 ms)
  ✓ in-app navigation from (guest) keeps the guest group at the same url (83 ms)
  ✓ in-app navigation from (creator) keeps the creator group at the same url (44 ms)
  ✓ naming the group in the href pins the group on a cold link (27 ms)
PASS Node src/__tests__/issue-49058.test.web.tsx
  ✓ cold link renders the first group in file order (100 ms)
  ✓ in-app navigation from (guest) keeps the guest group at the same url (70 ms)
  ✓ in-app navigation from (creator) keeps the creator group at the same url (42 ms)
  ✓ naming the group in the href pins the group on a cold link (21 ms)

Tests:       8 passed, 8 total
```

The same four cases also pass in the iOS jest project, so this is not
Web-specific. `getPathname()` stays `/campaign-details/123` in every
case, while `getSegments()` changes group. These test files were
measurement scaffolding and are not part of this change.

</details>

<details><summary>Checks run</summary>

From `docs/`:

- `.vale/bin/vale --config='.vale.ini'
pages/router/advanced/shared-routes.mdx` — `0 errors, 0 warnings and 0
suggestions in 1 file`.
- `oxfmt --check pages/router/advanced/shared-routes.mdx` — `All matched
files use the correct format.`
- `pnpm test` — `Test Suites: 58 passed, 58 total`, `Tests: 620 passed,
620 total`, `Snapshots: 32 passed, 32 total`.
- `pnpm test:worker` — `All tests passed!`.
- `tsc --noEmit -p .` — clean.

`pnpm lint` also runs `oxlint`, which crashed in my sandbox with a Rust
allocator panic (`oxc_allocator/src/pool/fixed_size.rs:112`) under
memory pressure. `oxlint` lints only JS and TS files; this change
touches neither. I did not run `expo-router` package checks, because
this change does not touch that package.

</details>

<details><summary>Not covered</summary>

- This change does not alter routing behaviour, so no app behaviour is
affected.
- The array syntax `(home,search)` builds the same two routes in memory
and should behave the same way, but I did not measure it.
- I did not drive a real browser. I ran the router in the Web jest
project with `jsdom` instead.
- I did not measure `require.context` key order end to end; the
alphabetical step is a source read of Metro.
- No test in the repository covers a cold link to an ambiguous shared
path. Every current "stay in the group" test navigates into a group
first. That gap remains.

</details>

<!-- expo-bot:fix-options v1 -->
<details><summary>Options considered</summary>

1. **Warn in development when two groups expose the same URL.** Shared
routes are a supported feature, and the array syntax `(home,search)`
creates this shape on purpose, so the warning would fire on apps that
are correct today. Rejected: it would add noise to every shared-routes
app and to the repository's own `apps/router-e2e` projects.
2. **Make URL resolution guard-aware, so a cold link prefers an
unguarded sibling group.** Guards live only in React render context
(`layouts/GuardContext.tsx`), while resolution runs before render in
`fork/getStateFromPath.ts`. Rejected: it needs a new data path from
render state into the linking layer, it changes which screen existing
cold links open, and that is a design decision for a maintainer.
3. **Sort sibling groups explicitly instead of relying on
`require.context` order.** Metro already sorts the file list before it
builds the context module, so no app would resolve a URL differently.
Rejected: it only removes a dependency on Metro's ordering, and it does
not address the reported confusion.
4. **Do nothing and document the behaviour.** Chosen: the behaviour is
intentional and already partly documented; the gap is that the page
never states that in-app navigation and a cold link resolve differently,
which is exactly what the reporter hit.

</details>
<!-- /expo-bot:fix-options -->

---------

Co-authored-by: expo-bot <expo-bot@users.noreply.github.com>
Co-authored-by: Aman Mittal <amandeepmittal@live.com>
…session interruption (#49239)

# Why

On iOS, a recording armed with `record({ forDuration })` loses its
duration limit if an audio session interruption occurs.
`handleInterruptionBegan` pauses the recorder, and the `.shouldResume`
path reaches `startRecording()`, which resumes with a **bare
`ref.record()`** — carrying no duration. The recording then continues
indefinitely.

An incoming call, an alarm, or Siri is enough to trigger it, and nothing
in the app is called, so there is no opportunity to re-arm from JS.

Measured on a physical iPhone 17 Pro Max (iOS 26.5),
`expo-audio@57.0.3`: a recorder armed `forDuration: 6.0`, paused, then
resumed via the bare call captured **14.06 s and was still going**.

# What this changes

`AudioRecorder` remembers the duration a recording was armed with, and
`startRecording()` re-applies it instead of resuming unbounded.

Two details that matter:

**The limit is re-applied absolutely, not as a remainder.**
`record(forDuration:)` stops when the recorder's own `currentTime`
*reaches* the value, and `currentTime` is cumulative across pause/resume
— so the original limit is correct. Re-arming with a computed remainder
was measured to end the recording early: 3.98 s against a 6.0 s arm.

**`resetDurationTracking()` deliberately does not clear the limit.**
`updateStateForDirectRecording()` calls that helper *after*
`record(forDuration:)` has armed the limit, so clearing it there would
discard the value the caller just set — and the first interruption would
resume unbounded again. The limit is cleared at capture boundaries
instead: `prepare`, `stopRecording`, `didFinish`, `encodeErrorDidOccur`,
and `handleMediaServicesReset`.

If the allowance is already spent when the resume arrives, the recorder
stops rather than resuming, which routes through the existing
`didFinish` path and emits `isFinished` as usual.

All three arming paths (`record({ forDuration })`, `record({ atTime,
forDuration })`, and the deprecated `recordForDuration`) now go through
one method, so none of them can bypass the limit.

# Test Plan

Verified on a physical device against `expo-audio@57.0.3` with the
equivalent change applied:

- armed 6.0 s, paused, resumed bare → **before:** 14.06 s and climbing;
**after:** stops at 6.000 s
- a real Clock-alarm interruption during a 120 s-capped recording →
resumes and ends at **exactly 120.0000 s** of audio, verified by parsing
the CAF `data` chunk, across 4 trials
- one canonical file throughout; the recorder's `didFinish` fires
normally at the limit

I could not run the repo's own iOS test suite for `expo-audio`; the
verification above is device-level against the published package.

---------

Co-authored-by: Wojciech Dróżdż <behenate@gmail.com>
# Why

<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

Add missing EAS dashboard entries (for example, Observe, Runtimes, etc.)
in the Search UI box and fix the broken one.

# Test Plan

<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

<img width="2004" height="2000" alt="CleanShot 2026-08-26 at 17 10
51@2x"
src="https://github.com/user-attachments/assets/8c74b812-ce01-4e55-8095-31aefa8290d1"
/>

<img width="1726" height="1860" alt="CleanShot 2026-08-26 at 17 10
59@2x"
src="https://github.com/user-attachments/assets/7c174990-5dac-4762-be7f-a814f0bfdffa"
/>


# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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)
# Why

<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

`Search.tsx` already lazy-loads the command menu. `CommandMenu` sits
behind `React.lazy`, and the trigger comes from the package's separate
lightweight `/trigger` entry. The intent was right and the code looked
right but it wasn't working as expected.

`ExpoDashboardItem.tsx` imports `addHighlight` and
`CommandItemBaseWithCopy` from the main `@expo/styleguide-search-ui`
entry, and `Search.tsx` imported that component at the top level. One
static import is enough for webpack to pull the whole package into the
sync graph, so it all landed in `_app` anyway and the `lazy()` ended up
resolving a module that was already loaded.

The `_app` chunk goes from 617,870 to 406,383 bytes (gzip -9), so about
207 KB off every page, and 16 packages leave the chunk entirely.
Measured on `/versions/latest/sdk/calendar/` with local Lighthouse at
median of 5: page weight 1363 KB to 1138 KB, Total Blocking Time 818 ms
to 715 ms. The TBT ranges don't overlap (799-832 before, 700-729 after).

# Test Plan

<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

N/A

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] 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)
…(address missed computed property exclusion) (#49278)

# Why

Resolves #49232
Related #45337

Previously, the split of `babel-preset-expo` into separate sub-presets
missed the `@babel/transform-object-rest-spread` plugin. I've likely
left it alone since I was unable to confirm that moving it wouldn't
cause the order-dependent issues the bug details.

This plugin should instead be only exercised for Hermes v0 and Webviews,
not for the Hermes v1 and Modern Web presets. It should be safely
movable assuming that it's added after
`@babel/plugin-transform-destructuring` (to avoid the ordering dependent
bug).

This is safe as long as we trust that Hermes does not have any
bugs/quirks in its implementation.

# How

- Move `transform-object-rest-spread` to individual `hermes-v0` and
`webview` presets
  - Note added to capture order dependence
- Update noxcturnal transformer config to mirror changes (main-only, no
changelog entry)

# Test Plan

- Unit tests added to capture transform bug related to #49232
- Unit tests added to capture plugin change in configs
- `test-suite` updated `JSDestructuring` with all relevant cases for
`transform-object-rest-spread`
  - **Note:** This is fully LLM-derived but looks comprehensive to me
- These tests have passed on a test run of `E2E_FORCE_BABEL=1 expo start
--clear` against a clean iOS simulator build
- Agent confirmed that the iOS bundle does not contain
`transform-object-rest-spread` transform outputs and instead contains
raw rest-spread patterns (Asked to validate with `pnpx 2g` and fetch the
raw iOS bundle identically to the simulator)

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [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)
# Why

Adds `expo-app-intents module`, which provides a framework for adding
App Intent support to Expo apps

# How

We can't really provide this functionality fully from JS. App Intents
are compiled from static swift classes at build time. Therefore our best
shot at adding App Intent and Apple Intelligence support with minimal
hassle for the users is allowing them to define the Intents via inline
modules.

We have to define the intents natively, but the data used by the intents
can be provided from JS.
For example: In a restaurant order use case - the Entities, Shortcuts
and Intents "shapes" have to be defined natively, but the individual
items can be provided at runtime from JS via `setEntityCatalogAsync`.

The general flow of a received AppIntent:

- An App Intent runs in the app target and calls
`AppIntentDispatcher.shared.dispatch(name:params:)`.
- `AppIntentDispatcher` wraps the call as an `AppIntentInvocation` with
an id, name, params, and timestamp.
- The dispatcher saves it through `AppIntentInvocationStore` first, so
the invocation survives even if JS is not running.
- If JS is alive, `ExpoAppIntentsModule` receives the invocation from
the dispatcher’s async stream and emits an onIntent event.
- JS handles live events via `addAppIntentListener()` or
`useAppIntents()`, or later reads stored ones with
`getPendingInvocationsAsync()`.
- After handling, JS calls `removePendingInvocationAsync(id)` or
`clearPendingInvocationsAsync()` to prevent reprocessing.
- For parameterized intents, JS manages searchable values with
`setEntityCatalogAsync()`, which stores entities in
`AppIntentEntityStore` and refreshes App Shortcuts.

The module provides functions for communication between the native and
js side:

- addAppIntentListener(listener) - subscribes to live App Intent
invocations while JS is running.
- useAppIntents(handler) - reads pending invocations on mount and
listens for new invocations afterward.
- getPendingInvocationsAsync() - returns queued intent invocations that
have not been removed yet.
- removePendingInvocationAsync(id) - marks one handled invocation as
removed from the pending queue.
- clearPendingInvocationsAsync() clears all queued pending invocations.
- setEntityCatalogAsync(kind, entities) - replaces an entity catalog
used by App Intents parameter queries and refreshes shortcuts.
- getEntityCatalogAsync(kind) reads the current entity catalog for a
given kind.
- refreshShortcutsAsync() asks iOS to re-evaluate App Shortcut phrases
and parameter values.

# Things coming in subsequent PRs

- cli tool for easy setup in an existing project. It will also allow
setup with a few examples for learning.
- Docs
- Examples for NCL

# Things we may add in the future

- Apple on-screen intelligence support for ExpoUI via a modifier - I
think this should be possible, but I didn't have a chance to try
implement it because I'm still on the waitlist for the new Siri, so I
would have no way of testing it.
- Non main-target App Intents - For some quick actions that don't
require an immediate action of the app it's not worth it to spin up the
main app target


# Test Plan

Tested on iPhone 13 and iPhone 17 in BareExpo and a separate test app

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Aug 26, 2026
@pull pull Bot added the ⤵️ pull label Aug 26, 2026
@pull
pull Bot merged commit a240859 into code:main Aug 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.