diff --git a/docs/pages/push-notifications/push-notifications-setup.mdx b/docs/pages/push-notifications/push-notifications-setup.mdx
index 9b0574f06774fc..87b0aceb1a27cc 100644
--- a/docs/pages/push-notifications/push-notifications-setup.mdx
+++ b/docs/pages/push-notifications/push-notifications-setup.mdx
@@ -36,9 +36,9 @@ If you need finer-grained control over your notifications, communicating directl
-
- Push notifications are not supported on Android Emulators or iOS Simulators. You will need a
- real device to test.
+
+ You can test push notifications on a physical Android or iOS device, on an Android Emulator with
+ Google Play services, or on an iOS Simulator running on Xcode 14 or later (macOS 13+, iOS 16+).
@@ -48,12 +48,11 @@ The following steps in this guide use [EAS Build](/build/introduction/). This is
## Install libraries
-Run the following command to install the `expo-notifications`, `expo-device` and `expo-constants` libraries:
+Run the following command to install the `expo-notifications` and `expo-constants` libraries:
-
+
- [`expo-notifications`](/versions/latest/sdk/notifications) library is used to request a user's permission and to obtain the `ExpoPushToken` for sending push notifications.
-- [`expo-device`](/versions/latest/sdk/device) is used to check whether the app is running on a physical device.
- [`expo-constants`](/versions/latest/sdk/constants) is used to get the `projectId` value from the app config.
@@ -88,7 +87,6 @@ The code below shows a working example of how to register for, send, and receive
```tsx App.tsx
import { useState, useEffect } from 'react';
import { Text, View, Button, Platform } from 'react-native';
-import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
@@ -140,37 +138,32 @@ async function registerForPushNotificationsAsync() {
});
}
- if (Device.isDevice) {
- const { status: existingStatus } = await Notifications.getPermissionsAsync();
- let finalStatus = existingStatus;
- if (existingStatus !== 'granted') {
- const { status } = await Notifications.requestPermissionsAsync();
- finalStatus = status;
- }
- if (finalStatus !== 'granted') {
- handleRegistrationError('Permission not granted to get push token for push notification!');
- return;
- }
- const projectId =
- Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
- if (!projectId) {
- handleRegistrationError('Project ID not found');
- }
- try {
- /* @info This fetches the Expo push token (if not previously fetched), which is unique to this device and projectID. */
- const pushTokenString = (
- await Notifications.getExpoPushTokenAsync({
- projectId,
- })
- ).data;
- /* @end */
- console.log(pushTokenString);
- return pushTokenString;
- } catch (e: unknown) {
- handleRegistrationError(`${e}`);
- }
- } else {
- handleRegistrationError('Must use physical device for push notifications');
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
+ let finalStatus = existingStatus;
+ if (existingStatus !== 'granted') {
+ const { status } = await Notifications.requestPermissionsAsync();
+ finalStatus = status;
+ }
+ if (finalStatus !== 'granted') {
+ handleRegistrationError('Permission not granted to get push token for push notification!');
+ return;
+ }
+ const projectId = Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
+ if (!projectId) {
+ handleRegistrationError('Project ID not found');
+ }
+ try {
+ /* @info This fetches the Expo push token (if not previously fetched), which is unique to this device and projectID. */
+ const pushTokenString = (
+ await Notifications.getExpoPushTokenAsync({
+ projectId,
+ })
+ ).data;
+ /* @end */
+ console.log(pushTokenString);
+ return pushTokenString;
+ } catch (e: unknown) {
+ handleRegistrationError(`${e}`);
}
}
diff --git a/docs/pages/versions/unversioned/sdk/notifications.mdx b/docs/pages/versions/unversioned/sdk/notifications.mdx
index bb8c959fddc508..c400138e0224cc 100644
--- a/docs/pages/versions/unversioned/sdk/notifications.mdx
+++ b/docs/pages/versions/unversioned/sdk/notifications.mdx
@@ -4,7 +4,7 @@ description: A library that provides an API to fetch push notification tokens an
sourceCodeUrl: 'https://github.com/expo/expo/tree/main/packages/expo-notifications'
packageName: 'expo-notifications'
iconUrl: '/static/images/packages/expo-notifications.png'
-platforms: ['android*', 'ios*']
+platforms: ['android', 'ios']
---
import { NotificationBoxIcon } from '@expo/styleguide-icons/outline/NotificationBoxIcon';
@@ -65,14 +65,13 @@ This issue only affects debug builds and does not occur in release builds. To wo
## Usage
-Check out the example Snack below to see Notifications in action, make sure to use a physical device to test it. Push notifications don't work on emulators/simulators.
+Check out the example Snack below to see Notifications in action. Push notifications work on physical devices, Android emulators with Google Play services, and iOS simulators on Xcode 14 or later (macOS 13+, iOS 16+).
-
+
```tsx
import { useState, useEffect } from 'react';
import { Text, View, Button, Platform } from 'react-native';
-import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
@@ -166,37 +165,33 @@ async function registerForPushNotificationsAsync() {
});
}
- if (Device.isDevice) {
- const { status: existingStatus } = await Notifications.getPermissionsAsync();
- let finalStatus = existingStatus;
- if (existingStatus !== 'granted') {
- const { status } = await Notifications.requestPermissionsAsync();
- finalStatus = status;
- }
- if (finalStatus !== 'granted') {
- alert('Failed to get push token for push notification!');
- return;
- }
- // Learn more about projectId:
- // https://docs.expo.dev/push-notifications/push-notifications-setup/#configure-projectid
- // EAS projectId is used here.
- try {
- const projectId =
- Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
- if (!projectId) {
- throw new Error('Project ID not found');
- }
- token = (
- await Notifications.getExpoPushTokenAsync({
- projectId,
- })
- ).data;
- console.log(token);
- } catch (e) {
- token = `${e}`;
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
+ let finalStatus = existingStatus;
+ if (existingStatus !== 'granted') {
+ const { status } = await Notifications.requestPermissionsAsync();
+ finalStatus = status;
+ }
+ if (finalStatus !== 'granted') {
+ alert('Failed to get push token for push notification!');
+ return;
+ }
+ // Learn more about projectId:
+ // https://docs.expo.dev/push-notifications/push-notifications-setup/#configure-projectid
+ // EAS projectId is used here.
+ try {
+ const projectId =
+ Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
+ if (!projectId) {
+ throw new Error('Project ID not found');
}
- } else {
- alert('Must use physical device for Push Notifications');
+ token = (
+ await Notifications.getExpoPushTokenAsync({
+ projectId,
+ })
+ ).data;
+ console.log(token);
+ } catch (e) {
+ token = `${e}`;
}
return token;
diff --git a/docs/pages/versions/unversioned/sdk/ui/swift-ui/datepicker.mdx b/docs/pages/versions/unversioned/sdk/ui/swift-ui/datepicker.mdx
index 59cca2a629a96b..38fc11b71aa01d 100644
--- a/docs/pages/versions/unversioned/sdk/ui/swift-ui/datepicker.mdx
+++ b/docs/pages/versions/unversioned/sdk/ui/swift-ui/datepicker.mdx
@@ -169,9 +169,12 @@ export default function GraphicalDatePickerExample() {
## Disabled picker
+You can make the picker non-interactive using the `disabled` modifier.
+
```tsx DisabledDatePickerExample.tsx
import { useState } from 'react';
import { Host, DatePicker } from '@expo/ui/swift-ui';
+import { disabled } from '@expo/ui/swift-ui/modifiers';
export default function DisabledDatePickerExample() {
const [selectedDate, setSelectedDate] = useState(new Date());
@@ -185,61 +188,7 @@ export default function DisabledDatePickerExample() {
onDateChange={date => {
setSelectedDate(date);
}}
- disabled
- />
-
- );
-}
-```
-
-## Custom locale
-
-Use the `locale` prop to display the picker in a specific locale.
-
-```tsx LocaleDatePickerExample.tsx
-import { useState } from 'react';
-import { Host, DatePicker } from '@expo/ui/swift-ui';
-
-export default function LocaleDatePickerExample() {
- const [selectedDate, setSelectedDate] = useState(new Date());
-
- return (
-
- {
- setSelectedDate(date);
- }}
- locale="fr_FR"
- />
-
- );
-}
-```
-
-## Custom time zone
-
-Use the `timeZone` prop to display the picker in a specific IANA time zone.
-
-```tsx TimeZoneDatePickerExample.tsx
-import { useState } from 'react';
-import { Host, DatePicker } from '@expo/ui/swift-ui';
-
-export default function TimeZoneDatePickerExample() {
- const [selectedDate, setSelectedDate] = useState(new Date());
-
- return (
-
- {
- setSelectedDate(date);
- }}
- timeZone="Asia/Tokyo"
+ modifiers={[disabled()]}
/>
);
@@ -248,11 +197,12 @@ export default function TimeZoneDatePickerExample() {
## Custom locale
-Use the `locale` prop to display the picker in a specific locale.
+Apply the `environment` modifier with the `locale` key to display the picker in a specific locale.
```tsx LocaleDatePickerExample.tsx
import { useState } from 'react';
import { Host, DatePicker } from '@expo/ui/swift-ui';
+import { environment } from '@expo/ui/swift-ui/modifiers';
export default function LocaleDatePickerExample() {
const [selectedDate, setSelectedDate] = useState(new Date());
@@ -266,7 +216,7 @@ export default function LocaleDatePickerExample() {
onDateChange={date => {
setSelectedDate(date);
}}
- locale="fr_FR"
+ modifiers={[environment('locale', 'fr_FR')]}
/>
);
@@ -275,11 +225,12 @@ export default function LocaleDatePickerExample() {
## Custom time zone
-Use the `timeZone` prop to display the picker in a specific IANA time zone.
+Apply the `environment` modifier with the `timeZone` key to display the picker in a specific IANA time zone.
```tsx TimeZoneDatePickerExample.tsx
import { useState } from 'react';
import { Host, DatePicker } from '@expo/ui/swift-ui';
+import { environment } from '@expo/ui/swift-ui/modifiers';
export default function TimeZoneDatePickerExample() {
const [selectedDate, setSelectedDate] = useState(new Date());
@@ -293,7 +244,7 @@ export default function TimeZoneDatePickerExample() {
onDateChange={date => {
setSelectedDate(date);
}}
- timeZone="Asia/Tokyo"
+ modifiers={[environment('timeZone', 'Asia/Tokyo')]}
/>
);
diff --git a/docs/pages/versions/v54.0.0/sdk/notifications.mdx b/docs/pages/versions/v54.0.0/sdk/notifications.mdx
index 2c24d3974234dd..576282db0c806e 100644
--- a/docs/pages/versions/v54.0.0/sdk/notifications.mdx
+++ b/docs/pages/versions/v54.0.0/sdk/notifications.mdx
@@ -4,7 +4,7 @@ description: A library that provides an API to fetch push notification tokens an
sourceCodeUrl: 'https://github.com/expo/expo/tree/sdk-54/packages/expo-notifications'
packageName: 'expo-notifications'
iconUrl: '/static/images/packages/expo-notifications.png'
-platforms: ['android*', 'ios*']
+platforms: ['android', 'ios']
---
import { NotificationBoxIcon } from '@expo/styleguide-icons/outline/NotificationBoxIcon';
@@ -65,14 +65,13 @@ This issue only affects debug builds and does not occur in release builds. To wo
## Usage
-Check out the example Snack below to see Notifications in action, make sure to use a physical device to test it. Push notifications don't work on emulators/simulators.
+Check out the example Snack below to see Notifications in action. Push notifications work on physical devices, Android emulators with Google Play services, and iOS simulators on Xcode 14 or later (macOS 13+, iOS 16+).
-
+
```tsx
import { useState, useEffect } from 'react';
import { Text, View, Button, Platform } from 'react-native';
-import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
@@ -166,37 +165,33 @@ async function registerForPushNotificationsAsync() {
});
}
- if (Device.isDevice) {
- const { status: existingStatus } = await Notifications.getPermissionsAsync();
- let finalStatus = existingStatus;
- if (existingStatus !== 'granted') {
- const { status } = await Notifications.requestPermissionsAsync();
- finalStatus = status;
- }
- if (finalStatus !== 'granted') {
- alert('Failed to get push token for push notification!');
- return;
- }
- // Learn more about projectId:
- // https://docs.expo.dev/push-notifications/push-notifications-setup/#configure-projectid
- // EAS projectId is used here.
- try {
- const projectId =
- Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
- if (!projectId) {
- throw new Error('Project ID not found');
- }
- token = (
- await Notifications.getExpoPushTokenAsync({
- projectId,
- })
- ).data;
- console.log(token);
- } catch (e) {
- token = `${e}`;
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
+ let finalStatus = existingStatus;
+ if (existingStatus !== 'granted') {
+ const { status } = await Notifications.requestPermissionsAsync();
+ finalStatus = status;
+ }
+ if (finalStatus !== 'granted') {
+ alert('Failed to get push token for push notification!');
+ return;
+ }
+ // Learn more about projectId:
+ // https://docs.expo.dev/push-notifications/push-notifications-setup/#configure-projectid
+ // EAS projectId is used here.
+ try {
+ const projectId =
+ Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
+ if (!projectId) {
+ throw new Error('Project ID not found');
}
- } else {
- alert('Must use physical device for Push Notifications');
+ token = (
+ await Notifications.getExpoPushTokenAsync({
+ projectId,
+ })
+ ).data;
+ console.log(token);
+ } catch (e) {
+ token = `${e}`;
}
return token;
diff --git a/docs/pages/versions/v55.0.0/sdk/notifications.mdx b/docs/pages/versions/v55.0.0/sdk/notifications.mdx
index bb8c959fddc508..c400138e0224cc 100644
--- a/docs/pages/versions/v55.0.0/sdk/notifications.mdx
+++ b/docs/pages/versions/v55.0.0/sdk/notifications.mdx
@@ -4,7 +4,7 @@ description: A library that provides an API to fetch push notification tokens an
sourceCodeUrl: 'https://github.com/expo/expo/tree/main/packages/expo-notifications'
packageName: 'expo-notifications'
iconUrl: '/static/images/packages/expo-notifications.png'
-platforms: ['android*', 'ios*']
+platforms: ['android', 'ios']
---
import { NotificationBoxIcon } from '@expo/styleguide-icons/outline/NotificationBoxIcon';
@@ -65,14 +65,13 @@ This issue only affects debug builds and does not occur in release builds. To wo
## Usage
-Check out the example Snack below to see Notifications in action, make sure to use a physical device to test it. Push notifications don't work on emulators/simulators.
+Check out the example Snack below to see Notifications in action. Push notifications work on physical devices, Android emulators with Google Play services, and iOS simulators on Xcode 14 or later (macOS 13+, iOS 16+).
-
+
```tsx
import { useState, useEffect } from 'react';
import { Text, View, Button, Platform } from 'react-native';
-import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
@@ -166,37 +165,33 @@ async function registerForPushNotificationsAsync() {
});
}
- if (Device.isDevice) {
- const { status: existingStatus } = await Notifications.getPermissionsAsync();
- let finalStatus = existingStatus;
- if (existingStatus !== 'granted') {
- const { status } = await Notifications.requestPermissionsAsync();
- finalStatus = status;
- }
- if (finalStatus !== 'granted') {
- alert('Failed to get push token for push notification!');
- return;
- }
- // Learn more about projectId:
- // https://docs.expo.dev/push-notifications/push-notifications-setup/#configure-projectid
- // EAS projectId is used here.
- try {
- const projectId =
- Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
- if (!projectId) {
- throw new Error('Project ID not found');
- }
- token = (
- await Notifications.getExpoPushTokenAsync({
- projectId,
- })
- ).data;
- console.log(token);
- } catch (e) {
- token = `${e}`;
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
+ let finalStatus = existingStatus;
+ if (existingStatus !== 'granted') {
+ const { status } = await Notifications.requestPermissionsAsync();
+ finalStatus = status;
+ }
+ if (finalStatus !== 'granted') {
+ alert('Failed to get push token for push notification!');
+ return;
+ }
+ // Learn more about projectId:
+ // https://docs.expo.dev/push-notifications/push-notifications-setup/#configure-projectid
+ // EAS projectId is used here.
+ try {
+ const projectId =
+ Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
+ if (!projectId) {
+ throw new Error('Project ID not found');
}
- } else {
- alert('Must use physical device for Push Notifications');
+ token = (
+ await Notifications.getExpoPushTokenAsync({
+ projectId,
+ })
+ ).data;
+ console.log(token);
+ } catch (e) {
+ token = `${e}`;
}
return token;
diff --git a/docs/pages/versions/v55.0.0/sdk/ui/swift-ui/datepicker.mdx b/docs/pages/versions/v55.0.0/sdk/ui/swift-ui/datepicker.mdx
index c890e5fd9ac5f3..226e50d5e190f0 100644
--- a/docs/pages/versions/v55.0.0/sdk/ui/swift-ui/datepicker.mdx
+++ b/docs/pages/versions/v55.0.0/sdk/ui/swift-ui/datepicker.mdx
@@ -169,9 +169,12 @@ export default function GraphicalDatePickerExample() {
## Disabled picker
+You can make the picker non-interactive using the `disabled` modifier.
+
```tsx DisabledDatePickerExample.tsx
import { useState } from 'react';
import { Host, DatePicker } from '@expo/ui/swift-ui';
+import { disabled } from '@expo/ui/swift-ui/modifiers';
export default function DisabledDatePickerExample() {
const [selectedDate, setSelectedDate] = useState(new Date());
@@ -185,61 +188,7 @@ export default function DisabledDatePickerExample() {
onDateChange={date => {
setSelectedDate(date);
}}
- disabled
- />
-
- );
-}
-```
-
-## Custom locale
-
-Use the `locale` prop to display the picker in a specific locale.
-
-```tsx LocaleDatePickerExample.tsx
-import { useState } from 'react';
-import { Host, DatePicker } from '@expo/ui/swift-ui';
-
-export default function LocaleDatePickerExample() {
- const [selectedDate, setSelectedDate] = useState(new Date());
-
- return (
-
- {
- setSelectedDate(date);
- }}
- locale="fr_FR"
- />
-
- );
-}
-```
-
-## Custom time zone
-
-Use the `timeZone` prop to display the picker in a specific IANA time zone.
-
-```tsx TimeZoneDatePickerExample.tsx
-import { useState } from 'react';
-import { Host, DatePicker } from '@expo/ui/swift-ui';
-
-export default function TimeZoneDatePickerExample() {
- const [selectedDate, setSelectedDate] = useState(new Date());
-
- return (
-
- {
- setSelectedDate(date);
- }}
- timeZone="Asia/Tokyo"
+ modifiers={[disabled()]}
/>
);
@@ -248,11 +197,12 @@ export default function TimeZoneDatePickerExample() {
## Custom locale
-Use the `locale` prop to display the picker in a specific locale.
+Apply the `environment` modifier with the `locale` key to display the picker in a specific locale.
```tsx LocaleDatePickerExample.tsx
import { useState } from 'react';
import { Host, DatePicker } from '@expo/ui/swift-ui';
+import { environment } from '@expo/ui/swift-ui/modifiers';
export default function LocaleDatePickerExample() {
const [selectedDate, setSelectedDate] = useState(new Date());
@@ -266,7 +216,7 @@ export default function LocaleDatePickerExample() {
onDateChange={date => {
setSelectedDate(date);
}}
- locale="fr_FR"
+ modifiers={[environment('locale', 'fr_FR')]}
/>
);
@@ -275,11 +225,12 @@ export default function LocaleDatePickerExample() {
## Custom time zone
-Use the `timeZone` prop to display the picker in a specific IANA time zone.
+Apply the `environment` modifier with the `timeZone` key to display the picker in a specific IANA time zone.
```tsx TimeZoneDatePickerExample.tsx
import { useState } from 'react';
import { Host, DatePicker } from '@expo/ui/swift-ui';
+import { environment } from '@expo/ui/swift-ui/modifiers';
export default function TimeZoneDatePickerExample() {
const [selectedDate, setSelectedDate] = useState(new Date());
@@ -293,7 +244,7 @@ export default function TimeZoneDatePickerExample() {
onDateChange={date => {
setSelectedDate(date);
}}
- timeZone="Asia/Tokyo"
+ modifiers={[environment('timeZone', 'Asia/Tokyo')]}
/>
);
diff --git a/packages/expo-doctor/CHANGELOG.md b/packages/expo-doctor/CHANGELOG.md
index 24e792ae157e78..ff73204f961da8 100644
--- a/packages/expo-doctor/CHANGELOG.md
+++ b/packages/expo-doctor/CHANGELOG.md
@@ -9,6 +9,7 @@
- Add version to the `--verbose` output ([#44592](https://github.com/expo/expo/pull/44592) by [@kitten](https://github.com/kitten))
- Add check that warns about invalid `overrides`/`resolutions` for critical package versions ([#44770](https://github.com/expo/expo/pull/44770) by [@kitten](https://github.com/kitten))
- add a warning when mixing `@expo/vector-icons` and `react-native-vector-icons` or packages from `@react-native-vector-icons` ([#37958](https://github.com/expo/expo/pull/37958) by [@vonovak](https://github.com/vonovak))
+- Add check for both expo-router and react-navigation installed in same project ([#45323](https://github.com/expo/expo/pull/45323) by [@Ubax](https://github.com/Ubax))
### 🐛 Bug fixes
diff --git a/packages/expo-doctor/src/checks/ExpoRouterReactNavigationCheck.ts b/packages/expo-doctor/src/checks/ExpoRouterReactNavigationCheck.ts
new file mode 100644
index 00000000000000..76da1963f2b08f
--- /dev/null
+++ b/packages/expo-doctor/src/checks/ExpoRouterReactNavigationCheck.ts
@@ -0,0 +1,40 @@
+import type { DoctorCheck, DoctorCheckParams, DoctorCheckResult } from './checks.types';
+
+export class ExpoRouterReactNavigationCheck implements DoctorCheck {
+ description = 'Check that @react-navigation packages are not installed alongside expo-router';
+
+ sdkVersionRange = '>=56.0.0 <57.0.0';
+
+ async runAsync({ pkg }: DoctorCheckParams): Promise {
+ const hasExpoRouter = !!(
+ pkg.dependencies?.['expo-router'] || pkg.devDependencies?.['expo-router']
+ );
+ if (!hasExpoRouter) {
+ return { isSuccessful: true, issues: [], advice: [] };
+ }
+
+ const reactNavigationDeps = [
+ ...Object.keys(pkg.dependencies ?? {}),
+ ...Object.keys(pkg.devDependencies ?? {}),
+ ]
+ .filter((name) => name.startsWith('@react-navigation/'))
+ .filter((name, index, all) => all.indexOf(name) === index)
+ .sort();
+
+ if (reactNavigationDeps.length === 0) {
+ return { isSuccessful: true, issues: [], advice: [] };
+ }
+
+ const list = reactNavigationDeps.map((n) => `"${n}"`).join(', ');
+
+ return {
+ isSuccessful: false,
+ issues: [
+ `As of SDK 56, expo-router is no longer compatible with react-navigation. The following @react-navigation packages are installed as direct dependencies and should be removed: ${list}.`,
+ ],
+ advice: [
+ `Remove these packages from your package.json and replace any direct \`@react-navigation/*\` imports in your code with their expo-router equivalents. See https://docs.expo.dev/router/migrate/sdk-55-to-56/ for more information.`,
+ ],
+ };
+ }
+}
diff --git a/packages/expo-doctor/src/checks/__tests__/ExpoRouterReactNavigationCheck.test.ts b/packages/expo-doctor/src/checks/__tests__/ExpoRouterReactNavigationCheck.test.ts
new file mode 100644
index 00000000000000..19cd6a28407c1b
--- /dev/null
+++ b/packages/expo-doctor/src/checks/__tests__/ExpoRouterReactNavigationCheck.test.ts
@@ -0,0 +1,161 @@
+import { ExpoRouterReactNavigationCheck } from '../ExpoRouterReactNavigationCheck';
+
+const additionalProjectProps = {
+ exp: {
+ name: 'name',
+ slug: 'slug',
+ sdkVersion: '56.0.0',
+ },
+ projectRoot: '/tmp/project',
+ hasUnusedStaticConfig: false,
+ staticConfigPath: null,
+ dynamicConfigPath: null,
+};
+
+describe('runAsync', () => {
+ it('returns isSuccessful = true if expo-router is not installed', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ dependencies: { '@react-navigation/native': '^7.0.0' },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeTruthy();
+ expect(result.issues).toEqual([]);
+ });
+
+ it('returns isSuccessful = true if expo-router is installed but no @react-navigation/* package is', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ dependencies: { 'expo-router': '^6.0.0' },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeTruthy();
+ expect(result.issues).toEqual([]);
+ });
+
+ it('returns isSuccessful = false if expo-router and @react-navigation/native are both in dependencies', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ dependencies: {
+ 'expo-router': '^6.0.0',
+ '@react-navigation/native': '^7.0.0',
+ },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeFalsy();
+ expect(result.issues).toHaveLength(1);
+ expect(result.issues[0]).toContain('@react-navigation/native');
+ expect(result.advice).toHaveLength(1);
+ });
+
+ it('returns isSuccessful = false if @react-navigation/* is in devDependencies', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ dependencies: { 'expo-router': '^6.0.0' },
+ devDependencies: { '@react-navigation/stack': '^7.0.0' },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeFalsy();
+ expect(result.issues[0]).toContain('@react-navigation/stack');
+ });
+
+ it('returns isSuccessful = false if expo-router is in devDependencies', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ devDependencies: {
+ 'expo-router': '^6.0.0',
+ '@react-navigation/native': '^7.0.0',
+ },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeFalsy();
+ expect(result.issues[0]).toContain('@react-navigation/native');
+ });
+
+ it('lists every offending @react-navigation/* package in the message, sorted', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ dependencies: {
+ 'expo-router': '^6.0.0',
+ '@react-navigation/stack': '^7.0.0',
+ '@react-navigation/native': '^7.0.0',
+ '@react-navigation/bottom-tabs': '^7.0.0',
+ },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeFalsy();
+ const message = result.issues[0];
+ const nativeIdx = message.indexOf('@react-navigation/native');
+ const stackIdx = message.indexOf('@react-navigation/stack');
+ const tabsIdx = message.indexOf('@react-navigation/bottom-tabs');
+ expect(tabsIdx).toBeGreaterThan(-1);
+ expect(nativeIdx).toBeGreaterThan(tabsIdx);
+ expect(stackIdx).toBeGreaterThan(nativeIdx);
+ });
+
+ it('ignores peerDependencies (the check targets app projects, not libraries)', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ peerDependencies: {
+ 'expo-router': '^6.0.0',
+ '@react-navigation/native': '^7.0.0',
+ },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeTruthy();
+ });
+
+ it('handles same package in both dependencies and devDependencies without duplicating it in the message', async () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ const result = await check.runAsync({
+ pkg: {
+ name: 'name',
+ version: '1.0.0',
+ dependencies: {
+ 'expo-router': '^6.0.0',
+ '@react-navigation/native': '^7.0.0',
+ },
+ devDependencies: {
+ '@react-navigation/native': '^7.0.0',
+ },
+ },
+ ...additionalProjectProps,
+ });
+ expect(result.isSuccessful).toBeFalsy();
+ const occurrences = result.issues[0].match(/@react-navigation\/native/g) ?? [];
+ expect(occurrences).toHaveLength(1);
+ });
+
+ it('has sdkVersionRange = >=56.0.0 <57.0.0', () => {
+ const check = new ExpoRouterReactNavigationCheck();
+ expect(check.sdkVersionRange).toBe('>=56.0.0 <57.0.0');
+ });
+});
diff --git a/packages/expo-doctor/src/utils/checkResolver.ts b/packages/expo-doctor/src/utils/checkResolver.ts
index f9afddfbc12c6b..e82cc207ff190c 100644
--- a/packages/expo-doctor/src/utils/checkResolver.ts
+++ b/packages/expo-doctor/src/utils/checkResolver.ts
@@ -13,6 +13,7 @@ import { DependencyVersionOverrideCheck } from '../checks/DependencyVersionOverr
import { DirectPackageInstallCheck } from '../checks/DirectPackageInstallCheck';
import { ExpoConfigCommonIssueCheck } from '../checks/ExpoConfigCommonIssueCheck';
import { ExpoConfigSchemaCheck } from '../checks/ExpoConfigSchemaCheck';
+import { ExpoRouterReactNavigationCheck } from '../checks/ExpoRouterReactNavigationCheck';
import { GlobalPackageInstalledLocallyCheck } from '../checks/GlobalPackageInstalledLocallyCheck';
import { IllegalPackageCheck } from '../checks/IllegalPackageCheck';
import { InstalledDependencyVersionCheck } from '../checks/InstalledDependencyVersionCheck';
@@ -52,6 +53,7 @@ export function resolveChecksInScope(exp: ExpoConfig, pkg: PackageJSONConfig): D
new GlobalPackageInstalledLocallyCheck(),
new DirectPackageInstallCheck(),
new PeerDependencyChecks(),
+ new ExpoRouterReactNavigationCheck(),
new AutolinkingDependencyDuplicatesCheck(),
new VectorIconsCheck(),