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/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/). + 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" }, { 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 diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index a83ac32dcc9c2a..c792bfb554bc3e 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -4,6 +4,8 @@ ### 🛠 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)) - Remove the deprecated `NavigationContainer` export ([#48760](https://github.com/expo/expo/pull/48760) by [@Ubax](https://github.com/Ubax)) @@ -61,6 +63,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/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/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/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/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/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( + +