From b2782502b7cb47305452e9c9d8e78e0f84db57f0 Mon Sep 17 00:00:00 2001 From: Alex Rapp Date: Wed, 19 Aug 2026 23:36:15 -0700 Subject: [PATCH 1/8] [docs] Clarify server-verified biometric sign-in (#49131) # Why The authentication guide currently describes biometric prompts as a local gate. That is accurate for a biometric prompt by itself, but an authentication provider can pair the prompt with a device-bound credential to authenticate with a server and establish a new session. Clerk recently added this trusted-device flow for Expo, iOS, and Android. Documenting the distinction helps readers understand the difference between local biometric checks, server-verified trusted-device sign-in, and passkeys. Related documentation: - https://clerk.com/changelog/2026-08-17-biometric-sign-in-mobile-sdks - https://clerk.com/docs/expo/guides/development/custom-flows/authentication/biometric-sign-in # How - Clarify that a biometric prompt alone does not authenticate with a server. - Explain how an authentication provider can combine the prompt with a device-bound credential to create a session. - Use Clerk biometric sign-in as a concrete example. - Distinguish app-installation-scoped trusted-device credentials from WebAuthn passkeys. # Test Plan - Ran `npx -y oxfmt@0.47.0 --check docs/pages/develop/authentication.mdx`. - Ran `git diff --check`. - Confirmed the Clerk biometric sign-in and W3C WebAuthn links return HTTP 200. # 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). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/pages/develop/authentication.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/pages/develop/authentication.mdx b/docs/pages/develop/authentication.mdx index ad17fc8e62d6e3..bbb49cf34789e9 100644 --- a/docs/pages/develop/authentication.mdx +++ b/docs/pages/develop/authentication.mdx @@ -268,10 +268,12 @@ Once you have a working authentication system in place, you can improve the user -Biometrics like Face ID and Touch ID can be used to unlock the app or confirm identity after a valid session is established. These are not authentication methods on their own, but act as a local gate that makes re-authentication faster and more secure. +Biometrics like Face ID and Touch ID can unlock an app or confirm identity after a valid session is established. A biometric prompt alone does not authenticate with your server. It acts as a local gate that protects access to content or credentials on the device. React Native provides access to biometric APIs through libraries like [`expo-local-authentication`](/versions/latest/sdk/local-authentication) or [`react-native-biometrics`](https://github.com/SelfLender/react-native-biometrics). +An authentication provider can combine the system biometric prompt with a device-bound credential to authenticate with its server and create a new session. For example, [Clerk biometric sign-in](https://clerk.com/docs/expo/guides/development/custom-flows/authentication/biometric-sign-in) enrolls an app installation as a trusted device. After the user approves the biometric prompt, the device signs a one-time challenge while the private key remains on the device. Unlike a passkey, this credential is scoped to the app installation and does not use [WebAuthn](https://www.w3.org/TR/webauthn-2/). + From 367fc8476a9694ba08c8f5ba850247b5c6b9dcd9 Mon Sep 17 00:00:00 2001 From: Alexis CHAPPRON Date: Thu, 20 Aug 2026 10:02:32 +0200 Subject: [PATCH 2/8] [android][ios][file-system] Fix File.size returning null (#49086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why File.size is typed as number, but native Android and iOS bridges returned null when the file was missing or unreadable. For instance here on android (https://github.com/expo/expo/blob/main/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemFile.kt#L205-L211). But TS type it as `number` https://github.com/ACHP/expo/blob/5ff5560a425024bfc2f7bba3c67e83498cca1eaf/packages/expo-file-system/src/internal/NativeFileSystem.types.ts#L323 image # How Return 0 from both native bridges for those cases. File implements Blob, whose size must be a number, so number | null is not possible. # Test Plan Ran pnpm exec et check-packages expo-file-system. # Checklist - [x] I added a changelog.md entry and rebuilt the package sources according to this short guide (https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for npx expo prebuild & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: BartΕ‚omiej Klocek --- apps/test-suite/tests/FileSystem.ts | 6 ++---- packages/expo-file-system/CHANGELOG.md | 1 + .../main/java/expo/modules/filesystem/FileSystemModule.kt | 4 ++-- packages/expo-file-system/ios/FileSystemModule.swift | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/test-suite/tests/FileSystem.ts b/apps/test-suite/tests/FileSystem.ts index 86e3cf9c0fbb7a..d6f6c49afc89de 100644 --- a/apps/test-suite/tests/FileSystem.ts +++ b/apps/test-suite/tests/FileSystem.ts @@ -1875,11 +1875,9 @@ export async function test({ describe, expect, it, ...t }: JasmineInterface) { expect(error).not.toBeNull(); }); - it('returns null size and md5 for nonexistent files', async () => { + it('returns zero size and null md5 for nonexistent files', async () => { const file = new File(testDirectory, 'file2.txt'); - // @ts-expect-error `size` is typed `number`, but a nonexistent file reports - // `null`, which is what this spec checks. - expect(file.size).toBe(null); + expect(file.size).toBe(0); expect(file.md5).toBe(null); }); }); diff --git a/packages/expo-file-system/CHANGELOG.md b/packages/expo-file-system/CHANGELOG.md index 031572ef00d9ca..0c7a4f27309555 100644 --- a/packages/expo-file-system/CHANGELOG.md +++ b/packages/expo-file-system/CHANGELOG.md @@ -14,6 +14,7 @@ ### πŸ› Bug fixes +- [Android][iOS] Fix `File.size` returning `null` for a missing or unreadable file. ([#49086](https://github.com/expo/expo/pull/49086)) by [@ACHP](https://github.com/ACHP)) - [iOS] Fix wrong permissions for text() and bytes(). ([#42422](https://github.com/expo/expo/pull/42422)) by [@simoneldevig](https://github.com/simoneldevig)) - Fixed `copyAsync` on iOS copying the unedited original when a `ph://` asset has edits applied in Photos. ([#48248](https://github.com/expo/expo/pull/48248) by [@CoffeeFlux](https://github.com/CoffeeFlux)) - Fixed iOS file previews rejecting a new preview while the previous Quick Look dismissal animation is still finishing. ([#47947](https://github.com/expo/expo/pull/47947) by [@eliotgevers](https://github.com/eliotgevers)) diff --git a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemModule.kt b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemModule.kt index 4e3285e1c0a61c..9489d288efcc8b 100644 --- a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemModule.kt +++ b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemModule.kt @@ -293,9 +293,9 @@ class FileSystemModule : Module() { Property("size") { file -> try { - file.size + file.size ?: 0 } catch (e: Exception) { - null + 0 } } diff --git a/packages/expo-file-system/ios/FileSystemModule.swift b/packages/expo-file-system/ios/FileSystemModule.swift index 0e461f375a54f4..e9338f134839c7 100644 --- a/packages/expo-file-system/ios/FileSystemModule.swift +++ b/packages/expo-file-system/ios/FileSystemModule.swift @@ -336,7 +336,7 @@ public final class FileSystemModule: Module { } Property("size") { file in - try? file.size + (try? file.size) ?? 0 } Property("md5") { file in From 900658c254cc5050e8a19354f0313a88bde9ff3d Mon Sep 17 00:00:00 2001 From: Expo Bot <34669131+expo-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:21:30 -0700 Subject: [PATCH 3/8] [docs] Sync upstream reference content (Expo Skills) (#49144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated upstream sync of generated reference content. | Generator | Status | | --- | --- | | App config schema | βž– No changes | | Expo Skills | βœ… Synced | | EAS CLI reference | βž– No changes | | Android permissions | βž– No changes | ## Files - `docs/ui/components/ExpoSkillsTable/data/expo-skills.json` Co-authored-by: Expo Bot --- .../components/ExpoSkillsTable/data/expo-skills.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/ui/components/ExpoSkillsTable/data/expo-skills.json b/docs/ui/components/ExpoSkillsTable/data/expo-skills.json index 77ee1173b1a0ba..76c36b4b23c28e 100644 --- a/docs/ui/components/ExpoSkillsTable/data/expo-skills.json +++ b/docs/ui/components/ExpoSkillsTable/data/expo-skills.json @@ -2,9 +2,9 @@ "source": { "repo": "expo/skills", "url": "https://api.github.com/repos/expo/skills/contents/plugins/expo/skills", - "fetchedAt": "2026-08-19T08:11:32.877Z" + "fetchedAt": "2026-08-20T08:13:05.828Z" }, - "totalSkills": 23, + "totalSkills": 24, "skills": [ { "name": "eas-app-stores", @@ -102,6 +102,12 @@ "description": "Build beautiful, native-feeling Expo screens. Covers Apple HIG styling, semantic colors, native controls, SF Symbols, media, animations, visual effects, gradients, storage, and responsive layout. For routing and navigation, use the expo-router skill.", "githubUrl": "https://github.com/expo/skills/blob/main/plugins/expo/skills/expo-native-ui/SKILL.md" }, + { + "name": "expo-overview", + "category": "framework", + "description": "Entry point and router for every Expo or EAS task. Load this skill first β€” before writing code and before choosing another expo-* / eas-* skill β€” when the request, PRD, or spec mentions Expo, EAS, Expo Go, or an expo-* package, or the project has an `expo` dependency in `package.json`. Within that gate it also covers app specs and designs to implement (tabs, stacks, maps, lists, navigation, building from a screenshot), and phrasings like 'implement a mobile app', 'make my app look native', 'add navigation', 'fetch some data', 'upgrade my SDK', 'add Expo to my existing native app', 'ship to the App Store', or 'I'm new to Expo, where do I start'. A fully specified request (SDK pinned, libraries named, layout given) still routes through here β€” the shared setup rules still apply. Do NOT load it when neither signal is present: a bare React Native project with no `expo` dependency is not Expo work. Detects the real goal, routes to the right expo-* / eas-* skill, and owns the shared setup rules.", + "githubUrl": "https://github.com/expo/skills/blob/main/plugins/expo/skills/expo-overview/SKILL.md" + }, { "name": "expo-project-structure", "category": "framework", @@ -129,7 +135,7 @@ { "name": "expo-ui", "category": "framework", - "description": "Build native UI with the @expo/ui package: real SwiftUI on iOS and Jetpack Compose on Android rendered from React in an Expo or React Native app. Covers universal cross-platform components (Host, Column, Row, Button, Text, List, and more imported from @expo/ui), drop-in replacements for popular React Native community libraries (BottomSheet, DateTimePicker, Slider, Menu, etc.), and platform-specific SwiftUI (@expo/ui/swift-ui, iOS only) and Jetpack Compose (@expo/ui/jetpack-compose, Android only) trees and modifiers. Use when adding or reviewing @expo/ui Host/RNHostView trees, building native-feeling UI where standard React Native components fall short (grouped settings forms with toggles, sections, menus, sheets, pickers, sliders), choosing between universal and platform-specific components, or replacing an RN community UI library with a native @expo/ui equivalent. Not for custom native modules, Expo Router navigation, Reanimated, or data fetching.", + "description": "Build native UI with the @expo/ui package: real SwiftUI on iOS and Jetpack Compose on Android. Default to @expo/ui for sheets (BottomSheet), pickers, sliders, toggles, menus, and grouped-form sections β€” do NOT reach for Reanimated, @gorhom/bottom-sheet, or RN built-in Picker/Switch; use @expo/ui instead. Fall back to RN built-ins only when @expo/ui is missing the component. NOTE: @expo/ui List renders native grouped rows like an iOS Settings screen β€” it is NOT a virtualized list; use FlatList/FlashList for large datasets. Covers universal components (Host, Column, Row, Button, Text, List, BottomSheet, FieldGroup, Switch, Slider, Picker, Menu), drop-in replacements for RN community libraries, and platform-specific SwiftUI/Jetpack Compose trees. Not for Expo Router navigation, Reanimated, or data fetching.", "githubUrl": "https://github.com/expo/skills/blob/main/plugins/expo/skills/expo-ui/SKILL.md" }, { From 0ff3f0c40e6aac7417b2a4b0126f3f80ceb62ada Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:38:18 +0200 Subject: [PATCH 4/8] [router] Remove deprecated NAVIGATION_DEPRECATED action (#49102) # Why The `NAVIGATION_DEPRECATED` action was deprecated and we can remove it. # How 1. Remove the action 2. Remove `DeprecatedNavigationInChildContext` 3. Remove all the usages # Test Plan CI # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com> --- packages/expo-router/CHANGELOG.md | 1 + .../src/link/preview/HrefPreview.tsx | 1 - .../core/BaseNavigationContainer.tsx | 10 +- .../DeprecatedNavigationInChildContext.tsx | 7 - .../__tests__/__fixtures__/MockRouter.tsx | 5 +- .../core/__tests__/useOnAction.test.ios.tsx | 140 +------- .../src/react-navigation/core/types.tsx | 43 --- .../src/react-navigation/core/useOnAction.tsx | 12 +- .../react-navigation/routers/BaseRouter.tsx | 4 +- .../routers/CommonActions.tsx | 36 -- .../react-navigation/routers/DrawerRouter.tsx | 3 +- .../react-navigation/routers/StackRouter.tsx | 58 ---- .../react-navigation/routers/TabRouter.tsx | 9 +- .../routers/__tests__/CommonActions.test.tsx | 4 + .../routers/__tests__/StackRouter.test.tsx | 321 ------------------ .../routers/__tests__/TabRouter.test.tsx | 1 - 16 files changed, 16 insertions(+), 639 deletions(-) delete mode 100644 packages/expo-router/src/react-navigation/core/DeprecatedNavigationInChildContext.tsx diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index a83ac32dcc9c2a..188ad31c558161 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -4,6 +4,7 @@ ### πŸ›  Breaking changes +- Remove the deprecated `navigateDeprecated` action and the `navigationInChildEnabled` container prop from `expo-router/react-navigation`. ([#49102](https://github.com/expo/expo/pull/49102) by [@Ubax](https://github.com/Ubax)) - Remove `getInitialState` from the `Router` interface. Custom routers no longer create initial state; the navigator creates it and passes it to `getRehydratedState`. ([#48783](https://github.com/expo/expo/pull/48783) by [@Ubax](https://github.com/Ubax)) - Remove `routeParamList` from `RouterConfigOptions` and remove the `RouterActionOptions` type. Custom routers receive `RouterConfigOptions` in both `getRehydratedState` and `getStateForAction`. ([#48783](https://github.com/expo/expo/pull/48783) by [@Ubax](https://github.com/Ubax)) - Remove the deprecated `NavigationContainer` export ([#48760](https://github.com/expo/expo/pull/48760) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/link/preview/HrefPreview.tsx b/packages/expo-router/src/link/preview/HrefPreview.tsx index 27ccd94cd1bb2d..b78325d6af4dd3 100644 --- a/packages/expo-router/src/link/preview/HrefPreview.tsx +++ b/packages/expo-router/src/link/preview/HrefPreview.tsx @@ -177,7 +177,6 @@ const navigationPropWithWarnings: NavigationProp = { push: createNOOPWithWarning('push'), pop: createNOOPWithWarning('pop'), popToTop: createNOOPWithWarning('popToTop'), - navigateDeprecated: createNOOPWithWarning('navigateDeprecated'), preload: createNOOPWithWarning('preload'), getId: () => { displayWarningForProp('getId'); diff --git a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx index 5611aad8f40054..d1dfcccb51764b 100644 --- a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx +++ b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx @@ -13,7 +13,6 @@ import { type PartialState, type Route, } from '../routers'; -import { DeprecatedNavigationInChildContext } from './DeprecatedNavigationInChildContext'; import { EnsureSingleNavigator } from './EnsureSingleNavigator'; import { NavigationBuilderContext } from './NavigationBuilderContext'; import { NavigationContainerRefContext } from './NavigationContainerRefContext'; @@ -89,7 +88,6 @@ export function BaseNavigationContainer({ onStateChange, onReady, onUnhandledAction, - navigationInChildEnabled = false, theme, children, }: NavigationContainerProps & { ref?: React.Ref> }) { @@ -414,11 +412,9 @@ export function BaseNavigationContainer({ - - - {children} - - + + {children} + diff --git a/packages/expo-router/src/react-navigation/core/DeprecatedNavigationInChildContext.tsx b/packages/expo-router/src/react-navigation/core/DeprecatedNavigationInChildContext.tsx deleted file mode 100644 index 7fcb55a2b25049..00000000000000 --- a/packages/expo-router/src/react-navigation/core/DeprecatedNavigationInChildContext.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; -import * as React from 'react'; - -/** - * Context which enables deprecated bubbling to child navigators. - */ -export const DeprecatedNavigationInChildContext = React.createContext(false); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/MockRouter.tsx b/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/MockRouter.tsx index 70196694c80d54..d85c4e4f3562b6 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/MockRouter.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/MockRouter.tsx @@ -110,8 +110,7 @@ export function MockRouter(_options: DefaultRouterOptions) { case 'NOOP': return state; - case 'NAVIGATE': - case 'NAVIGATE_DEPRECATED': { + case 'NAVIGATE': { if (!state.routeNames.includes(action.payload.name)) { return null; } @@ -171,7 +170,7 @@ export function MockRouter(_options: DefaultRouterOptions) { }, shouldActionChangeFocus(action: CommonNavigationAction) { - return action.type === 'NAVIGATE' || action.type === 'NAVIGATE_DEPRECATED'; + return action.type === 'NAVIGATE'; }, }; diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useOnAction.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useOnAction.test.ios.tsx index 9d4ff889aa30b0..c6f5054895e8b9 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useOnAction.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useOnAction.test.ios.tsx @@ -156,138 +156,7 @@ test('handles an unsupported targeted action as a no-op without bubbling', () => expect(onUnhandledAction).not.toHaveBeenCalled(); }); -test("lets children handle the action if parent didn't with navigationInChildEnabled", () => { - const CurrentParentRouter = MockRouter; - - function CurrentChildRouter(options: DefaultRouterOptions) { - const CurrentMockRouter = MockRouter(options); - const ChildRouter: Router = { - ...CurrentMockRouter, - - shouldActionChangeFocus() { - return true; - }, - - getStateForAction(state, action, options) { - if (action.type === 'REVERSE') { - return { - ...state, - routes: state.routes.slice().reverse(), - }; - } - return CurrentMockRouter.getStateForAction(state, action, options); - }, - }; - return ChildRouter; - } - - const ChildNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder( - CurrentChildRouter, - props - ); - - return ( - {descriptors[state.routes[state.index]!.key]!.render()} - ); - }; - - const ParentNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder( - CurrentParentRouter, - props - ); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - - const TestScreen = (props: any) => { - React.useEffect(() => { - props.navigation.dispatch({ type: 'REVERSE' }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return null; - }; - - const onStateChange = jest.fn(); - - const initialState = { - index: 1, - routes: [ - { - key: 'baz', - name: 'baz', - state: { - index: 0, - key: '4', - routeNames: ['qux', 'lex'], - routes: [ - { key: 'qux', name: 'qux' }, - { key: 'lex', name: 'lex' }, - ], - }, - }, - { key: 'bar', name: 'bar' }, - ], - }; - - const element = ( - - - {() => null} - - - {() => ( - - {() => null} - {() => null} - - )} - - - - ); - - render(element).update(element); - - expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onStateChange).toHaveBeenLastCalledWith({ - stale: false, - type: 'test', - index: 0, - key: '0', - routeNames: ['foo', 'bar', 'baz'], - routes: [ - { - key: 'baz', - name: 'baz', - state: { - stale: false, - type: 'test', - index: 0, - key: '1', - routeNames: ['qux', 'lex'], - routes: [ - { key: 'lex', name: 'lex' }, - { key: 'qux', name: 'qux' }, - ], - }, - }, - { key: 'bar', name: 'bar' }, - ], - }); -}); - -test("lets children handle the action if parent didn't with NAVIGATE_DEPRECATED", () => { +test("doesn't let a child handle an untargeted navigate action", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); @@ -346,13 +215,6 @@ test("lets children handle the action if parent didn't with NAVIGATE_DEPRECATED" ); expect(navigation.getCurrentRoute()?.name).toBe('foo'); - - act(() => navigation.navigateDeprecated('lex')); - - expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onUnhandledAction).toHaveBeenCalledTimes(1); - - expect(navigation.getCurrentRoute()?.name).toBe('lex'); }); test('action goes to correct parent navigator if target is specified', () => { diff --git a/packages/expo-router/src/react-navigation/core/types.tsx b/packages/expo-router/src/react-navigation/core/types.tsx index 9e48903bd79d27..ea0f0e46a47bf8 100644 --- a/packages/expo-router/src/react-navigation/core/types.tsx +++ b/packages/expo-router/src/react-navigation/core/types.tsx @@ -277,39 +277,6 @@ type NavigationHelpersCommon< : never ): void; - /** - * Navigate to a route in current navigation tree. - * - * @deprecated Use `navigate` instead. - * - * @param screen Name of the route to navigate to. - * @param [params] Params object for the route. - */ - navigateDeprecated( - ...args: RouteName extends unknown - ? undefined extends ParamList[RouteName] - ? [screen: RouteName, params?: ParamList[RouteName]] - : [screen: RouteName, params: ParamList[RouteName]] - : never - ): void; - - /** - * Navigate to a route in current navigation tree. - * - * @deprecated Use `navigate` instead. - * - * @param options Object with `name` for the route to navigate to, and a `params` object. - */ - navigateDeprecated( - options: RouteName extends unknown - ? { - name: RouteName; - params: ParamList[RouteName]; - merge?: boolean; - } - : never - ): void; - /** * Preloads the route in current navigation tree. * @@ -422,16 +389,6 @@ export type NavigationContainerProps = { * Callback which is called when an action is not handled. */ onUnhandledAction?: (action: Readonly) => void; - /** - * Whether child navigator should handle a navigation action. - * The child navigator needs to be mounted before it can handle the action. - * Defaults to `false`. - * - * This will be removed in the next major release. - * - * @deprecated Use nested navigation API instead - */ - navigationInChildEnabled?: boolean; /** * Theme object for the UI elements. */ diff --git a/packages/expo-router/src/react-navigation/core/useOnAction.tsx b/packages/expo-router/src/react-navigation/core/useOnAction.tsx index cb5b8b4a14bbce..9aec9412b354ad 100644 --- a/packages/expo-router/src/react-navigation/core/useOnAction.tsx +++ b/packages/expo-router/src/react-navigation/core/useOnAction.tsx @@ -9,7 +9,6 @@ import type { Router, RouterConfigOptions, } from '../routers'; -import { DeprecatedNavigationInChildContext } from './DeprecatedNavigationInChildContext'; import { type ChildActionListener, type ChildBeforeRemoveListener, @@ -66,8 +65,6 @@ export function useOnAction({ addListener: addListenerParent, onDispatchAction, } = use(NavigationBuilderContext); - const navigationInChildEnabled = use(DeprecatedNavigationInChildContext); - const routerConfigOptionsRef = React.useRef(routerConfigOptions); React.useEffect(() => { @@ -143,14 +140,8 @@ export function useOnAction({ } } - if ( - typeof action.target === 'string' || - // For backward compatibility - action.type === 'NAVIGATE_DEPRECATED' || - navigationInChildEnabled - ) { + if (typeof action.target === 'string') { // If the action wasn't handled by current navigator or a parent navigator, let children handle it - // Handling this when target isn't specified is deprecated and will be removed in the future for (let i = actionListeners.length - 1; i >= 0; i--) { const listener = actionListeners[i]!; if (listener(action, visitedNavigators)) { @@ -167,7 +158,6 @@ export function useOnAction({ emitter, getState, isRoutePrevented, - navigationInChildEnabled, key, onActionParent, onDispatchAction, diff --git a/packages/expo-router/src/react-navigation/routers/BaseRouter.tsx b/packages/expo-router/src/react-navigation/routers/BaseRouter.tsx index 5b792ba1544064..e30eaf14b5deaa 100644 --- a/packages/expo-router/src/react-navigation/routers/BaseRouter.tsx +++ b/packages/expo-router/src/react-navigation/routers/BaseRouter.tsx @@ -102,8 +102,6 @@ export const BaseRouter = { }, shouldActionChangeFocus(action: NavigationAction) { - return ( - action.type === 'PUSH' || action.type === 'NAVIGATE' || action.type === 'NAVIGATE_DEPRECATED' - ); + return action.type === 'PUSH' || action.type === 'NAVIGATE'; }, }; diff --git a/packages/expo-router/src/react-navigation/routers/CommonActions.tsx b/packages/expo-router/src/react-navigation/routers/CommonActions.tsx index d22635bdb4634e..32452981f20df9 100644 --- a/packages/expo-router/src/react-navigation/routers/CommonActions.tsx +++ b/packages/expo-router/src/react-navigation/routers/CommonActions.tsx @@ -26,17 +26,6 @@ export type NavigateAction = { target?: string; }; -type NavigateDeprecatedAction = { - type: 'NAVIGATE_DEPRECATED'; - payload: { - name: string; - params?: object; - merge?: boolean; - }; - source?: string; - target?: string; -}; - type ResetAction = { type: 'RESET'; payload: ResetState | undefined; @@ -80,7 +69,6 @@ export type InternalRouteNamesChangedAction = { export type Action = | GoBackAction | NavigateAction - | NavigateDeprecatedAction | ResetAction | SetParamsAction | ReplaceParamsAction @@ -139,30 +127,6 @@ export function navigate(...args: any): Action { } } -export function navigateDeprecated( - ...args: - | [name: string] - | [name: string, params: object | undefined] - | [options: { name: string; params?: object }] -): Action { - if (typeof args[0] === 'string') { - return { - type: 'NAVIGATE_DEPRECATED', - payload: { name: args[0], params: args[1] }, - }; - } else { - const payload = args[0] || {}; - - if (!('name' in payload)) { - throw new Error( - 'You need to specify a name when calling navigateDeprecated with an object as the argument. See https://reactnavigation.org/docs/navigation-actions#navigatelegacy for usage.' - ); - } - - return { type: 'NAVIGATE_DEPRECATED', payload }; - } -} - export function reset(state: ResetState | undefined) { return { type: 'RESET', payload: state } as const satisfies ResetAction; } diff --git a/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx b/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx index fa29b3c7a6eabc..6e7229be9d4c4e 100644 --- a/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx +++ b/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx @@ -205,8 +205,7 @@ export function DrawerRouter({ case 'PUSH': case 'REPLACE': case 'JUMP_TO': - case 'NAVIGATE': - case 'NAVIGATE_DEPRECATED': { + case 'NAVIGATE': { const result = router.getStateForAction(state, action, options); if (result != null && result.index !== state.index) { diff --git a/packages/expo-router/src/react-navigation/routers/StackRouter.tsx b/packages/expo-router/src/react-navigation/routers/StackRouter.tsx index 3197ca438a47b4..9d0a37834f4ed9 100644 --- a/packages/expo-router/src/react-navigation/routers/StackRouter.tsx +++ b/packages/expo-router/src/react-navigation/routers/StackRouter.tsx @@ -455,64 +455,6 @@ export function StackRouter(options: StackRouterOptions) { ); } - case 'NAVIGATE_DEPRECATED': { - if (!state.routeNames.includes(action.payload.name)) { - return null; - } - - const getId = options.routeGetIdList[action.payload.name]; - const id = getId?.({ params: action.payload.params }); - - if ( - preloadedRoutes.find( - (route) => - route.name === action.payload.name && id === getId?.({ params: route.params }) - ) - ) { - return null; - } - - // If the route already exists, navigate to that - let index = -1; - - if (id !== undefined) { - index = activeRoutes.findIndex( - (route) => - route.name === action.payload.name && id === getId?.({ params: route.params }) - ); - } else if (activeRoutes[state.index]!.name === action.payload.name) { - index = state.index; - } else { - index = activeRoutes.findLastIndex((route) => route.name === action.payload.name); - } - - if (index === -1) { - const routes = [...activeRoutes, createRouteFromAction({ action })]; - return reconcileStackRoutes(state, routes); - } - - const route = activeRoutes[index]!; - - let params; - - if (action.payload.merge) { - params = - action.payload.params !== undefined - ? { - ...route.params, - ...action.payload.params, - } - : route.params; - } else { - params = action.payload.params; - } - - return reconcileStackRoutes(state, [ - ...activeRoutes.slice(0, index), - params !== route.params ? { ...route, params } : activeRoutes[index]!, - ]); - } - case 'REMOVE_ROUTES': { const focusedRoute = activeRoutes[state.index]!; const routes = activeRoutes.filter( diff --git a/packages/expo-router/src/react-navigation/routers/TabRouter.tsx b/packages/expo-router/src/react-navigation/routers/TabRouter.tsx index 8104c7516a5e5f..b20daa0857e422 100644 --- a/packages/expo-router/src/react-navigation/routers/TabRouter.tsx +++ b/packages/expo-router/src/react-navigation/routers/TabRouter.tsx @@ -451,8 +451,7 @@ export function TabRouter({ case 'PUSH': case 'REPLACE': case 'JUMP_TO': - case 'NAVIGATE': - case 'NAVIGATE_DEPRECATED': { + case 'NAVIGATE': { if (!state.routeNames.includes(action.payload.name)) { return null; } @@ -481,11 +480,7 @@ export function TabRouter({ let params; - if ( - (action.type === 'NAVIGATE' || action.type === 'NAVIGATE_DEPRECATED') && - action.payload.merge && - currentId === nextId - ) { + if (action.type === 'NAVIGATE' && action.payload.merge && currentId === nextId) { params = action.payload.params !== undefined ? { diff --git a/packages/expo-router/src/react-navigation/routers/__tests__/CommonActions.test.tsx b/packages/expo-router/src/react-navigation/routers/__tests__/CommonActions.test.tsx index fa7734dd671dff..2187612ab8cf43 100644 --- a/packages/expo-router/src/react-navigation/routers/__tests__/CommonActions.test.tsx +++ b/packages/expo-router/src/react-navigation/routers/__tests__/CommonActions.test.tsx @@ -2,6 +2,10 @@ import { expect, test } from '@jest/globals'; import * as CommonActions from '../CommonActions'; +test('does not export deprecated navigation actions', () => { + expect(CommonActions).not.toHaveProperty('navigateDeprecated'); +}); + test('throws if NAVIGATE is called without name', () => { // @ts-expect-error: we're explicitly using an invalid argument here expect(() => CommonActions.navigate({})).toThrow( diff --git a/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx b/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx index 7ad6d4983572d5..6444204e382734 100644 --- a/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx +++ b/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx @@ -821,327 +821,6 @@ test('goes back to matching ID for navigate if pop: true', () => { }); }); -test('handles navigate action (legacy)', () => { - const router = StackRouter({}); - const options: RouterConfigOptions = { - routeNames: ['baz', 'bar', 'qux'], - routeGetIdList: {}, - }; - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar' }, - ], - }, - CommonActions.navigateDeprecated('qux', { answer: 42 }), - options - ) - ).toEqual({ - stale: false, - type: 'stack', - key: 'root', - index: 2, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar' }, - { - key: 'qux-test', - name: 'qux', - params: { answer: 42 }, - }, - ], - }); - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar' }, - ], - }, - CommonActions.navigateDeprecated('baz', { answer: 42 }), - options - ) - ).toEqual({ - stale: false, - type: 'stack', - key: 'root', - index: 0, - routeNames: ['baz', 'bar', 'qux'], - routes: [{ key: 'baz', name: 'baz', params: { answer: 42 } }], - }); - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar', params: { answer: 42 } }, - ], - }, - CommonActions.navigateDeprecated('bar', { answer: 96 }), - options - ) - ).toEqual({ - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar', params: { answer: 96 } }, - ], - }); - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar' }, - ], - }, - CommonActions.navigateDeprecated('unknown'), - options - ) - ).toBeNull(); -}); - -test("doesn't navigate to nonexistent screen (legacy)", () => { - const router = StackRouter({}); - const options: RouterConfigOptions = { - routeNames: ['baz', 'bar', 'qux'], - routeGetIdList: {}, - }; - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar' }, - ], - }, - CommonActions.navigateDeprecated('far', { answer: 42 }), - options - ) - ).toBeNull(); - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar' }, - ], - }, - CommonActions.navigateDeprecated({ - name: 'far', - params: { answer: 42 }, - }), - options - ) - ).toBeNull(); -}); - -test('ensures unique ID for navigate (legacy)', () => { - const router = StackRouter({}); - const options: RouterConfigOptions = { - routeNames: ['baz', 'bar', 'qux'], - routeGetIdList: { - bar: ({ params }) => params?.foo, - qux: ({ params }) => params?.fux, - }, - }; - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 0, - routeNames: ['baz', 'bar', 'qux'], - routes: [{ key: 'bar', name: 'bar' }], - }, - CommonActions.navigateDeprecated('bar', { foo: 'a' }), - options - ) - ).toEqual({ - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'bar', name: 'bar' }, - { key: 'bar-test', name: 'bar', params: { foo: 'a' } }, - ], - }); - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'bar', name: 'bar' }, - { key: 'bar-test', name: 'bar', params: { foo: 'a' } }, - ], - }, - CommonActions.navigateDeprecated('bar', { foo: 'a' }), - options - ) - ).toEqual({ - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'bar', name: 'bar' }, - { key: 'bar-test', name: 'bar', params: { foo: 'a' } }, - ], - }); - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'bar', name: 'bar' }, - { key: 'bar-test', name: 'bar', params: { foo: 'a' } }, - ], - }, - CommonActions.navigateDeprecated('bar', { foo: 'b' }), - options - ) - ).toEqual({ - stale: false, - type: 'stack', - key: 'root', - index: 2, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'bar', name: 'bar' }, - { key: 'bar-test', name: 'bar', params: { foo: 'a' } }, - { key: 'bar-test', name: 'bar', params: { foo: 'b' } }, - ], - }); -}); - -test('ignores legacy navigate when the matching route is preloaded', () => { - const router = StackRouter({}); - const options: RouterConfigOptions = { - routeNames: ['baz', 'bar'], - routeGetIdList: { - bar: ({ params }) => params?.foo, - }, - }; - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 0, - routeNames: ['baz', 'bar'], - routes: [ - { key: 'baz', name: 'baz' }, - { key: 'bar', name: 'bar', params: { foo: 'a' } }, - ], - }, - CommonActions.navigateDeprecated('bar', { foo: 'a' }), - options - ) - ).toBeNull(); -}); - -test('ensure unique ID is only per route name for navigate (legacy)', () => { - const router = StackRouter({}); - const options: RouterConfigOptions = { - routeNames: ['baz', 'bar', 'qux'], - routeGetIdList: { - baz: ({ params }) => params?.foo, - bar: ({ params }) => params?.foo, - qux: ({ params }) => params?.test, - }, - }; - - expect( - router.getStateForAction( - { - stale: false, - type: 'stack', - key: 'root', - index: 1, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'qux-test', name: 'qux', params: { test: 'a' } }, - { key: 'baz-test', name: 'baz', params: { foo: 'a' } }, - ], - }, - CommonActions.navigateDeprecated('bar', { foo: 'a' }), - options - ) - ).toEqual({ - stale: false, - type: 'stack', - key: 'root', - index: 2, - routeNames: ['baz', 'bar', 'qux'], - routes: [ - { key: 'qux-test', name: 'qux', params: { test: 'a' } }, - { key: 'baz-test', name: 'baz', params: { foo: 'a' } }, - { key: 'bar-test', name: 'bar', params: { foo: 'a' } }, - ], - }); -}); - test('handles go back action', () => { const router = StackRouter({}); const options: RouterConfigOptions = { diff --git a/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx b/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx index 047ed5dde360c4..03da111eda2070 100644 --- a/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx +++ b/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx @@ -220,7 +220,6 @@ test('gets rehydrated state from partial state', () => { test.each([ CommonActions.navigate('baz', { value: 2 }), - CommonActions.navigateDeprecated('baz', { value: 2 }), TabActions.jumpTo('baz', { value: 2 }), TabActions.replace('baz', { value: 2 }), ])('$type mints and focuses an absent declared route', (action) => { From 21bc261283f6f003feece568c72b0aecb292d476 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:38:19 +0200 Subject: [PATCH 5/8] [router] Remove linking.enabled option (#49103) # Why Linking is always enabled in expo-router, so we can remove the checks and logic for when it is not enabled # How # Test Plan CI # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com> --- packages/expo-router/CHANGELOG.md | 1 + .../src/fork/NavigationContainer.tsx | 17 ++-------- .../expo-router/src/fork/useLinking.native.ts | 31 ++++++++++--------- packages/expo-router/src/fork/useLinking.ts | 17 +++++----- .../src/react-navigation/native/types.tsx | 5 --- .../native/useLinkBuilder.tsx | 13 +------- .../react-navigation/native/useRoutePath.tsx | 6 +--- 7 files changed, 30 insertions(+), 60 deletions(-) diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 188ad31c558161..7ca108a3cef8d7 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -62,6 +62,7 @@ ### πŸ’‘ Others +- Remove the ignored `linking.enabled` option. ([#49103](https://github.com/expo/expo/pull/49103) by [@Ubax](https://github.com/Ubax)) - Render only the focused tab route during the first render. ([#48618](https://github.com/expo/expo/pull/48618) by [@Ubax](https://github.com/Ubax)) - Order JS Tabs, NativeTabs, headless tabs and Drawer by `routeNames` order ([#48374](https://github.com/expo/expo/pull/48374) by [@Ubax](https://github.com/Ubax)) - Integrate native stack with standard navigation ([#48114](https://github.com/expo/expo/pull/48114) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/fork/NavigationContainer.tsx b/packages/expo-router/src/fork/NavigationContainer.tsx index e2c114416b4490..634b4183e5987d 100644 --- a/packages/expo-router/src/fork/NavigationContainer.tsx +++ b/packages/expo-router/src/fork/NavigationContainer.tsx @@ -56,7 +56,7 @@ type Props = NavigationContainerProps & { * @param props.onUnhandledAction Callback which is called when an action is not handled. * @param props.direction Text direction of the components. Defaults to `'ltr'`. * @param props.theme Theme object for the UI elements. - * @param props.linking Options for deep linking. Deep link handling is enabled when this prop is provided, unless `linking.enabled` is `false`. + * @param props.linking Options for deep linking. Deep link handling is enabled when this prop is provided. * @param props.fallback Fallback component to render until we have finished getting initial state when linking is enabled. Defaults to `null`. * @param props.documentTitle Options to configure the document title on Web. Updating document title is handled by default unless `documentTitle.enabled` is `false`. * @param props.children Child elements to render the content. @@ -75,8 +75,6 @@ function NavigationContainerInner( }: Props, ref?: React.Ref | null> ) { - const isLinkingEnabled = linking ? linking.enabled !== false : false; - if (linking?.config) { validatePathConfig(linking.config); } @@ -89,15 +87,7 @@ function NavigationContainerInner( const [lastUnhandledLink, setLastUnhandledLink] = React.useState(); - const { getInitialState } = useLinking( - refContainer, - { - enabled: isLinkingEnabled, - prefixes: [], - ...linking, - }, - setLastUnhandledLink - ); + const { getInitialState } = useLinking(refContainer, linking, setLastUnhandledLink); const linkingContext = React.useMemo(() => ({ options: linking }), [linking]); @@ -139,7 +129,6 @@ function NavigationContainerInner( get linking() { return { ...linking, - enabled: isLinkingEnabled, prefixes: linking?.prefixes ?? [], getStateFromPath: linking?.getStateFromPath ?? getStateFromPath, getPathFromState: linking?.getPathFromState ?? getPathFromState, @@ -154,7 +143,7 @@ function NavigationContainerInner( React.useImperativeHandle(ref, () => refContainer.current!); - const isLinkingReady = rest.initialState != null || !isLinkingEnabled || isResolved; + const isLinkingReady = rest.initialState != null || !linking || isResolved; if (!isLinkingReady) { // This is temporary until we have Suspense for data-fetching diff --git a/packages/expo-router/src/fork/useLinking.native.ts b/packages/expo-router/src/fork/useLinking.native.ts index d3c4d0858ec5bf..24a6358c5a1154 100644 --- a/packages/expo-router/src/fork/useLinking.native.ts +++ b/packages/expo-router/src/fork/useLinking.native.ts @@ -20,13 +20,17 @@ const linkingHandlers: symbol[] = []; export function useLinking( ref: RefObject>, - { - enabled = true, - prefixes, - filter, - config, - getInitialURL = () => getInitialURLWithTimeout(), - subscribe = (listener) => { + options: Options | undefined, + onUnhandledLinking: (lastUnhandledLining: string | undefined) => void +) { + const enabled = options !== undefined; + const prefixes = options?.prefixes ?? []; + const filter = options?.filter; + const config = options?.config; + const getInitialURL = options?.getInitialURL ?? (() => getInitialURLWithTimeout()); + const subscribe = + options?.subscribe ?? + ((listener) => { const callback = ({ url }: { url: string }) => listener(url); const subscription = Linking.addEventListener('url', callback) as @@ -45,12 +49,9 @@ export function useLinking( removeEventListener?.('url', callback); } }; - }, - getStateFromPath = getStateFromPathDefault, - getActionFromState = getActionFromStateDefault, - }: Options, - onUnhandledLinking: (lastUnhandledLining: string | undefined) => void -) { + }); + const getStateFromPath = options?.getStateFromPath ?? getStateFromPathDefault; + const getActionFromState = options?.getActionFromState ?? getActionFromStateDefault; const independent = useNavigationIndependentTree(); useEffect(() => { @@ -62,7 +63,7 @@ export function useLinking( return undefined; } - if (enabled !== false && linkingHandlers.length) { + if (enabled && linkingHandlers.length) { console.error( [ 'Looks like you have configured linking in multiple places. This is likely an error since deep links should only be handled in one place to avoid conflicts. Make sure that:', @@ -76,7 +77,7 @@ export function useLinking( const handler = Symbol(); - if (enabled !== false) { + if (enabled) { linkingHandlers.push(handler); } diff --git a/packages/expo-router/src/fork/useLinking.ts b/packages/expo-router/src/fork/useLinking.ts index 6eea3472d47d69..e0252c0bbcadd7 100644 --- a/packages/expo-router/src/fork/useLinking.ts +++ b/packages/expo-router/src/fork/useLinking.ts @@ -78,15 +78,14 @@ type Options = LinkingOptions; export function useLinking( ref: RefObject | null>, - { - enabled = true, - config, - getStateFromPath = getStateFromPathDefault, - getPathFromState = getPathFromStateDefault, - getActionFromState = getActionFromStateDefault, - }: Options, + options: Options | undefined, onUnhandledLinking: (lastUnhandledLining: string | undefined) => void ) { + const enabled = options !== undefined; + const config = options?.config; + const getStateFromPath = options?.getStateFromPath ?? getStateFromPathDefault; + const getPathFromState = options?.getPathFromState ?? getPathFromStateDefault; + const getActionFromState = options?.getActionFromState ?? getActionFromStateDefault; const independent = useNavigationIndependentTree(); const store = useExpoRouterStore(); @@ -100,7 +99,7 @@ export function useLinking( return undefined; } - if (enabled !== false && linkingHandlers.length) { + if (enabled && linkingHandlers.length) { console.error( [ 'Looks like you have configured linking in multiple places. This is likely an error since deep links should only be handled in one place to avoid conflicts. Make sure that:', @@ -114,7 +113,7 @@ export function useLinking( const handler = Symbol(); - if (enabled !== false) { + if (enabled) { linkingHandlers.push(handler); } diff --git a/packages/expo-router/src/react-navigation/native/types.tsx b/packages/expo-router/src/react-navigation/native/types.tsx index 35cadc83e92093..eaeb0a53114c9d 100644 --- a/packages/expo-router/src/react-navigation/native/types.tsx +++ b/packages/expo-router/src/react-navigation/native/types.tsx @@ -58,11 +58,6 @@ export type Theme = NativeTheme; export type LocaleDirection = 'ltr' | 'rtl'; export type LinkingOptions = { - /** - * Whether deep link handling should be enabled. - * Defaults to true. - */ - enabled?: boolean; /** * The prefixes are stripped from the URL before parsing them. * Usually they are the `scheme` + `host` (e.g. `myapp://chat?user=jane`) diff --git a/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx b/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx index 7c66ab01cf60c5..e9269edc9222c3 100644 --- a/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx +++ b/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx @@ -33,10 +33,6 @@ export function useBuildHref() { const buildHref = React.useCallback( (name: string, params?: object) => { - if (options?.enabled === false) { - return undefined; - } - // Check that we're inside: // - navigator's context // - route context of the navigator (could be a screen, tab, etc.) @@ -86,14 +82,7 @@ export function useBuildHref() { return path; }, - [ - options?.enabled, - options?.config, - route?.key, - navigation, - focusedRouteState, - getPathFromStateHelper, - ] + [options?.config, route?.key, navigation, focusedRouteState, getPathFromStateHelper] ); return buildHref; diff --git a/packages/expo-router/src/react-navigation/native/useRoutePath.tsx b/packages/expo-router/src/react-navigation/native/useRoutePath.tsx index 1ab84ef0a88b27..4461a34a0db944 100644 --- a/packages/expo-router/src/react-navigation/native/useRoutePath.tsx +++ b/packages/expo-router/src/react-navigation/native/useRoutePath.tsx @@ -24,14 +24,10 @@ export function useRoutePath() { const getPathFromStateHelper = options?.getPathFromState ?? getPathFromState; const path = React.useMemo(() => { - if (options?.enabled === false) { - return undefined; - } - const path = getPathFromStateHelper(state, options?.config); return path; - }, [options?.enabled, options?.config, state, getPathFromStateHelper]); + }, [options?.config, state, getPathFromStateHelper]); return path; } From 31afe0cce1e509bf2b007c3924ca28897b8d5afd Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:38:19 +0200 Subject: [PATCH 6/8] [router] Remove the deprecated `Link` and `useLinkProps` exports from `expo-router/react-navigation` (#48895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Remove the deprecated `Link` and `useLinkProps` exports from expo-router/react-navigation # How # Test Plan CI # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] 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) --- Stack created with GitHub Stacks CLI β€’ Give Feedback πŸ’¬ --- packages/expo-router/CHANGELOG.md | 1 + .../src/link/__tests__/Link.test.web.tsx | 83 +++++++++- .../src/link/useLinkToPathProps.tsx | 2 + .../src/react-navigation/elements/Button.tsx | 57 +++---- .../elements/__tests__/Button.test.ios.tsx | 42 +++++ .../elements/__tests__/Button.test.native.tsx | 57 +++++++ .../elements/__tests__/Button.test.web.tsx | 125 +++++++++++++++ .../src/react-navigation/native/Link.tsx | 57 ------- .../src/react-navigation/native/index.tsx | 8 - .../react-navigation/native/useLinkProps.tsx | 150 ------------------ 10 files changed, 338 insertions(+), 244 deletions(-) create mode 100644 packages/expo-router/src/react-navigation/elements/__tests__/Button.test.ios.tsx create mode 100644 packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx create mode 100644 packages/expo-router/src/react-navigation/elements/__tests__/Button.test.web.tsx delete mode 100644 packages/expo-router/src/react-navigation/native/Link.tsx delete mode 100644 packages/expo-router/src/react-navigation/native/useLinkProps.tsx diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 7ca108a3cef8d7..c792bfb554bc3e 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -4,6 +4,7 @@ ### πŸ›  Breaking changes +- Remove the deprecated `Link` and `useLinkProps` exports from `expo-router/react-navigation`. Use `Link` from `expo-router` with an `href` instead. ([#48895](https://github.com/expo/expo/pull/48895) by [@Ubax](https://github.com/Ubax)) - Remove the deprecated `navigateDeprecated` action and the `navigationInChildEnabled` container prop from `expo-router/react-navigation`. ([#49102](https://github.com/expo/expo/pull/49102) by [@Ubax](https://github.com/Ubax)) - Remove `getInitialState` from the `Router` interface. Custom routers no longer create initial state; the navigator creates it and passes it to `getRehydratedState`. ([#48783](https://github.com/expo/expo/pull/48783) by [@Ubax](https://github.com/Ubax)) - Remove `routeParamList` from `RouterConfigOptions` and remove the `RouterActionOptions` type. Custom routers receive `RouterConfigOptions` in both `getRehydratedState` and `getStateForAction`. ([#48783](https://github.com/expo/expo/pull/48783) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/link/__tests__/Link.test.web.tsx b/packages/expo-router/src/link/__tests__/Link.test.web.tsx index d02f639e11855f..02593dff3664cf 100644 --- a/packages/expo-router/src/link/__tests__/Link.test.web.tsx +++ b/packages/expo-router/src/link/__tests__/Link.test.web.tsx @@ -1,9 +1,26 @@ /** @jest-environment jsdom */ -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import { StyleSheet, Text, View } from 'react-native'; +import { linkTo } from '../../global-state/routing'; import { Link } from '../Link'; +jest.mock('../../global-state/routing', () => { + const actual = jest.requireActual( + '../../global-state/routing' + ) as typeof import('../../global-state/routing'); + return { + ...actual, + linkTo: jest.fn(), + }; +}); + +const mockedLinkTo = linkTo as jest.MockedFunction; + +beforeEach(() => { + mockedLinkTo.mockClear(); +}); + it('renders a Link', () => { const { getByTestId } = render( @@ -203,6 +220,70 @@ describe('base url relative links', () => { }); }); +describe('web click navigation', () => { + it('intercepts unmodified same-tab clicks', () => { + const { getByTestId } = render( + + Foo + + ); + + fireEvent.click(getByTestId('link'), { button: 0 }); + + expect(mockedLinkTo).toHaveBeenCalledWith('/foo', { + dangerouslySingular: undefined, + event: undefined, + relativeToDirectory: undefined, + withAnchor: undefined, + }); + }); + + it.each([ + ['metaKey', { metaKey: true }], + ['altKey', { altKey: true }], + ['ctrlKey', { ctrlKey: true }], + ['shiftKey', { shiftKey: true }], + ['middle click', { button: 1 }], + ])('does not intercept %s clicks', (_name, event) => { + const { getByTestId } = render( + + Foo + + ); + + fireEvent.click(getByTestId('link'), event); + + expect(mockedLinkTo).not.toHaveBeenCalled(); + }); + + it('does not intercept links with a target', () => { + const { getByTestId } = render( + + Foo + + ); + + fireEvent.click(getByTestId('link'), { button: 0 }); + + expect(mockedLinkTo).not.toHaveBeenCalled(); + }); + + it.each(['https://expo.dev', '//expo.dev/router', 'mailto:hello@example.com'])( + 'intercepts external href %s', + (href) => { + const { getByTestId } = render( + + Foo + + ); + + fireEvent.click(getByTestId('link'), { button: 0 }); + + expect(mockedLinkTo).toHaveBeenCalled(); + } + ); +}); + describe('Link with preview', () => { it('renders a Link with preview as normal link', () => { const { getByTestId } = render( diff --git a/packages/expo-router/src/link/useLinkToPathProps.tsx b/packages/expo-router/src/link/useLinkToPathProps.tsx index 3bcd319139710c..660977ca8286d6 100644 --- a/packages/expo-router/src/link/useLinkToPathProps.tsx +++ b/packages/expo-router/src/link/useLinkToPathProps.tsx @@ -39,6 +39,8 @@ type UseLinkToPathPropsOptions = LinkToOptions & { export default function useLinkToPathProps({ href, ...options }: UseLinkToPathPropsOptions) { const onPress = (event?: MouseEvent | GestureResponderEvent) => { + // TODO: Align external links: anchors stay in the same tab, while a non-anchor `asChild` + // falls back to `linkTo` and `Linking.openURL`, which opens a new tab. if (shouldHandleMouseEvent(event)) { if (emitDomLinkEvent(href, options)) { return; diff --git a/packages/expo-router/src/react-navigation/elements/Button.tsx b/packages/expo-router/src/react-navigation/elements/Button.tsx index a7dbdc3b4d2f30..3a690ee11c6c21 100644 --- a/packages/expo-router/src/react-navigation/elements/Button.tsx +++ b/packages/expo-router/src/react-navigation/elements/Button.tsx @@ -1,8 +1,11 @@ import Color from 'color'; -import * as React from 'react'; import { Platform, StyleSheet } from 'react-native'; -import { type LinkProps, useLinkProps, useTheme } from '../native'; +import { router } from '../../imperative-api'; +import { resolveHref } from '../../link/href'; +import useLinkToPathProps from '../../link/useLinkToPathProps'; +import type { Href } from '../../types'; +import { useTheme } from '../native'; import { PlatformPressable, type Props as PlatformPressableProps } from './PlatformPressable'; import { Text } from './Text'; @@ -12,39 +15,37 @@ type ButtonBaseProps = Omit & { children: string | string[]; }; -type ButtonLinkProps = LinkProps & - Omit; +type ButtonProps = Omit & { + href?: Href; +}; const BUTTON_RADIUS = 40; -export function Button( - props: ButtonLinkProps -): React.JSX.Element; - -export function Button(props: ButtonBaseProps): React.JSX.Element; - -export function Button( - props: ButtonBaseProps | ButtonLinkProps -) { - if ('screen' in props || 'action' in props) { - // @ts-expect-error: This is already type-checked by the prop types - return ; - } else { - return ; +export function Button({ href, ...rest }: ButtonProps) { + if (href != null) { + return ; } + + return ; } -function ButtonLink({ - screen, - params, - action, - href, - ...rest -}: ButtonLinkProps) { - // @ts-expect-error: This is already type-checked by the prop types - const props = useLinkProps({ screen, params, action, href }); +function ButtonLink({ href, onPress, ...rest }: ButtonProps & { href: Href }) { + const { href: resolvedHref } = useLinkToPathProps({ href: resolveHref(href) }); - return ; + return ( + { + onPress?.(event); + // `PlatformPressable` prevents unmodified web clicks before calling `onPress`, so a + // consumer cannot cancel navigation there with `preventDefault`; on native, they can. + if (Platform.OS === 'web' || !event?.defaultPrevented) { + router.navigate(href); + } + }} + /> + ); } function ButtonBase({ diff --git a/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.ios.tsx b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.ios.tsx new file mode 100644 index 00000000000000..3eef790bad4ed0 --- /dev/null +++ b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.ios.tsx @@ -0,0 +1,42 @@ +import { act, fireEvent } from '@testing-library/react-native'; +import { Text } from 'react-native'; + +import { renderRouter, screen } from '../../../testing-library'; +import { Button } from '../Button'; + +it('navigates to its href when pressed', () => { + renderRouter({ + index: () => , + profile: () => Profile, + }); + + act(() => fireEvent.press(screen.getByText('Profile'))); + + expect(screen.getByTestId('profile')).toBeVisible(); +}); + +it('calls onPress without an href', () => { + const onPress = jest.fn(); + renderRouter({ index: () => }); + + fireEvent.press(screen.getByText('Action')); + + expect(onPress).toHaveBeenCalledTimes(1); +}); + +it('calls onPress before navigating to an href', () => { + const onPress = jest.fn(); + renderRouter({ + index: () => ( + + ), + profile: () => Profile, + }); + + act(() => fireEvent.press(screen.getByText('Profile'))); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('profile')).toBeVisible(); +}); diff --git a/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx new file mode 100644 index 00000000000000..d9c5b2c3e10427 --- /dev/null +++ b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import type { ComponentProps } from 'react'; + +import { router } from '../../../imperative-api'; +import { DefaultTheme, ThemeProvider } from '../../native'; +import { Button } from '../Button'; + +jest.mock('../../../imperative-api', () => { + const actual = jest.requireActual( + '../../../imperative-api' + ) as typeof import('../../../imperative-api'); + return { + ...actual, + router: { + ...actual.router, + navigate: jest.fn(), + }, + }; +}); + +const mockedNavigate = router.navigate as jest.MockedFunction; + +function renderButton(props: ComponentProps) { + return render( + +