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(
+
+
+
+ );
+}
+
+beforeEach(() => {
+ mockedNavigate.mockReset();
+});
+
+it('does not navigate when the press is prevented', () => {
+ const { getByTestId } = renderButton({
+ children: 'Profile',
+ href: '/profile',
+ testID: 'button',
+ });
+
+ fireEvent.press(getByTestId('button'), { defaultPrevented: true });
+
+ expect(mockedNavigate).not.toHaveBeenCalled();
+});
+
+it('navigates on press', () => {
+ const { getByTestId } = renderButton({
+ children: 'Profile',
+ href: '/profile',
+ testID: 'button',
+ });
+
+ fireEvent.press(getByTestId('button'));
+
+ expect(mockedNavigate).toHaveBeenCalledWith('/profile');
+});
diff --git a/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.web.tsx b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.web.tsx
new file mode 100644
index 00000000000000..56efc59ba8ac3d
--- /dev/null
+++ b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.web.tsx
@@ -0,0 +1,125 @@
+/** @jest-environment jsdom */
+import { fireEvent, render } from '@testing-library/react';
+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(
+
+
+
+ );
+}
+
+beforeEach(() => {
+ mockedNavigate.mockReset();
+});
+
+it('renders an anchor with a resolved href', () => {
+ const { getByTestId } = renderButton({
+ children: 'Profile',
+ href: '/(group)/profile',
+ testID: 'button',
+ });
+
+ const button = getByTestId('button');
+ expect(button.tagName).toBe('A');
+ expect(button.getAttribute('href')).toBe('/profile');
+});
+
+it('navigates on an unmodified left click', () => {
+ const { getByTestId } = renderButton({
+ children: 'Profile',
+ href: '/profile',
+ testID: 'button',
+ });
+
+ fireEvent.click(getByTestId('button'), { button: 0 });
+
+ expect(mockedNavigate).toHaveBeenCalledWith('/profile');
+});
+
+it('navigates when onPress prevents the default', () => {
+ const { getByTestId } = renderButton({
+ children: 'Profile',
+ href: '/profile',
+ onPress: (event) => event.preventDefault(),
+ testID: 'button',
+ });
+
+ fireEvent.click(getByTestId('button'), { button: 0 });
+
+ expect(mockedNavigate).toHaveBeenCalledWith('/profile');
+});
+
+it('renders and navigates to an external href', () => {
+ const { getByTestId } = renderButton({
+ children: 'Expo',
+ href: 'https://expo.dev',
+ testID: 'button',
+ });
+ const button = getByTestId('button');
+
+ expect(button.getAttribute('href')).toBe('https://expo.dev');
+
+ fireEvent.click(button, { button: 0 });
+
+ expect(mockedNavigate).toHaveBeenCalledWith('https://expo.dev');
+});
+
+it.each([
+ ['metaKey', { metaKey: true }],
+ ['altKey', { altKey: true }],
+ ['ctrlKey', { ctrlKey: true }],
+ ['shiftKey', { shiftKey: true }],
+ ['middle click', { button: 1 }],
+])('does not intercept %s clicks', (_name, eventInit) => {
+ const { getByTestId } = renderButton({
+ children: 'Profile',
+ href: '/profile',
+ testID: 'button',
+ });
+ const event = new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ ...eventInit,
+ });
+
+ fireEvent(getByTestId('button'), event);
+
+ expect(mockedNavigate).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+});
+
+it('calls onPress before navigating', () => {
+ const calls: string[] = [];
+ mockedNavigate.mockImplementation(() => calls.push('navigate'));
+ const { getByTestId } = renderButton({
+ children: 'Profile',
+ href: '/profile',
+ onPress: () => calls.push('onPress'),
+ testID: 'button',
+ });
+
+ fireEvent.click(getByTestId('button'), { button: 0 });
+
+ expect(calls).toEqual(['onPress', 'navigate']);
+});
diff --git a/packages/expo-router/src/react-navigation/native/Link.tsx b/packages/expo-router/src/react-navigation/native/Link.tsx
deleted file mode 100644
index 86e1de634152f5..00000000000000
--- a/packages/expo-router/src/react-navigation/native/Link.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import * as React from 'react';
-import { type GestureResponderEvent, Platform, Text, type TextProps } from 'react-native';
-
-import { useTheme } from '../core';
-import { type LinkProps, useLinkProps } from './useLinkProps';
-
-type Props = LinkProps &
- Omit & {
- target?: string;
- onPress?: (e: React.MouseEvent | GestureResponderEvent) => void;
- disabled?: boolean | null;
- children: React.ReactNode;
- };
-
-/**
- * Component to render link to another screen using a path.
- * Uses an anchor tag on the web.
- *
- * @param props.screen Name of the screen to navigate to (e.g. `'Feeds'`).
- * @param props.params Params to pass to the screen to navigate to (e.g. `{ sort: 'hot' }`).
- * @param props.href Optional absolute path to use for the href (e.g. `/feeds/hot`).
- * @param props.action Optional action to use for in-page navigation. By default, the path is parsed to an action based on linking config.
- * @param props.children Child elements to render the content.
- */
-export function Link({
- screen,
- params,
- action,
- href,
- style,
- ...rest
-}: Props) {
- const { colors, fonts } = useTheme();
- // @ts-expect-error: This is already type-checked by the prop types
- const props = useLinkProps({ screen, params, action, href });
-
- const onPress = (e: React.MouseEvent | GestureResponderEvent) => {
- if ('onPress' in rest) {
- rest.onPress?.(e);
- }
-
- // Let user prevent default behavior
- if (!e.defaultPrevented) {
- props.onPress(e);
- }
- };
-
- return React.createElement(Text, {
- ...props,
- ...rest,
- ...Platform.select({
- web: { onClick: onPress } as any,
- default: { onPress },
- }),
- style: [{ color: colors.primary }, fonts.regular, style],
- });
-}
diff --git a/packages/expo-router/src/react-navigation/native/index.tsx b/packages/expo-router/src/react-navigation/native/index.tsx
index 7e5eb69319a1a1..4bcf652a64373b 100644
--- a/packages/expo-router/src/react-navigation/native/index.tsx
+++ b/packages/expo-router/src/react-navigation/native/index.tsx
@@ -1,7 +1,3 @@
-/**
- * @deprecated Use `Link` from `expo-router` instead. Will be removed in a future SDK.
- */
-export { Link } from './Link';
export { LinkingContext } from './LinkingContext';
/**
* @deprecated Use the `I18nManager` API from `react-native` to read or override the layout
@@ -27,10 +23,6 @@ export { UnhandledLinkingContext as UNSTABLE_UnhandledLinkingContext } from './U
* @deprecated Use `Link` from `expo-router`. Will be removed in a future SDK.
*/
export { useLinkBuilder } from './useLinkBuilder';
-/**
- * @deprecated Use `Link` from `expo-router`. Will be removed in a future SDK.
- */
-export { type LinkProps, useLinkProps } from './useLinkProps';
/**
* @deprecated Use `useRouter` from `expo-router` instead. Will be removed in a future SDK.
*/
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/useLinkProps.tsx b/packages/expo-router/src/react-navigation/native/useLinkProps.tsx
deleted file mode 100644
index 4b735c6b4ab571..00000000000000
--- a/packages/expo-router/src/react-navigation/native/useLinkProps.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-'use client';
-import * as React from 'react';
-import { type GestureResponderEvent, Platform } from 'react-native';
-
-import {
- getPathFromState,
- type NavigationAction,
- NavigationContainerRefContext,
- NavigationHelpersContext,
- type NavigatorScreenParams,
- type ParamListBase,
-} from '../core';
-import type { NavigationState, PartialState } from '../routers';
-import { LinkingContext } from './LinkingContext';
-
-export type LinkProps<
- ParamList extends ReactNavigation.RootParamList,
- RouteName extends keyof ParamList = keyof ParamList,
-> =
- | ({
- href?: string;
- action?: NavigationAction;
- } & (RouteName extends unknown
- ? undefined extends ParamList[RouteName]
- ? { screen: RouteName; params?: ParamList[RouteName] }
- : { screen: RouteName; params: ParamList[RouteName] }
- : never))
- | {
- href?: string;
- action: NavigationAction;
- screen?: undefined;
- params?: undefined;
- };
-
-const getStateFromParams = (
- params: NavigatorScreenParams | undefined
-): PartialState | NavigationState | undefined => {
- if (params?.state) {
- return params.state;
- }
-
- if (params?.screen) {
- return {
- routes: [
- {
- name: params.screen,
- params: params.params,
- // @ts-expect-error this is fine 🔥
- state: params.screen
- ? getStateFromParams(params.params as NavigatorScreenParams | undefined)
- : undefined,
- },
- ],
- };
- }
-
- return undefined;
-};
-
-/**
- * Hook to get props for an anchor tag so it can work with in page navigation.
- *
- * @param props.screen Name of the screen to navigate to (e.g. `'Feeds'`).
- * @param props.params Params to pass to the screen to navigate to (e.g. `{ sort: 'hot' }`).
- * @param props.href Optional absolute path to use for the href (e.g. `/feeds/hot`).
- * @param props.action Optional action to use for in-page navigation. By default, the path is parsed to an action based on linking config.
- */
-export function useLinkProps({
- screen,
- params,
- href,
- action,
-}: LinkProps) {
- const root = React.useContext(NavigationContainerRefContext);
- const navigation = React.useContext(NavigationHelpersContext);
- const { options } = React.useContext(LinkingContext);
-
- const onPress = (e?: React.MouseEvent | GestureResponderEvent) => {
- let shouldHandle = false;
-
- if (Platform.OS !== 'web' || !e) {
- e?.preventDefault?.();
- shouldHandle = true;
- } else {
- // ignore clicks with modifier keys
- const hasModifierKey =
- ('metaKey' in e && e.metaKey) ||
- ('altKey' in e && e.altKey) ||
- ('ctrlKey' in e && e.ctrlKey) ||
- ('shiftKey' in e && e.shiftKey);
-
- // only handle left clicks
- const isLeftClick = 'button' in e ? e.button == null || e.button === 0 : true;
-
- // let browser handle "target=_blank" etc.
- const isSelfTarget =
- e.currentTarget && 'target' in e.currentTarget
- ? [undefined, null, '', 'self'].includes(e.currentTarget.target)
- : true;
-
- if (!hasModifierKey && isLeftClick && isSelfTarget) {
- e.preventDefault?.();
- shouldHandle = true;
- }
- }
-
- if (shouldHandle) {
- if (action) {
- if (navigation) {
- navigation.dispatch(action);
- } else if (root) {
- root.dispatch(action);
- } else {
- throw new Error(
- "Couldn't find a navigation object. Is your component inside NavigationContainer?"
- );
- }
- } else {
- // @ts-expect-error This is already type-checked by the prop types
- navigation?.navigate(screen, params);
- }
- }
- };
-
- const getPathFromStateHelper = options?.getPathFromState ?? getPathFromState;
-
- return {
- href:
- href ??
- (Platform.OS === 'web' && screen != null
- ? getPathFromStateHelper(
- {
- routes: [
- {
- // @ts-expect-error this is fine 🔥
- name: screen,
- // @ts-expect-error this is fine 🔥
- params,
- // @ts-expect-error this is fine 🔥
- state: getStateFromParams(params),
- },
- ],
- },
- options?.config
- )
- : undefined),
- role: 'link' as const,
- onPress,
- };
-}
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;
}
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) => {
diff --git a/packages/expo-ui/CHANGELOG.md b/packages/expo-ui/CHANGELOG.md
index 412b3fe5769e32..da0e43e36bca2c 100644
--- a/packages/expo-ui/CHANGELOG.md
+++ b/packages/expo-ui/CHANGELOG.md
@@ -23,9 +23,11 @@
### 🐛 Bug fixes
+- [iOS] Fix `Toggle` rendering local state instead of `isOn`, which left `community/menu` checkmarks out of sync and dropped every second `onPressAction`. `Toggle` is now fully controlled: when `isOn` is set, it only moves once JS updates the prop. ([#48982](https://github.com/expo/expo/issues/48982) by [@JustJoostNL](https://github.com/JustJoostNL)) ([#49021](https://github.com/expo/expo/pull/49021) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
- [Android] Preserve vector drawable `fillType` values when loading images so even-odd paths render correctly.
- [universal] Fix `Cannot use shared object that was already released` when a worklet callback prop closes over an unstable value. ([#48819](https://github.com/expo/expo/pull/48819) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
- [universal] Add an explicit type annotation to `BottomSheetTextInput` so its type doesn't depend on referencing React Native's internal `TextInputType`. ([#48218](https://github.com/expo/expo/pull/48218) by [@zoontek](https://github.com/zoontek))
+- [iOS] Fix `Slider` ignoring `value` prop updates after the first drag. ([#49075](https://github.com/expo/expo/issues/49075) by [@matinzd](https://github.com/matinzd)) ([#49139](https://github.com/expo/expo/pull/49139) by [@nishan](https://github.com/intergalacticspacehighway))
- [iOS] Fix `community/bottom-sheet` close callbacks firing before the sheet finished dismissing. ([#48389](https://github.com/expo/expo/issues/48389) by [@nicklamont](https://github.com/nicklamont)) ([#48436](https://github.com/expo/expo/pull/48436) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway))
- [iOS] Fix the `community/datetime-picker` field collapsing to zero width — invisible and untappable — when its parent doesn't stretch it (e.g. `alignItems: 'center'`). ([#47033](https://github.com/expo/expo/pull/47033) by [@nishan](https://github.com/intergalacticspacehighway))
- [iOS] Fixed viewport size measurement reading the main screen instead of the scene the view is in. ([#48170](https://github.com/expo/expo/pull/48170) by [@alanjhughes](https://github.com/alanjhughes))
diff --git a/packages/expo-ui/ios/SliderView.swift b/packages/expo-ui/ios/SliderView.swift
index 6383933625b4c0..f5586ca4fc880e 100644
--- a/packages/expo-ui/ios/SliderView.swift
+++ b/packages/expo-ui/ios/SliderView.swift
@@ -6,8 +6,8 @@ import ExpoModulesCore
struct SliderView: ExpoSwiftUI.View {
@ObservedObject var props: SliderProps
@State var value: Float = 0.0
- @State var isEditing: Bool = false
-
+ @State private var eventCount: Int = 0
+
init(props: SliderProps) {
self.props = props
}
@@ -19,13 +19,19 @@ struct SliderView: ExpoSwiftUI.View {
value = clamp(props.value ?? 0.0)
}
.onChange(of: props.value) { newValue in
- guard !isEditing else { return }
+ // A prop JS produced before our newest change is a stale echo of a drag that has
+ // since moved on, so applying it would pull the thumb back from under the finger.
+ if let seenCount = props.mostRecentEventCount, seenCount < eventCount {
+ return
+ }
value = clamp(newValue ?? 0.0)
}
.onChange(of: value) { newValue in
if props.value != newValue {
+ eventCount += 1
props.onValueChanged([
- "value": newValue
+ "value": newValue,
+ "eventCount": eventCount
])
}
}
@@ -56,7 +62,6 @@ struct SliderView: ExpoSwiftUI.View {
let hasAnyLabel = label != nil || minimumValueLabel != nil || maximumValueLabel != nil
let handleEditingChanged: (Bool) -> Void = { isEditing in
- self.isEditing = isEditing
props.onEditingChanged(["isEditing": isEditing])
}
@@ -142,6 +147,7 @@ final class SliderProps: UIBaseViewProps {
@Field var max: Float?
@Field var lowerLimit: Float?
@Field var upperLimit: Float?
+ @Field var mostRecentEventCount: Int?
var onValueChanged = EventDispatcher()
var onEditingChanged = EventDispatcher()
}
diff --git a/packages/expo-ui/ios/Toggle/ToggleView.swift b/packages/expo-ui/ios/Toggle/ToggleView.swift
index b5e1f78dcb2fc7..ac10b9ac20fb16 100644
--- a/packages/expo-ui/ios/Toggle/ToggleView.swift
+++ b/packages/expo-ui/ios/Toggle/ToggleView.swift
@@ -12,38 +12,36 @@ internal final class ToggleProps: UIBaseViewProps {
internal struct ToggleView: ExpoSwiftUI.View {
@ObservedObject var props: ToggleProps
- @State var checked: Bool = false
+ // Only used when `isOn` isn't passed. While the prop is set it is the sole source of
+ // truth, so JS stays in control of what the toggle shows and can refuse a change.
+ @State private var uncontrolled = false
init(props: ToggleProps) {
self.props = props
}
var body: some View {
- makeToggle()
- .onChange(of: checked) { newValue in
- if props.isOn == newValue {
- return
+ makeToggle(isOn: Binding(
+ get: { props.isOn ?? uncontrolled },
+ set: { newValue in
+ uncontrolled = newValue
+ if props.isOn != newValue {
+ props.onIsOnChange([
+ "isOn": newValue
+ ])
}
- props.onIsOnChange([
- "isOn": newValue
- ])
- }
- .onChange(of: props.isOn) { newValue in
- checked = newValue ?? false
- }
- .onAppear {
- checked = props.isOn ?? false
}
+ ))
}
@ViewBuilder
- private func makeToggle() -> some View {
+ private func makeToggle(isOn: Binding) -> some View {
if let systemImage = props.systemImage, let label = props.label {
- Toggle(label, systemImage: systemImage, isOn: $checked)
+ Toggle(label, systemImage: systemImage, isOn: isOn)
} else if let label = props.label {
- Toggle(label, isOn: $checked)
+ Toggle(label, isOn: isOn)
} else {
- Toggle(isOn: $checked) { Children() }
+ Toggle(isOn: isOn) { Children() }
}
}
}
diff --git a/packages/expo-ui/src/swift-ui/Slider/index.tsx b/packages/expo-ui/src/swift-ui/Slider/index.tsx
index 50bef889a8e845..043c2f5537188a 100644
--- a/packages/expo-ui/src/swift-ui/Slider/index.tsx
+++ b/packages/expo-ui/src/swift-ui/Slider/index.tsx
@@ -1,4 +1,5 @@
import { requireNativeView } from 'expo';
+import { useRef } from 'react';
import type { NativeSyntheticEvent } from 'react-native';
import { Slot } from '../SlotView';
@@ -57,7 +58,8 @@ type NativeSliderProps = Omit<
SliderProps,
'onValueChange' | 'onEditingChanged' | 'label' | 'minimumValueLabel' | 'maximumValueLabel'
> & {
- onValueChanged?: (event: NativeSyntheticEvent<{ value: number }>) => void;
+ mostRecentEventCount?: number;
+ onValueChanged?: (event: NativeSyntheticEvent<{ value: number; eventCount: number }>) => void;
onEditingChanged?: (event: NativeSyntheticEvent<{ isEditing: boolean }>) => void;
children?: React.ReactNode;
};
@@ -67,7 +69,10 @@ const SliderNativeView: React.ComponentType = requireNativeVi
'SliderView'
);
-function transformSliderProps(props: SliderProps): NativeSliderProps {
+function transformSliderProps(
+ props: SliderProps,
+ eventCount: { current: number }
+): NativeSliderProps {
const {
label,
minimumValueLabel,
@@ -78,11 +83,11 @@ function transformSliderProps(props: SliderProps): NativeSliderProps {
} = props;
return {
...restProps,
- onValueChanged: onValueChange
- ? ({ nativeEvent: { value } }) => {
- onValueChange(value);
- }
- : undefined,
+ mostRecentEventCount: eventCount.current,
+ onValueChanged: ({ nativeEvent: { value, eventCount: nativeEventCount } }) => {
+ eventCount.current = nativeEventCount;
+ onValueChange?.(value);
+ },
onEditingChanged: onEditingChanged
? ({ nativeEvent: { isEditing } }) => {
onEditingChanged(isEditing);
@@ -93,9 +98,10 @@ function transformSliderProps(props: SliderProps): NativeSliderProps {
export function Slider(props: SliderProps) {
const { label, minimumValueLabel, maximumValueLabel } = props;
+ const eventCount = useRef(0);
return (
-
+
{label && {label}}
{minimumValueLabel && {minimumValueLabel}}
{maximumValueLabel && {maximumValueLabel}}