Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions apps/test-suite/tests/FileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
4 changes: 3 additions & 1 deletion docs/pages/develop/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,12 @@ Once you have a working authentication system in place, you can improve the user

<Collapsible summary="Biometrics">

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/).

</Collapsible>

<Collapsible summary="Passkeys">
Expand Down
12 changes: 9 additions & 3 deletions docs/ui/components/ExpoSkillsTable/data/expo-skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
{
Expand Down
1 change: 1 addition & 0 deletions packages/expo-file-system/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,9 @@ class FileSystemModule : Module() {

Property("size") { file ->
try {
file.size
file.size ?: 0
} catch (e: Exception) {
null
0
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/expo-file-system/ios/FileSystemModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ public final class FileSystemModule: Module {
}

Property("size") { file in
try? file.size
(try? file.size) ?? 0
}

Property("md5") { file in
Expand Down
3 changes: 3 additions & 0 deletions packages/expo-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
17 changes: 3 additions & 14 deletions packages/expo-router/src/fork/NavigationContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type Props<ParamList extends object> = 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.
Expand All @@ -75,8 +75,6 @@ function NavigationContainerInner(
}: Props<ParamListBase>,
ref?: React.Ref<NavigationContainerRef<ParamListBase> | null>
) {
const isLinkingEnabled = linking ? linking.enabled !== false : false;

if (linking?.config) {
validatePathConfig(linking.config);
}
Expand All @@ -89,15 +87,7 @@ function NavigationContainerInner(

const [lastUnhandledLink, setLastUnhandledLink] = React.useState<string | undefined>();

const { getInitialState } = useLinking(
refContainer,
{
enabled: isLinkingEnabled,
prefixes: [],
...linking,
},
setLastUnhandledLink
);
const { getInitialState } = useLinking(refContainer, linking, setLastUnhandledLink);

const linkingContext = React.useMemo(() => ({ options: linking }), [linking]);

Expand Down Expand Up @@ -139,7 +129,6 @@ function NavigationContainerInner(
get linking() {
return {
...linking,
enabled: isLinkingEnabled,
prefixes: linking?.prefixes ?? [],
getStateFromPath: linking?.getStateFromPath ?? getStateFromPath,
getPathFromState: linking?.getPathFromState ?? getPathFromState,
Expand All @@ -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
Expand Down
31 changes: 16 additions & 15 deletions packages/expo-router/src/fork/useLinking.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ const linkingHandlers: symbol[] = [];

export function useLinking(
ref: RefObject<NavigationContainerRef<ParamListBase>>,
{
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
Expand All @@ -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(() => {
Expand All @@ -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:',
Expand All @@ -76,7 +77,7 @@ export function useLinking(

const handler = Symbol();

if (enabled !== false) {
if (enabled) {
linkingHandlers.push(handler);
}

Expand Down
17 changes: 8 additions & 9 deletions packages/expo-router/src/fork/useLinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,14 @@ type Options = LinkingOptions<ParamListBase>;

export function useLinking(
ref: RefObject<NavigationContainerRef<ParamListBase> | 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();
Expand All @@ -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:',
Expand All @@ -114,7 +113,7 @@ export function useLinking(

const handler = Symbol();

if (enabled !== false) {
if (enabled) {
linkingHandlers.push(handler);
}

Expand Down
83 changes: 82 additions & 1 deletion packages/expo-router/src/link/__tests__/Link.test.web.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof linkTo>;

beforeEach(() => {
mockedLinkTo.mockClear();
});

it('renders a Link', () => {
const { getByTestId } = render(
<Link testID="link" href="/foo">
Expand Down Expand Up @@ -203,6 +220,70 @@ describe('base url relative links', () => {
});
});

describe('web click navigation', () => {
it('intercepts unmodified same-tab clicks', () => {
const { getByTestId } = render(
<Link testID="link" href="/foo">
Foo
</Link>
);

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(
<Link testID="link" href="/foo">
Foo
</Link>
);

fireEvent.click(getByTestId('link'), event);

expect(mockedLinkTo).not.toHaveBeenCalled();
});

it('does not intercept links with a target', () => {
const { getByTestId } = render(
<Link testID="link" href="/foo" target="_blank">
Foo
</Link>
);

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(
<Link testID="link" href={href}>
Foo
</Link>
);

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(
Expand Down
1 change: 0 additions & 1 deletion packages/expo-router/src/link/preview/HrefPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ const navigationPropWithWarnings: NavigationProp<ParamListBase> = {
push: createNOOPWithWarning('push'),
pop: createNOOPWithWarning('pop'),
popToTop: createNOOPWithWarning('popToTop'),
navigateDeprecated: createNOOPWithWarning('navigateDeprecated'),
preload: createNOOPWithWarning('preload'),
getId: () => {
displayWarningForProp('getId');
Expand Down
2 changes: 2 additions & 0 deletions packages/expo-router/src/link/useLinkToPathProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type UseLinkToPathPropsOptions = LinkToOptions & {

export default function useLinkToPathProps({ href, ...options }: UseLinkToPathPropsOptions) {
const onPress = (event?: MouseEvent<HTMLAnchorElement> | 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;
Expand Down
Loading
Loading