From ec2952537fa21dcf9f97f58587ba66ff49381b51 Mon Sep 17 00:00:00 2001
From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:39:09 +0200
Subject: [PATCH 1/4] [router] Wrap all actions from queue in transitions
(#49448)
# Why
At the moment all actions are dispatched synchronously. Add support for
transitions.
This is only an initial implementation and we still need to clarify the
best DX/UX.
# How
1. Wrap all queue actions into transition
# Test Plan
1. CI
2. Router e2e
# 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)
---
.../__e2e__/native-navigation/app/index.tsx | 1 +
.../app/suspense/_layout.tsx | 20 ++
.../native-navigation/app/suspense/index.tsx | 18 ++
.../initial-200ms-consecutive-200ms.tsx | 5 +
.../suspense/initial-200ms-consecutive-5s.tsx | 5 +
.../app/suspense/initial-200ms.tsx | 5 +
.../suspense/initial-5s-consecutive-5s.tsx | 5 +
.../app/suspense/initial-5s.tsx | 5 +
.../native-navigation/components/suspense.tsx | 199 ++++++++++++++++++
packages/expo-router/CHANGELOG.md | 2 +
packages/expo-router/src/exports.ts | 1 +
.../src/global-state/RoutingQueueDrainer.tsx | 41 ++--
.../navigationTransition.test.ios.tsx | 164 +++++++++++++++
.../routingQueueContext.test.ios.tsx | 4 +
.../src/global-state/routingQueueContext.tsx | 26 ++-
.../src/global-state/useIsNavigating.ts | 19 ++
packages/expo-router/src/imperative-api.tsx | 1 +
packages/expo-router/src/link/Link.tsx | 1 +
.../__tests__/navigation.test.ios.tsx | 26 +--
.../native-tabs/__tests__/render.test.ios.tsx | 11 +-
.../core/BaseNavigationContainer.tsx | 2 +
21 files changed, 518 insertions(+), 43 deletions(-)
create mode 100644 apps/router-e2e/__e2e__/native-navigation/app/suspense/_layout.tsx
create mode 100644 apps/router-e2e/__e2e__/native-navigation/app/suspense/index.tsx
create mode 100644 apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-200ms.tsx
create mode 100644 apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-5s.tsx
create mode 100644 apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms.tsx
create mode 100644 apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s-consecutive-5s.tsx
create mode 100644 apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s.tsx
create mode 100644 apps/router-e2e/__e2e__/native-navigation/components/suspense.tsx
create mode 100644 packages/expo-router/src/global-state/__tests__/navigationTransition.test.ios.tsx
create mode 100644 packages/expo-router/src/global-state/useIsNavigating.ts
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/index.tsx b/apps/router-e2e/__e2e__/native-navigation/app/index.tsx
index b2b3b1196af5a4..38d1fc349a7827 100644
--- a/apps/router-e2e/__e2e__/native-navigation/app/index.tsx
+++ b/apps/router-e2e/__e2e__/native-navigation/app/index.tsx
@@ -31,6 +31,7 @@ const HomeIndex = () => {
+
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/suspense/_layout.tsx b/apps/router-e2e/__e2e__/native-navigation/app/suspense/_layout.tsx
new file mode 100644
index 00000000000000..2968ecbedcdcb3
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/app/suspense/_layout.tsx
@@ -0,0 +1,20 @@
+import { Stack } from 'expo-router';
+import { Text, View } from 'react-native';
+
+export default function Layout() {
+ return ;
+}
+
+export function SuspenseFallback() {
+ return (
+
+ fallback
+
+ );
+}
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/suspense/index.tsx b/apps/router-e2e/__e2e__/native-navigation/app/suspense/index.tsx
new file mode 100644
index 00000000000000..043c927737d968
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/app/suspense/index.tsx
@@ -0,0 +1,18 @@
+import { usePathname } from 'expo-router';
+import { ScrollView, Text } from 'react-native';
+
+import { SuspenseLinks } from '../../components/suspense';
+
+export default function SuspenseIndex() {
+ const pathname = usePathname();
+ return (
+
+ Suspense
+ Current Path: {pathname}
+
+
+ );
+}
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-200ms.tsx b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-200ms.tsx
new file mode 100644
index 00000000000000..cc265e17ea7e51
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-200ms.tsx
@@ -0,0 +1,5 @@
+import { SuspenseScreen } from '../../components/suspense';
+
+export default function Screen() {
+ return ;
+}
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-5s.tsx b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-5s.tsx
new file mode 100644
index 00000000000000..adfe469b8de4db
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms-consecutive-5s.tsx
@@ -0,0 +1,5 @@
+import { SuspenseScreen } from '../../components/suspense';
+
+export default function Screen() {
+ return ;
+}
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms.tsx b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms.tsx
new file mode 100644
index 00000000000000..3052fcb88c225a
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-200ms.tsx
@@ -0,0 +1,5 @@
+import { SuspenseScreen } from '../../components/suspense';
+
+export default function Screen() {
+ return ;
+}
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s-consecutive-5s.tsx b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s-consecutive-5s.tsx
new file mode 100644
index 00000000000000..a8531998d51294
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s-consecutive-5s.tsx
@@ -0,0 +1,5 @@
+import { SuspenseScreen } from '../../components/suspense';
+
+export default function Screen() {
+ return ;
+}
diff --git a/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s.tsx b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s.tsx
new file mode 100644
index 00000000000000..d947f1bc5583fd
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/app/suspense/initial-5s.tsx
@@ -0,0 +1,5 @@
+import { SuspenseScreen } from '../../components/suspense';
+
+export default function Screen() {
+ return ;
+}
diff --git a/apps/router-e2e/__e2e__/native-navigation/components/suspense.tsx b/apps/router-e2e/__e2e__/native-navigation/components/suspense.tsx
new file mode 100644
index 00000000000000..534f99135ab1a1
--- /dev/null
+++ b/apps/router-e2e/__e2e__/native-navigation/components/suspense.tsx
@@ -0,0 +1,199 @@
+import {
+ Link,
+ unstable_useIsNavigating,
+ useIsFocused,
+ usePathname,
+ useRoute,
+ useRouter,
+ type Href,
+} from 'expo-router';
+import React, { use } from 'react';
+import { Pressable, ScrollView, Text, View } from 'react-native';
+
+type SuspenseScreenConfig = {
+ href: Href;
+ title: string;
+ /** Delay of the first render of a given route key, in milliseconds. */
+ initial: number;
+ /** Delay of every focus after the first one, in milliseconds. `0` never suspends again. */
+ consecutive: number;
+};
+
+export const SUSPENSE_SCREENS: SuspenseScreenConfig[] = [
+ {
+ href: '/suspense/initial-5s',
+ title: 'initial: 5s, consecutive: 0',
+ initial: 5000,
+ consecutive: 0,
+ },
+ {
+ href: '/suspense/initial-200ms',
+ title: 'initial: 200ms, consecutive: 0',
+ initial: 200,
+ consecutive: 0,
+ },
+ {
+ href: '/suspense/initial-200ms-consecutive-200ms',
+ title: 'initial: 200ms, consecutive: 200ms',
+ initial: 200,
+ consecutive: 200,
+ },
+ {
+ href: '/suspense/initial-5s-consecutive-5s',
+ title: 'initial: 5s, consecutive: 5s',
+ initial: 5000,
+ consecutive: 5000,
+ },
+ {
+ href: '/suspense/initial-200ms-consecutive-5s',
+ title: 'initial: 200ms, consecutive: 5s',
+ initial: 200,
+ consecutive: 5000,
+ },
+];
+
+type SuspenseRecord = {
+ /**
+ * The promise the screen waits on. It stays the same instance once resolved, so `use` returns
+ * right away until a new focus replaces it.
+ */
+ promise: Promise;
+ focusCount: number;
+ isFocused: boolean;
+};
+
+/** Keyed by navigation route key, so a new push of the same route suspends again. */
+const records = new Map();
+
+function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function getRecord(routeKey: string, initial: number): SuspenseRecord {
+ let record = records.get(routeKey);
+ if (!record) {
+ record = { promise: delay(initial), focusCount: 0, isFocused: false };
+ records.set(routeKey, record);
+ }
+ return record;
+}
+
+/**
+ * Suspends on the first render of a route key for `initial` ms, and on every focus after that for
+ * `consecutive` ms.
+ */
+function useSuspendOnFocus(initial: number, consecutive: number): SuspenseRecord {
+ const routeKey = useRoute().key;
+ // Re-renders the screen on focus and on blur, which is what drives the logic below.
+ const isFocused = useIsFocused();
+ const record = getRecord(routeKey, initial);
+
+ // A screen also re-renders for reasons other than focus, so only the blurred -> focused
+ // transition starts a new delay. Without that guard the render after the promise resolves would
+ // start another one and the screen would never settle.
+ if (isFocused !== record.isFocused) {
+ record.isFocused = isFocused;
+ if (isFocused) {
+ record.focusCount += 1;
+ if (record.focusCount > 1 && consecutive > 0) {
+ record.promise = delay(consecutive);
+ }
+ }
+ }
+
+ use(record.promise);
+ return record;
+}
+
+function formatDelay(delay: number) {
+ if (delay === 0) return '0';
+ return delay >= 1000 ? `${delay / 1000}s` : `${delay}ms`;
+}
+
+export function SuspenseScreen({ initial, consecutive }: { initial: number; consecutive: number }) {
+ const record = useSuspendOnFocus(initial, consecutive);
+ const pathname = usePathname();
+
+ return (
+
+ Current Path: {pathname}
+ Initial: {formatDelay(initial)}
+
+ Consecutive: {formatDelay(consecutive)}
+
+ Focus count: {record.focusCount}
+
+
+ );
+}
+
+export function SuspenseLinks() {
+ // True while a navigation is queued or its state update is pending. The screen that started the
+ // navigation stays visible while the destination suspends, so this is where it shows up.
+ const isNavigating = unstable_useIsNavigating();
+
+ return (
+
+ {/* Rendered even when idle so the list below does not jump. */}
+ {isNavigating ? 'Loading...' : ''}
+ {SUSPENSE_SCREENS.map((screen) => (
+
+ {screen.title}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function LinkButton({ href, text }: { href: Href; text: string }) {
+ return (
+
+ {text}
+
+ );
+}
+
+function BackButton() {
+ const router = useRouter();
+ return (
+ router.back()}
+ style={{ backgroundColor: 'rgb(66, 66, 66)', padding: 12, borderRadius: 8 }}>
+ back
+
+ );
+}
+
+function PreloadButton({ href }: { href: Href }) {
+ const router = useRouter();
+ return (
+ router.prefetch(href)}
+ style={{ backgroundColor: 'rgb(94, 53, 177)', padding: 12, borderRadius: 8 }}>
+ preload
+
+ );
+}
diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md
index 425a707151a338..8e262650b8fd95 100644
--- a/packages/expo-router/CHANGELOG.md
+++ b/packages/expo-router/CHANGELOG.md
@@ -4,6 +4,7 @@
### 🛠 Breaking changes
+- Dispatch queued navigation actions in React transitions. The current screen stays visible while the destination suspends, so `SuspenseFallback` no longer renders for navigation-triggered suspense. ([#49448](https://github.com/expo/expo/pull/49448) by [@Ubax](https://github.com/Ubax))
- Remove `beforeRemove`, `__unsafe_action__`, `PreventRemoveContext`, and `usePreventRemoveContext` from `expo-router/react-navigation`. ([#49408](https://github.com/expo/expo/pull/49408) by [@Ubax](https://github.com/Ubax))
- Preserve the focused route when switching navigator types in a conditional layout. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax))
- Generate deterministic navigation states and route keys. Complete states from custom routers or persisted state must include `routeKeySeq`. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax))
@@ -45,6 +46,7 @@
### 🎉 New features
+- Add `unstable_useIsNavigating` for observing queued or pending navigation. ([#49448](https://github.com/expo/expo/pull/49448) by [@Ubax](https://github.com/Ubax))
- Add unstable `NavigationAwareActivity` component. ([#49164](https://github.com/expo/expo/pull/49164) by [@Ubax](https://github.com/Ubax))
- Add screen error boundaries ([#49174](https://github.com/expo/expo/pull/49174) by [@Ubax](https://github.com/Ubax))
- Export `NativeStackView` for custom navigator implementations on web. ([#49204](https://github.com/expo/expo/pull/49204) by [@Ubax](https://github.com/Ubax))
diff --git a/packages/expo-router/src/exports.ts b/packages/expo-router/src/exports.ts
index 6b1f2674f36d7c..3aa3d4bd698fb0 100644
--- a/packages/expo-router/src/exports.ts
+++ b/packages/expo-router/src/exports.ts
@@ -16,6 +16,7 @@ export {
} from './hooks';
export { router, type ImperativeRouter } from './imperative-api';
+export { useIsNavigating as unstable_useIsNavigating } from './global-state/useIsNavigating';
export { withLayoutContext } from './layouts/withLayoutContext';
export { Navigator, Slot };
diff --git a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx
index 8191adcfcd411d..09843b0d626662 100644
--- a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx
+++ b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx
@@ -12,7 +12,7 @@ type Props = {
export function RoutingQueueDrainer({ ready, processIntent }: Props) {
const intents = React.use(PendingIntentsContext);
- const { dequeue } = React.use(RoutingQueueApiContext)!;
+ const { dequeue, startTransition } = React.use(RoutingQueueApiContext)!;
const lastProcessed = React.useRef(undefined);
React.useEffect(() => {
@@ -21,22 +21,33 @@ export function RoutingQueueDrainer({ ready, processIntent }: Props) {
}
// Strict Mode re-runs the mount effect with the same array before `dequeue` updates state.
lastProcessed.current = intents;
+ // TODO(@ubax): Navigation runs in a transition, so a destination that suspends keeps the
+ // current screen visible and never renders `SuspenseFallback` (including the web dev
+ // "Bundling..." toast for async routes). Design a fallback UX for pending navigation.
+ // Dequeue urgently so a later enqueue is not rebased on a stale queue.
dequeue(intents);
- for (const intent of intents) {
- // Only catches errors thrown while dispatching. The navigation reducer runs
- // during the next render, so errors from it surface there, not here.
- try {
- intent.onDispatch?.(intent.metadata);
- processIntent(intent);
- } catch (error) {
- const message =
- typeof error === 'object' && error != null && 'message' in error ? error.message : error;
- console.warn(
- `An error occurred when trying to handle navigation action ${JSON.stringify(intent)}: ${message}`
- );
+ startTransition(() => {
+ for (const intent of intents) {
+ // Only catches errors thrown while dispatching. The navigation reducer runs
+ // during the next render, so errors from it surface there, not here.
+ try {
+ // TODO(@ubax): `onDispatch` records the web history operation now, but the commit that
+ // consumes it is deferred by the transition and an urgent `dispatchSync` can land in between.
+ // https://linear.app/expo/issue/ENG-22046
+ intent.onDispatch?.(intent.metadata);
+ processIntent(intent);
+ } catch (error) {
+ const message =
+ typeof error === 'object' && error != null && 'message' in error
+ ? error.message
+ : error;
+ console.warn(
+ `An error occurred when trying to handle navigation action ${JSON.stringify(intent)}: ${message}`
+ );
+ }
}
- }
- }, [dequeue, intents, processIntent, ready]);
+ });
+ }, [dequeue, intents, processIntent, ready, startTransition]);
return null;
}
diff --git a/packages/expo-router/src/global-state/__tests__/navigationTransition.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/navigationTransition.test.ios.tsx
new file mode 100644
index 00000000000000..b5764cc66e1748
--- /dev/null
+++ b/packages/expo-router/src/global-state/__tests__/navigationTransition.test.ios.tsx
@@ -0,0 +1,164 @@
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native';
+import { use } from 'react';
+import { Text } from 'react-native';
+
+import { unstable_useIsNavigating, usePathname } from '../../exports';
+import Stack from '../../layouts/StackClient';
+import { unstable_navigationEvents } from '../../navigationEvents';
+import { CommonActions } from '../../react-navigation/routers';
+import { renderRouter } from '../../testing-library';
+import { navigationRef } from '../navigationRef';
+import { router } from '../router';
+
+afterEach(cleanup);
+
+function createDeferred() {
+ let resolve!: (value: string) => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+function PendingStackLayout() {
+ const isNavigating = unstable_useIsNavigating();
+ return (
+ <>
+ {String(isNavigating)}
+
+ >
+ );
+}
+
+it('keeps the current screen visible and reports pending while a navigation suspends', async () => {
+ const deferred = createDeferred();
+
+ function SlowScreen() {
+ const content = use(deferred.promise);
+ return {content};
+ }
+
+ renderRouter({
+ _layout: PendingStackLayout,
+ index: () => Index,
+ slow: {
+ default: SlowScreen,
+ SuspenseFallback: () => Fallback,
+ },
+ });
+
+ expect(screen.getByTestId('is-navigating')).toHaveTextContent('false');
+ expect(screen.getByTestId('index')).toBeVisible();
+
+ const navigationAct = act(() => router.push('/slow'));
+
+ expect(screen.getByTestId('is-navigating')).toHaveTextContent('true');
+ expect(screen.getByTestId('index')).toBeVisible();
+ expect(screen.queryByTestId('fallback')).toBeNull();
+
+ deferred.resolve('Slow');
+ await navigationAct;
+
+ await waitFor(() => expect(screen.getByTestId('is-navigating')).toHaveTextContent('false'));
+ expect(screen.getByTestId('slow')).toBeVisible();
+});
+
+it('commits two queued navigations in one transition', async () => {
+ const committedPaths: string[] = [];
+
+ function Layout() {
+ committedPaths.push(usePathname());
+ return ;
+ }
+
+ renderRouter({
+ _layout: Layout,
+ index: () => Index,
+ first: () => First,
+ second: () => Second,
+ });
+
+ await act(async () => {
+ router.push('/first');
+ router.push('/second');
+ });
+
+ expect(screen).toHavePathname('/second');
+ expect(committedPaths).not.toContain('/first');
+});
+
+it('processes each intent once when one is queued during a pending transition', async () => {
+ const deferred = createDeferred();
+ const dispatchedActions: string[] = [];
+
+ function SlowScreen() {
+ const content = use(deferred.promise);
+ return {content};
+ }
+
+ renderRouter({
+ _layout: PendingStackLayout,
+ index: () => Index,
+ slow: SlowScreen,
+ sync: () => Sync,
+ });
+ const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) =>
+ dispatchedActions.push(event.actionType)
+ );
+
+ try {
+ const navigationAct = act(() => router.push('/slow'));
+ expect(screen.getByTestId('is-navigating')).toHaveTextContent('true');
+ expect(screen.getByTestId('index')).toBeVisible();
+
+ await act(async () => {
+ router.push('/sync');
+ });
+
+ deferred.resolve('Slow');
+ await navigationAct;
+
+ expect(screen.getByTestId('sync')).toBeVisible();
+ expect(screen).toHavePathname('/sync');
+ expect(dispatchedActions).toHaveLength(2);
+ } finally {
+ unsubscribe();
+ }
+});
+
+it('preserves action order when a synchronous dispatch interrupts a transition', async () => {
+ const deferred = createDeferred();
+
+ function SlowScreen() {
+ const content = use(deferred.promise);
+ return {content};
+ }
+
+ renderRouter({
+ _layout: PendingStackLayout,
+ index: () => Index,
+ sync: () => Sync,
+ slow: SlowScreen,
+ });
+
+ const navigationAct = act(() => router.push('/slow'));
+ expect(screen.getByTestId('is-navigating')).toHaveTextContent('true');
+
+ act(() => navigationRef.current?.dispatchSync(CommonActions.navigate('sync')));
+
+ deferred.resolve('Slow');
+ await navigationAct;
+
+ expect(screen.getByTestId('sync')).toBeVisible();
+ expect(screen).toHavePathname('/sync');
+});
+
+it('reports no pending navigation outside ExpoRoot', () => {
+ function Consumer() {
+ return {String(unstable_useIsNavigating())};
+ }
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('is-navigating')).toHaveTextContent('false');
+});
diff --git a/packages/expo-router/src/global-state/__tests__/routingQueueContext.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/routingQueueContext.test.ios.tsx
index d5d8ae7aa31c21..3404024e1d2ece 100644
--- a/packages/expo-router/src/global-state/__tests__/routingQueueContext.test.ios.tsx
+++ b/packages/expo-router/src/global-state/__tests__/routingQueueContext.test.ios.tsx
@@ -4,6 +4,7 @@ import { use, type ContextType } from 'react';
import { router } from '../router';
import type { RoutingIntent } from '../routingQueue';
import {
+ NavigationPendingContext,
PendingIntentsContext,
RoutingQueueApiContext,
RoutingQueueProvider,
@@ -64,11 +65,13 @@ it('warns when a second root binds the imperative router', () => {
it('preserves enqueue order and drains on the following render', () => {
const snapshots: RoutingIntent[][] = [];
+ const pendingSnapshots: boolean[] = [];
let enqueue: ReturnType;
function Consumer() {
enqueue = useEnqueueRoutingIntent();
snapshots.push(use(PendingIntentsContext));
+ pendingSnapshots.push(use(NavigationPendingContext));
return null;
}
@@ -84,6 +87,7 @@ it('preserves enqueue order and drains on the following render', () => {
});
expect(snapshots).toEqual([[], [actionIntent('FIRST'), actionIntent('SECOND')]]);
+ expect(pendingSnapshots).toEqual([false, true]);
});
it('keeps intents added while a batch is being dequeued', () => {
diff --git a/packages/expo-router/src/global-state/routingQueueContext.tsx b/packages/expo-router/src/global-state/routingQueueContext.tsx
index ecbc1e22201b9f..38362d86869a34 100644
--- a/packages/expo-router/src/global-state/routingQueueContext.tsx
+++ b/packages/expo-router/src/global-state/routingQueueContext.tsx
@@ -1,6 +1,14 @@
'use client';
-import { createContext, use, useMemo, useState, type PropsWithChildren } from 'react';
+import {
+ createContext,
+ use,
+ useMemo,
+ useState,
+ useTransition,
+ type PropsWithChildren,
+ type TransitionStartFunction,
+} from 'react';
import { useClientLayoutEffect } from '../react-navigation/core/useClientLayoutEffect';
import { createImperativeRouter, router, unboundRouter } from './router';
@@ -17,29 +25,35 @@ const throwMissingRoutingQueue = () => {
export type RoutingQueueApi = {
enqueue: (intent: RoutingIntent) => void;
dequeue: (processed: RoutingIntent[]) => void;
+ startTransition: TransitionStartFunction;
};
export const RoutingQueueApiContext = createContext(undefined);
export const PendingIntentsContext = createContext(EMPTY);
+export const NavigationPendingContext = createContext(false);
export function RoutingQueueProvider({ children }: PropsWithChildren) {
const [queue, setQueue] = useState(EMPTY);
+ const [isPending, startTransition] = useTransition();
const api = useMemo(
() => ({
enqueue: (intent) => setQueue((previous) => [...previous, intent]),
// Keep intents added between the drained render and this state update.
dequeue: (processed) =>
setQueue((previous) => (previous === processed ? EMPTY : previous.slice(processed.length))),
+ startTransition,
}),
- []
+ [startTransition]
);
return (
-
- {children}
-
-
+ 0}>
+
+ {children}
+
+
+
);
}
diff --git a/packages/expo-router/src/global-state/useIsNavigating.ts b/packages/expo-router/src/global-state/useIsNavigating.ts
new file mode 100644
index 00000000000000..8c8a13ce183270
--- /dev/null
+++ b/packages/expo-router/src/global-state/useIsNavigating.ts
@@ -0,0 +1,19 @@
+'use client';
+
+import { use } from 'react';
+
+import { NavigationPendingContext } from './routingQueueContext';
+
+/**
+ * Returns whether a navigation is queued or its state update is pending.
+ *
+ * The current screen can remain visible while this returns `true` if the destination suspends.
+ * `navigation.dispatchSync` bypasses the queue, and native back gestures never enter it, so this
+ * hook does not report navigation triggered by either one.
+ * Returns `false` when called outside an Expo Router root.
+ *
+ * @experimental
+ */
+export function useIsNavigating(): boolean {
+ return use(NavigationPendingContext);
+}
diff --git a/packages/expo-router/src/imperative-api.tsx b/packages/expo-router/src/imperative-api.tsx
index 0f86bf1a7f52b7..e375f8a86208bf 100644
--- a/packages/expo-router/src/imperative-api.tsx
+++ b/packages/expo-router/src/imperative-api.tsx
@@ -3,4 +3,5 @@ import { router as internalRouter } from './global-state/router';
export type { ImperativeRouter };
// Hide internal `goBack` and `linkTo` methods from the public API and typedoc.
+// TODO(@ubax): Return promises from navigation methods that resolve when their transitions commit.
export const router: ImperativeRouter = internalRouter;
diff --git a/packages/expo-router/src/link/Link.tsx b/packages/expo-router/src/link/Link.tsx
index 3e992c3229be47..cb18201565ae07 100644
--- a/packages/expo-router/src/link/Link.tsx
+++ b/packages/expo-router/src/link/Link.tsx
@@ -32,6 +32,7 @@ export const Link = Object.assign(
*}
* ```
*/
+ // TODO(@ubax): Expose pending status scoped to this link's navigation.
function Link(props: LinkProps) {
// Re-exporting ExpoLink here so that Link.* can be used in server components.
return ;
diff --git a/packages/expo-router/src/native-tabs/__tests__/navigation.test.ios.tsx b/packages/expo-router/src/native-tabs/__tests__/navigation.test.ios.tsx
index 810e17f29c170c..76d7bf3d44c961 100644
--- a/packages/expo-router/src/native-tabs/__tests__/navigation.test.ios.tsx
+++ b/packages/expo-router/src/native-tabs/__tests__/navigation.test.ios.tsx
@@ -33,10 +33,6 @@ describe('Native Bottom Tabs Navigation', () => {
expect(TabsScreen).toHaveBeenCalledTimes(2);
}
- function expectTwoRenders() {
- expect(TabsScreen).toHaveBeenCalledTimes(4);
- }
-
function lastHostSelectedKey() {
const calls = TabsHost.mock.calls;
return calls[calls.length - 1][0].navStateRequest.selectedScreenKey;
@@ -89,25 +85,23 @@ describe('Native Bottom Tabs Navigation', () => {
it('can navigate using router.push', () => {
act(() => router.push('/second'));
- expectTwoRenders();
- expectSecondTabFocused(2);
+ expectOneRender();
+ expectSecondTabFocused();
TabsScreen.mockClear();
act(() => router.push('/'));
- expectTwoRenders();
- expectIndexTabFocused(2);
+ expectOneRender();
+ expectIndexTabFocused();
});
it('can navigate using Link', () => {
act(() => fireEvent.press(screen.getByTestId('index-second-link')));
- // First render is deferred index=0, index =1
- // Second one is deferred index=1, index =1
- expectTwoRenders();
- expectSecondTabFocused(2);
+ expectOneRender();
+ expectSecondTabFocused();
TabsScreen.mockClear();
act(() => fireEvent.press(screen.getByTestId('second-index-link')));
- expectTwoRenders();
- expectIndexTabFocused(2);
+ expectOneRender();
+ expectIndexTabFocused();
});
it('does not re-render when router.push is called to the same tab', () => {
@@ -123,7 +117,7 @@ describe('Native Bottom Tabs Navigation', () => {
TabsScreen.mockClear();
act(() => router.push('/second'));
- expectSecondTabFocused(2);
+ expectSecondTabFocused();
TabsScreen.mockClear();
act(() => fireEvent.press(screen.getByTestId('second-second-link'))); // link to same tab
@@ -138,7 +132,7 @@ describe('Native Bottom Tabs Navigation', () => {
TabsScreen.mockClear();
act(() => router.push('/second'));
- expectSecondTabFocused(2);
+ expectSecondTabFocused();
act(() => fireEvent.press(screen.getByTestId('second-hidden-link')));
expect(lastHostSelectedKey()).toBe('index');
diff --git a/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx b/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx
index e949248bf9114a..931ff8eed79c07 100644
--- a/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx
+++ b/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx
@@ -393,12 +393,11 @@ describe('First focused tab', () => {
expect(screen.getByTestId('index')).toBeVisible();
expect(screen.getByTestId('second')).toBeVisible();
- expect(TabsScreen).toHaveBeenCalledTimes(4);
- expect(TabsScreen.mock.calls[2][0].screenKey).toBe('index');
- expect(TabsScreen.mock.calls[3][0].screenKey).toBe('second');
- expect(TabsHost).toHaveBeenCalledTimes(2);
- expect(TabsHost.mock.calls[0][0].navStateRequest.selectedScreenKey).toBe('index');
- expect(TabsHost.mock.calls[1][0].navStateRequest.selectedScreenKey).toBe('second');
+ expect(TabsScreen).toHaveBeenCalledTimes(2);
+ expect(TabsScreen.mock.calls[0][0].screenKey).toBe('index');
+ expect(TabsScreen.mock.calls[1][0].screenKey).toBe('second');
+ expect(TabsHost).toHaveBeenCalledTimes(1);
+ expect(TabsHost.mock.calls[0][0].navStateRequest.selectedScreenKey).toBe('second');
TabsScreen.mockClear();
TabsHost.mockClear();
diff --git a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx
index 87a570b9edd0bd..0b152bc57d259e 100644
--- a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx
+++ b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx
@@ -133,6 +133,8 @@ function BaseNavigationContainerInner({
const dispatchSync = useLatestCallback((action: NavigationAction) => {
// TODO(@ubax): Throw if this is called from a `removePrevented` callback.
+ // TODO(@ubax): Review urgent dispatches interleaved with pending navigation transitions. React
+ // rebases the queued actions, but intermediate state can reflect only the urgent action.
handleAction(action);
});
From 2b8811ea49fb9c4cbccabac5890f35042bdef987 Mon Sep 17 00:00:00 2001
From: Szymon Dziedzic
Date: Fri, 28 Aug 2026 13:47:10 +0200
Subject: [PATCH 2/4] docs: update EAS Simulator preview guide (#49451)
# Why
The hidden EAS Simulator preview docs did not cover the latest simulator
command flags and had drifted from the updated EAS Simulator skill. This
made the recommended session setup, app installation, and
live-development workflows incomplete.
# How
- Document the current `simulator:start` options for naming and device
selection, build/archive/Expo Go sources, launch URLs and arguments,
automatic duration and idle limits, and Appium sessions.
- Update the run-and-control guide to prefer start-time app installation
and launch while retaining controller installation for local artifacts.
- Add purpose-based session naming, name filtering, current aliases,
JSON output details, and troubleshooting for stale sessions and invalid
launch-source combinations.
- Update the EAS Simulator waitlist URL.
The command reference was checked against EAS CLI 22.6.0 and the
workflows were aligned with the current EAS Simulator skill.
# Test Plan
Preview
---------
Co-authored-by: Brent Vatne
Co-authored-by: Aman Mittal
---
docs/pages/eas/cli.mdx | 2 +-
.../preview/eas-simulator/cli-reference.mdx | 87 ++++++++++---
.../preview/eas-simulator/get-started.mdx | 57 +++++++--
.../preview/eas-simulator/introduction.mdx | 33 ++---
.../preview/eas-simulator/run-and-control.mdx | 115 ++++++++++++++----
.../preview/eas-simulator/troubleshooting.mdx | 36 +++++-
.../data/eas-cli-commands.json | 12 +-
7 files changed, 263 insertions(+), 79 deletions(-)
diff --git a/docs/pages/eas/cli.mdx b/docs/pages/eas/cli.mdx
index a3ba85d30c6f5b..68dee0ab595464 100644
--- a/docs/pages/eas/cli.mdx
+++ b/docs/pages/eas/cli.mdx
@@ -2,7 +2,7 @@
title: EAS CLI reference
sidebar_title: EAS CLI
description: EAS CLI is a command-line tool that allows you to interact with Expo Application Services (EAS) from your terminal.
-cliVersion: 22.6.0
+cliVersion: 23.0.0
---
import { EASCLIReference } from '~/ui/components/EASCLIReference';
diff --git a/docs/pages/preview/eas-simulator/cli-reference.mdx b/docs/pages/preview/eas-simulator/cli-reference.mdx
index 18a489e368a6e3..23bd896798be37 100644
--- a/docs/pages/preview/eas-simulator/cli-reference.mdx
+++ b/docs/pages/preview/eas-simulator/cli-reference.mdx
@@ -13,6 +13,8 @@ The `simulator:*` commands are experimental and hidden. Install or update [EAS C
The `--help` flag displays the available options for `simulator:start`.
+The start command is also available as `eas simulator`, `eas sim`, and `eas sim:start`. The other commands have matching `eas sim:*` aliases, such as `eas sim:list` and `eas sim:stop`.
+
## Commands
| Command | Purpose |
@@ -34,7 +36,7 @@ The `--json` flag is optional. It prints a stable, machine-readable result for a
## `simulator:start`
-A controller is the tool provisioned with the remote device to install, inspect, and interact with apps. Choose a controller type when starting a session:
+The session type determines which interface EAS provisions with the remote device. On iOS, every type includes a web preview. Choose agent-device, Argent, or Appium to add programmatic control, or choose `web-preview-only` for the web preview only:
@@ -42,7 +44,11 @@ A controller is the tool provisioned with the remote device to install, inspect,
Use [agent-device](/agents/agent-device/) for accessibility-driven device actions and app installation.
-
+
@@ -50,7 +56,35 @@ Use [agent-device](/agents/agent-device/) for accessibility-driven device action
Use [Argent](/agents/argent/) to run its remote device tools through the session.
-
+
+
+
+
+
+
+Use Appium when you want to connect an existing Appium client or test suite.
+
+
+
+
+
+
+
+Use the iOS web preview without provisioning a programmatic controller.
+
+
@@ -58,15 +92,27 @@ Use [Argent](/agents/argent/) to run its remote device tools through the session
Important flags:
-| Flag | Description |
-| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
-| `-p, --platform` | `android` or `ios`. Required in non-interactive mode; prompted for in an interactive terminal |
-| `--type` | Controller provisioned for the session. Use `agent-device` or `argent` for programmatic control, or `serve-sim` for a browser-only preview |
-| `--package-version` | Controller package version. Defaults to the service's latest version |
-| `--out-config-type` | `dotenv` to write **.env.eas-simulator**, or `env` to print shell exports |
-| `--[no-]force` | Whether to create a new session when a session ID already exists in the environment. Defaults to `true` |
-| `--non-interactive` | Return after the controller is ready instead of staying attached |
-| `--json` | Print machine-readable output and imply non-interactive mode |
+| Flag | Description |
+| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `-p, --platform` | `android` or `ios`. Required in non-interactive mode; prompted for in an interactive terminal |
+| `--name` | Human-readable session name shown by `simulator:list`, `simulator:get`, and expo.dev |
+| `--device` | iOS Simulator name or unique device identifier (UDID), or Android virtual device hardware profile. The runner chooses a device when omitted |
+| `--build-id` | EAS Build to install and launch before the session is ready |
+| `--application-archive-url` | Application archive to download, install, and launch before the session is ready |
+| `--expo-go` | Install and launch the Expo Go version that matches the current project's Expo SDK |
+| `--sdk-version` | Expo SDK used to select Expo Go. Only valid with `--expo-go`; defaults to the current project SDK |
+| `--launch-arg` | Argument passed to the installed app when it launches. Repeat the flag for multiple arguments |
+| `--open-url` | Expo or development-client URL to open in the installed app after launch |
+| `--type` | Session type. On iOS, every type includes a web preview. Use `agent-device`, `appium`, or `argent` to add programmatic control, or `web-preview-only` for the web preview only |
+| `--package-version` | Controller package version. Defaults to the service's latest version |
+| `--max-duration-minutes` | Automatic session stop time. Custom values are available on paid plans; otherwise the default depends on job priority |
+| `--max-idle-time-minutes` | Stop after this many minutes without session activity. When omitted, the session has no idle timeout and runs until its maximum duration |
+| `--out-config-type` | `dotenv` to write **.env.eas-simulator**, or `env` to print shell exports |
+| `--[no-]force` | Whether to create a new session when a session ID already exists in the environment. Defaults to `true` |
+| `--non-interactive` | Return after the session is ready instead of staying attached |
+| `--json` | Print machine-readable output and imply non-interactive mode |
+
+`--build-id`, `--application-archive-url`, and `--expo-go` are mutually exclusive application sources. `--launch-arg` and `--open-url` require one of those sources because the start command needs an installed application to launch.
The default `dotenv` output writes configuration even when `--json` is used. Use `--out-config-type env` when you explicitly do not want the file.
@@ -75,6 +121,7 @@ Common JSON output fields have this shape. The fields inside `remoteConfig` depe
```json
{
"id": "",
+ "name": "Checkout flow screenshots",
"type": "",
"deviceRunSessionUrl": "https://expo.dev/accounts//projects//simulator-sessions/",
"remoteConfig": {
@@ -151,7 +198,7 @@ Get another session explicitly:
The response includes:
-- ID, type, status, and platform
+- ID, name, type, status, and platform
- Created, started, finished, and updated timestamps
- The expo.dev simulator session URL
- Controller connection configuration
@@ -165,11 +212,12 @@ The response includes:
Filters can be repeated:
-| Filter | Values |
-| ------------ | ------------------------------------------ |
-| `--platform` | `ios`, `android` |
-| `--type` | `agent-device`, `argent`, `serve-sim` |
-| `--status` | `new`, `in-progress`, `stopped`, `errored` |
+| Filter | Values |
+| ------------ | ------------------------------------------------------ |
+| `--platform` | `ios`, `android` |
+| `--type` | `agent-device`, `appium`, `argent`, `web-preview-only` |
+| `--status` | `new`, `in-progress`, `stopped`, `errored` |
+| `--name` | Case-insensitive session name prefix |
Use `--limit` to control page size and `--after` with the previous response's `endCursor` for pagination.
@@ -185,6 +233,8 @@ Stop a specific session:
The stop mutation is idempotent.
+Use `--json` for a machine-readable object containing the session `id` and final `status`.
+
## **.env.eas-simulator**
The managed file always contains the session ID:
@@ -199,6 +249,7 @@ Controller connection variables depend on the selected type:
| ------------ | ---------------------------------------------------------------- |
| agent-device | `AGENT_DEVICE_DAEMON_BASE_URL`, `AGENT_DEVICE_DAEMON_AUTH_TOKEN` |
| Argent | `ARGENT_TOOLS_URL` and, when required, `ARGENT_AUTH_TOKEN` |
+| Appium | `APPIUM_URL`, `APPIUM_CAPS` |
Add the file to **.gitignore**:
diff --git a/docs/pages/preview/eas-simulator/get-started.mdx b/docs/pages/preview/eas-simulator/get-started.mdx
index b66245936d1362..7c7735af427354 100644
--- a/docs/pages/preview/eas-simulator/get-started.mdx
+++ b/docs/pages/preview/eas-simulator/get-started.mdx
@@ -10,7 +10,7 @@ import { Step } from '~/ui/components/Step';
import { Terminal } from '~/ui/components/Snippet';
import { Tab, Tabs } from '~/ui/components/Tabs';
-> **important** **EAS Simulator is a limited-access preview.** It is not included with paid or free plans and is currently available only to select partners. [Join the waitlist](https://expo.dev/services/simulator) if you are interested in trying it.
+> **important** **EAS Simulator is a limited-access preview.** It is not included with paid or free plans and is currently available only to select partners. [Join the waitlist](https://expo.dev/services/simulators) if you are interested in trying it.
@@ -23,7 +23,8 @@ import { Tab, Tabs } from '~/ui/components/Tabs';
The `simulator:*` commands are experimental and hidden. [Install or update EAS CLI](/eas/cli/)
- before using them.
+ before using them. Since the preview changes quickly, use `eas simulator:start --help` to check
+ the flags supported by your installed version.
@@ -68,7 +69,7 @@ An enabled account returns:
}
```
-If `available` is `false`, do not call `simulator:start`. Use a local simulator or emulator, or [join the waitlist](https://expo.dev/services/simulator) for access.
+If `available` is `false`, do not call `simulator:start`. Use a local simulator or emulator, or [join the waitlist](https://expo.dev/services/simulators) for access.
@@ -88,9 +89,9 @@ Do not commit or share this file. It includes the token that lets a controller r
-### Choose a controller and start a remote device
+### Choose a session type and start a remote device
-Choose the controller you plan to use for installation and device actions. Pass its type explicitly when you start the session.
+Choose the controller you plan to use for installation and device actions. On iOS, each controller-backed session also includes a web preview. If you only need the web preview, use `web-preview-only` without a controller. Pass the session type explicitly when you start the session.
@@ -98,7 +99,11 @@ Choose the controller you plan to use for installation and device actions. Pass
See [agent-device and Expo](/agents/agent-device/) for controller setup and commands.
-
+
@@ -106,22 +111,56 @@ See [agent-device and Expo](/agents/agent-device/) for controller setup and comm
See [Argent and Expo](/agents/argent/) for controller setup and commands.
-
+
+
+
+
+
+
+Use an Appium client when you already have Appium tests or automation. EAS CLI writes the remote server URL and capabilities to the session configuration.
+
+
+
+
+
+
+
+Use the iOS web preview without provisioning a programmatic controller.
+
+
-Use `--platform android` for an Android Emulator. Android sessions do not currently include a live browser preview.
+Give each session a short name that describes its purpose. The name appears in `simulator:list`, `simulator:get`, and on expo.dev, which makes parallel and historical sessions easier to identify.
+
+Use `--platform android` for an Android Emulator. Android sessions do not currently include a live browser preview. Use `--device` to select a specific iOS Simulator name or unique device identifier (UDID), or an Android virtual device hardware profile. If the flag is omitted, the remote runner chooses a device.
+
+For an unattended or shareable session, use `--max-duration-minutes` to set an automatic stop time. You can also use `--max-idle-time-minutes` to stop after a period without session activity. A custom maximum duration is available on paid plans.
When `--platform` is omitted in an interactive terminal, EAS CLI asks which platform to use. Non-interactive runs require the flag.
-The start command waits for the selected controller to become ready, then writes **.env.eas-simulator**. It also prints:
+The start command waits for the session to become ready, then writes **.env.eas-simulator**. It also prints:
- A direct simulator session page on expo.dev
- A temporary `webPreviewUrl` for supported iOS sessions
- The command pattern for controlling the device
+To install and launch an app before the session becomes ready, pass exactly one of `--build-id`, `--application-archive-url`, or `--expo-go`. You can also pass launch arguments and an Expo or development-client URL. See [Run and control an app](/preview/eas-simulator/run-and-control/) for complete examples.
+
> **warning** `--non-interactive` does not automatically stop the session. The command returns when the session is ready, and the remote device remains active.
diff --git a/docs/pages/preview/eas-simulator/introduction.mdx b/docs/pages/preview/eas-simulator/introduction.mdx
index 9273c7e03bbb7a..6b232e9fe560c7 100644
--- a/docs/pages/preview/eas-simulator/introduction.mdx
+++ b/docs/pages/preview/eas-simulator/introduction.mdx
@@ -10,7 +10,7 @@ import { BoxLink } from '~/ui/components/BoxLink';
import { NoIcon, YesIcon } from '~/ui/components/DocIcons';
import { RelatedSkills } from '~/ui/components/RelatedSkills';
-> **important** **EAS Simulator is a limited-access preview.** It is not included with paid or free plans and is currently available only to select partners. [Join the waitlist](https://expo.dev/services/simulator) if you are interested in trying it.
+> **important** **EAS Simulator is a limited-access preview.** It is not included with paid or free plans and is currently available only to select partners. [Join the waitlist](https://expo.dev/services/simulators) if you are interested in trying it.
**EAS Simulator** runs a remote iOS Simulator or Android Emulator on EAS infrastructure. You can install an app, inspect its user interface, and interact with it. You can also collect screenshots and recordings or share an iOS browser preview without running the device locally.
@@ -29,7 +29,7 @@ EAS Simulator does not replace local Xcode or Android Studio workflows when you
## Use EAS Simulator with a coding agent
-For agentic development, start with the official EAS Simulator skill. Ask your coding agent to use the skill instead of giving it individual EAS CLI and controller commands. The skill covers the access check, build preparation, remote session, controller workflow, evidence collection, and cleanup.
+For agentic development, start with the official EAS Simulator skill. Ask your coding agent to use the skill instead of giving it individual EAS CLI and controller commands. The skill covers the access check, build preparation, session naming and limits, remote session, controller workflow, evidence collection, and cleanup.
@@ -49,25 +49,26 @@ A common setup is a [Cursor cloud agent](https://cursor.com/cloud) or another ba
An EAS Simulator workflow has four stages:
-1. **Start a session** with EAS CLI. EAS boots a remote device and the selected controller.
-2. **Install your app.** The remote device starts blank, so install a local build or an EAS Build artifact.
-3. **Drive the device.** Use [agent-device](/agents/agent-device/), [Argent](/agents/argent/), or the iOS browser preview.
-4. **Stop the session.** An unattended non-interactive session continues consuming usage until it stops.
+1. **Start a session** with EAS CLI. Give the session a descriptive name, choose a remote device and session type, and optionally set automatic duration or idle limits.
+2. **Install your app.** Pass an EAS Build, application archive URL, or Expo Go to the start command, or install a local build through the controller after the session starts. A session without an application source starts with a blank device.
+3. **Drive the device.** Use [agent-device](/agents/agent-device/), [Argent](/agents/argent/), or [Appium](https://appium.io/docs/en/) for programmatic control. Supported iOS sessions also include a web preview.
+4. **Stop the session.** Stop it explicitly when you finish. An unattended non-interactive session continues consuming usage until it stops or reaches a configured limit.
EAS CLI manages the session lifecycle and connection configuration. The controller provides device actions such as opening an app, pressing a button, entering text, inspecting the accessibility tree, and capturing a screenshot.
## Available surfaces
-| Capability | Availability |
-| ----------------------------------------------- | ------------ |
-| Remote iOS Simulator | |
-| Remote Android Emulator | |
-| Programmatic control with agent-device | |
-| Programmatic control with Argent | |
-| Live browser preview for iOS | |
-| Live browser preview for Android | |
-| Physical device access | |
-| Fast Refresh with a development build and Metro | |
+| Capability | Availability |
+| -------------------------------------------------------------- | ------------ |
+| Remote iOS Simulator | |
+| Remote Android Emulator | |
+| Programmatic control with agent-device | |
+| Programmatic control with Argent | |
+| Programmatic control with [Appium](https://appium.io/docs/en/) | |
+| Live browser preview for iOS | |
+| Live browser preview for Android | Coming soon |
+| Physical device access | |
+| Fast Refresh with Metro using Expo Go or a development build | |
Use [EAS Build](/build/introduction/) to create installable builds, and use local device tooling for physical devices.
diff --git a/docs/pages/preview/eas-simulator/run-and-control.mdx b/docs/pages/preview/eas-simulator/run-and-control.mdx
index d76c5b7c8214ed..5dc2c3ef98d24c 100644
--- a/docs/pages/preview/eas-simulator/run-and-control.mdx
+++ b/docs/pages/preview/eas-simulator/run-and-control.mdx
@@ -1,34 +1,37 @@
---
title: Run and control an app
sidebar_title: Run and control an app
-description: Install an app on EAS Simulator and control it with agent-device, Argent, or the iOS browser preview.
+description: Install an app on EAS Simulator and control it with agent-device, Appium, Argent, or the iOS browser preview.
---
import { Terminal } from '~/ui/components/Snippet';
import { Tab, Tabs } from '~/ui/components/Tabs';
-The remote device starts blank. Install a simulator- or emulator-compatible build before opening and controlling the app.
+The remote device starts blank unless the start command receives an application source. You can ask EAS CLI to install and launch an EAS Build, an application archive, or Expo Go while the session starts. You can also start a blank device and install a local simulator- or emulator-compatible build through its controller.
-> **info** For agentic development, ask your coding agent to use the [EAS Simulator skill](https://github.com/expo/skills/blob/main/plugins/expo/skills/eas-simulator/SKILL.md). It can choose the appropriate build workflow, start the remote device, drive the app, collect evidence, and stop the session. The sections below document the same workflow for manual use.
+> **info** **Typical use case:** A coding agent starts an agent-device session, opens Expo Go or a development build connected to Metro, and iterates with Fast Refresh. You can follow the same iOS session in the web preview. Ask the agent to use the [EAS Simulator skill](https://github.com/expo/skills/blob/main/plugins/expo/skills/eas-simulator/SKILL.md), which handles the build workflow, remote session, device control, evidence collection, and cleanup. The sections below document the same workflow for manual use.
Choose the build type based on what you need:
-| Goal | Build type |
-| -------------------------------------------- | ------------------------------------------- |
-| Inspect a fixed build or capture evidence | Local release build or EAS simulator build |
-| See current source changes with Fast Refresh | Development build connected to Metro |
-| Use an existing EAS artifact | Matching iOS Simulator build or Android APK |
+| Goal | Build type |
+| --------------------------------------------- | ------------------------------------------- |
+| Inspect a fixed build or capture evidence | Local release build or EAS simulator build |
+| See current source changes with Fast Refresh | Development build connected to Metro |
+| Test a compatible project without native code | Expo Go connected to Metro |
+| Use an existing EAS artifact | Matching iOS Simulator build or Android APK |
> **warning** A release build contains the JavaScript from build time. Connecting it to Metro does not enable Fast Refresh. Use a development build for live iteration.
-## Choose a controller
+## Choose a session type
-Choose a controller before starting the session and pass its type explicitly. Use that controller's commands for the rest of the session.
+Choose how you plan to control the device before starting the session. Pass the session type explicitly. On iOS, agent-device, Appium, and Argent sessions include both the selected controller and a web preview. Use `web-preview-only` when you only need the web preview without programmatic control.
-| Controller | Start option | Documentation |
-| ------------ | --------------------- | ---------------------------------------------- |
-| agent-device | `--type agent-device` | [agent-device and Expo](/agents/agent-device/) |
-| Argent | `--type argent` | [Argent and Expo](/agents/argent/) |
+| Session type | Start option | Use case |
+| ---------------- | ------------------------- | ----------------------------------------------------------------------------------- |
+| agent-device | `--type agent-device` | [Agent-native device control and installation](/agents/agent-device/) |
+| Appium | `--type appium` | Existing Appium clients and test suites |
+| Argent | `--type argent` | [Argent device tools and Model Context Protocol (MCP) integration](/agents/argent/) |
+| Web preview only | `--type web-preview-only` | Interactive iOS stream without programmatic control |
## Find the app identifier
@@ -61,36 +64,49 @@ Build the app before starting EAS Simulator. A build can take long enough for an
-You can also find a completed simulator build:
+Record the build ID from the command output, or find a completed simulator build:
-After the build is ready, start an agent-device session and use `install-from-source`. The remote virtual machine (VM) downloads the artifact directly:
+After the build is ready, pass its ID to `simulator:start`. EAS downloads, installs, and launches the build before the session becomes ready:
.tar.gz" --platform ios',
- '$ eas simulator:exec npx agent-device@latest open com.example.app --platform ios',
+ '$ eas simulator:start --platform ios --type agent-device --build-id --name "Release build review" --non-interactive',
+ ]}
+/>
+
+You can pass an application archive URL instead of a build ID:
+
+.tar.gz" --name "Release build review" --non-interactive',
]}
/>
-Replace `com.example.app` with the app's iOS bundle identifier.
+`--build-id` and `--application-archive-url` are mutually exclusive. The iOS build must target the Simulator.
For Android, create and install the APK from the same profile:
.apk" --platform android',
- '$ eas simulator:exec npx agent-device@latest open com.example.app --platform android',
+ '$ eas simulator:start --platform android --type agent-device --build-id --name "Release build review" --non-interactive',
]}
/>
-Replace `com.example.app` with the app's Android package. Android sessions support controller-driven interactions and screenshots, but not the live browser preview.
+The Android build must produce an APK. Android sessions support controller-driven interactions and screenshots, but not the live browser preview.
+
+If the session is already running, agent-device can still download an artifact into the active remote virtual machine (VM):
+
+.tar.gz" --platform ios',
+ '$ eas simulator:exec npx agent-device@latest open com.example.app --platform ios',
+ ]}
+/>
## Install a local build with agent-device
@@ -98,6 +114,7 @@ For a local iOS simulator `.app` bundle, `install` uploads the build through the
" --name "Expo Go live preview" --non-interactive',
+ ]}
+/>
+
+EAS CLI selects the Expo Go version that matches the current project's Expo SDK. Pass `--sdk-version ` with `--expo-go` only when you need to override the detected version. The `--open-url` value must be an Expo URL that the installed app understands; do not pass the browser's `webPreviewUrl`.
+
## Use a development build for live changes
Live iteration requires a [development build](/develop/development-builds/introduction/) with `expo-dev-client`. The development build loads JavaScript from Metro instead of relying only on the bundle embedded at build time.
@@ -132,11 +162,23 @@ Create the build before starting the simulator session:
For Android, the same profile produces an installable APK when you run the build command with `--platform android`. No additional Android configuration is required.
-Start Metro with a public tunnel that the remote development client can reach:
+Start Metro with a public tunnel that the remote development client can reach. Start Metro first because EAS CLI opens the URL while preparing the session:
-Install and open the development build, then enter the public Metro URL in the development client's **Enter URL manually** flow. Keep exactly one Metro process running. After the first bundle loads, Fast Refresh sends source edits to the remote app.
+Pass the EAS Build ID and development-client URL to `simulator:start`. EAS installs and launches the build, then opens the URL before the session becomes ready:
+
+ --open-url "://expo-development-client/?url=" --name "Checkout live edits" --non-interactive',
+ ]}
+/>
+
+Replace `` with the custom scheme from the app config and URL-encode the public Metro URL when needed. Repeat `--launch-arg ` for any launch-time arguments the app requires.
+
+Keep exactly one Metro process running. After the first bundle loads, Fast Refresh sends source edits to the remote app.
+
+The start command can install a development build only from a remote EAS Build or an application archive. For a local development **.app**, start a blank agent-device session, upload it with `install`, and use agent-device to open the development-client URL.
For the complete tested development-client sequence, install the [EAS Simulator skill](https://github.com/expo/skills/blob/main/plugins/expo/skills/eas-simulator/SKILL.md).
@@ -228,7 +270,11 @@ Install the Argent CLI:
Start an Argent-backed session with:
-
+
Use these commands with a session started with `--type argent`. `simulator:exec` supplies `ARGENT_TOOLS_URL` and `ARGENT_AUTH_TOKEN` from **.env.eas-simulator**, so `argent link` is not required for commands invoked this way:
@@ -241,6 +287,19 @@ Use these commands with a session started with `--type argent`. `simulator:exec`
Argent uses its own tools and app installation commands. An Argent session does not also provision an agent-device daemon, so do not run agent-device commands against it.
+## Control the app with Appium
+
+Start an Appium-backed session when you want to connect an existing Appium client or test suite:
+
+ [args...]',
+ ]}
+/>
+
+EAS CLI writes `APPIUM_URL` and `APPIUM_CAPS` to **.env.eas-simulator**. `simulator:exec` loads those values before running the Appium client command. Appium sessions do not also provision agent-device or Argent.
+
## Inspect session activity
Sessions that use agent-device or Argent record controller activity. Show the activity recorded so far for the current session:
@@ -259,6 +318,8 @@ Session activity describes controller operations and interactions. It does not r
Supported iOS sessions return a `webPreviewUrl`. Open it in a desktop browser while the session is active.
+When handing the preview to another person, add `--max-duration-minutes ` when starting the session so it stops automatically. You can also add `--max-idle-time-minutes ` to stop after a period without session activity. Tell the viewer when the session will stop, and remember that it continues consuming usage until it stops.
+
## Stop when finished
diff --git a/docs/pages/preview/eas-simulator/troubleshooting.mdx b/docs/pages/preview/eas-simulator/troubleshooting.mdx
index fe9439f932ccb5..ea98e4e837a37a 100644
--- a/docs/pages/preview/eas-simulator/troubleshooting.mdx
+++ b/docs/pages/preview/eas-simulator/troubleshooting.mdx
@@ -16,6 +16,12 @@ The installed EAS CLI is too old. [Install or update EAS CLI](/eas/cli/), then i
The commands remain hidden because the API is experimental.
+### `simulator:start` rejects a documented flag
+
+Update to the latest version of [EAS CLI](/eas/cli/), then compare the command with the installed help:
+
+
+
## Access and project errors
### EAS Simulator is not enabled for the account
@@ -24,7 +30,7 @@ Check before starting:
-If `available` is `false`, do not retry `simulator:start`. Use a local simulator or emulator, or [join the waitlist](https://expo.dev/services/simulator) for access.
+If `available` is `false`, do not retry `simulator:start`. Use a local simulator or emulator, or [join the waitlist](https://expo.dev/services/simulators) for access.
### EAS CLI reports that a user account is required
@@ -54,6 +60,18 @@ The session is ready when the status is `IN_PROGRESS` and `remoteConfig` is pres
+### Starting a session left the previous session running
+
+`simulator:start` creates a new session by default, even when **.env.eas-simulator** contains another session ID. It replaces the local configuration, but it does not stop the previous remote session.
+
+List active sessions and stop the old one explicitly:
+
+']}
+/>
+
+Use `--no-force` when you want `simulator:start` to fail instead of creating a new session while the environment already contains an ID. Give every session a descriptive `--name` so it is easy to identify in the list.
+
## Controller and tunnel problems
### `Remote daemon is unavailable` or the tunnel endpoint is offline
@@ -99,9 +117,23 @@ Open an installed app before taking the screenshot:
## App and build problems
+### `Launch options require an application source`
+
+`--launch-arg` and `--open-url` apply to an application installed during session startup. Pass exactly one source with the command:
+
+- `--build-id ` for an EAS Build
+- `--application-archive-url ` for a remote application archive
+- `--expo-go` for the Expo Go version matching the project SDK
+
+For a local **.app** or APK, start a blank session and install it through agent-device or another controller instead.
+
+### Expo Go cannot determine the SDK version
+
+Run the command from an Expo project with a valid app config, or pass `--sdk-version ` together with `--expo-go`. The SDK override is not valid with `--build-id` or `--application-archive-url`.
+
### The remote device does not contain the app
-This is expected. Each session starts with a blank device. Install a local simulator or emulator build, or use `install-from-source` with an EAS Build artifact.
+This is expected when the session starts without `--build-id`, `--application-archive-url`, or `--expo-go`. Install a local simulator or emulator build through the controller, or start a new session with an application source.
### The screenshot shows old source code
diff --git a/docs/ui/components/EASCLIReference/data/eas-cli-commands.json b/docs/ui/components/EASCLIReference/data/eas-cli-commands.json
index ae534f0c15a588..db5ba33eac9f32 100644
--- a/docs/ui/components/EASCLIReference/data/eas-cli-commands.json
+++ b/docs/ui/components/EASCLIReference/data/eas-cli-commands.json
@@ -1,8 +1,8 @@
{
"source": {
"url": "https://raw.githubusercontent.com/expo/eas-cli/main/packages/eas-cli/README.md",
- "fetchedAt": "2026-08-27T11:24:14.976Z",
- "cliVersion": "22.6.0"
+ "fetchedAt": "2026-08-28T11:26:04.819Z",
+ "cliVersion": "23.0.0"
},
"totalCommands": 147,
"commands": [
@@ -494,7 +494,7 @@
{
"command": "eas sim",
"description": "[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it",
- "usage": "USAGE\n $ eas sim [-p android|ios] [--name ] [--device ] [--build-id |\n --application-archive-url | --expo-go] [--sdk-version ] [--launch-arg ...] [--open-url\n ] [--type agent-device|appium|argent|serve-sim] [--package-version ] [--max-duration-minutes ]\n [--max-idle-time-minutes ] [--force] [--out-config-type env|dotenv] [--json] [--non-interactive]\n\nFLAGS\n -p, --platform=