diff --git a/apps/native-component-list/src/screens/UI/TextFieldScreen.android.tsx b/apps/native-component-list/src/screens/UI/TextFieldScreen.android.tsx index 1233e9858d94f9..d846382391c81c 100644 --- a/apps/native-component-list/src/screens/UI/TextFieldScreen.android.tsx +++ b/apps/native-component-list/src/screens/UI/TextFieldScreen.android.tsx @@ -4,7 +4,6 @@ import { TextFieldKeyboardType, TextFieldImeAction, TextFieldCapitalization, - TextFieldValue, OutlinedTextField, Button, Host, @@ -28,10 +27,8 @@ export default function TextFieldScreen() { const [lastAction, setLastAction] = React.useState(''); const textRef = React.useRef(null); - const maskedPhone = useNativeState({ - text: '', - selection: { start: 0, end: 0 }, - }); + const maskedPhoneText = useNativeState(''); + const maskedPhoneSelection = useNativeState({ start: 0, end: 0 }); const imperativeText = useNativeState('Select me!'); const imperativeSelection = useNativeState<{ start: number; end: number }>({ start: 0, end: 0 }); @@ -151,12 +148,13 @@ export default function TextFieldScreen() { Worklet Phone Masking { 'worklet'; - const digits = v.text.replace(/\D/g, '').slice(0, 10); + const digits = v.replace(/\D/g, '').slice(0, 10); let formatted: string; if (digits.length === 0) { formatted = ''; @@ -167,11 +165,9 @@ export default function TextFieldScreen() { } else { formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; } - if (formatted !== v.text) { - maskedPhone.value = { - text: formatted, - selection: { start: formatted.length, end: formatted.length }, - }; + if (formatted !== v) { + maskedPhoneText.value = formatted; + maskedPhoneSelection.value = { start: formatted.length, end: formatted.length }; } }}> diff --git a/apps/native-component-list/src/screens/UI/TextFieldScreen.ios.tsx b/apps/native-component-list/src/screens/UI/TextFieldScreen.ios.tsx index bf5e313e4252cd..4c99d13d3acf9b 100644 --- a/apps/native-component-list/src/screens/UI/TextFieldScreen.ios.tsx +++ b/apps/native-component-list/src/screens/UI/TextFieldScreen.ios.tsx @@ -26,7 +26,6 @@ import { foregroundStyle, } from '@expo/ui/swift-ui/modifiers'; import * as React from 'react'; -import { runOnJS } from 'react-native-worklets'; export default function TextFieldScreen() { const textRef = React.useRef(null); @@ -41,13 +40,6 @@ export default function TextFieldScreen() { const maskedPhone = useNativeState(''); const phoneSelection = useNativeState({ start: 0, end: 0 }); - const setPhoneCursor = React.useCallback( - (position: number) => { - phoneSelection.value = { start: position, end: position }; - }, - [phoneSelection] - ); - const submitLabelOptions = [ 'continue', 'done', @@ -128,8 +120,7 @@ export default function TextFieldScreen() { } if (formatted !== v) { maskedPhone.value = formatted; - // To keep selection at the end of the input while typing - runOnJS(setPhoneCursor)(formatted.length); + phoneSelection.value = { start: formatted.length, end: formatted.length }; } }} /> diff --git a/docs/.vale/writing-styles/expo-docs/EmDash.yml b/docs/.vale/writing-styles/expo-docs/EmDash.yml index 68e56e19a1806d..3877972d09dca3 100644 --- a/docs/.vale/writing-styles/expo-docs/EmDash.yml +++ b/docs/.vale/writing-styles/expo-docs/EmDash.yml @@ -1,9 +1,11 @@ extends: substitution -message: "Instead of '-' or '--', use '—'. Markdown renders this nicely." +message: "Use '%s' instead. Markdown renders this nicely." link: 'https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md#use-mdash' level: suggestion scope: raw -# Look for ' - ' (space, hyphen, space) or ' -- ' (space, two hyphens, space) +nonword: true +# Look for ' - ' (space, hyphen, space), ' -- ' (space, two hyphens, space), or '—' (Unicode em-dash, U+2014) swap: ' - ': ' — ' ' -- ': ' — ' + '—': '—' diff --git a/docs/pages/eas/observe/reference/metrics.mdx b/docs/pages/eas/observe/reference/metrics.mdx index f854cd9bf9847f..41034c4dca1a1e 100644 --- a/docs/pages/eas/observe/reference/metrics.mdx +++ b/docs/pages/eas/observe/reference/metrics.mdx @@ -126,6 +126,12 @@ Each TTI event includes extra params to help triage issues: - **`expo.frameRate.slowFrames`** (count): Frames that took 17ms or longer to render. If this is high relative to the startup duration, the main thread was consistently busy during launch. Points to heavy layout work, synchronous bridge calls, or too many components rendering at once. - **`expo.frameRate.frozenFrames`** (count): Frames that took 700ms or longer to render. These are hard freezes where the app visibly hung. Even one during startup is a serious issue. Usually caused by synchronous I/O, large JSON parsing, or blocking network calls on the main thread. - **`expo.frameRate.totalDelay`** (seconds): Total accumulated time all frames exceeded their target duration. This is the single best "smoothness" number. Compare it to TTI: if TTI is 2.5s and `totalDelay` is 0.1s, startup was slow but smooth (the time was spent on legitimate work). If `totalDelay` is 1.5s, the app was janky for most of the startup, and the user was staring at a stuttering screen. +- **`expo.device.lowPowerMode`** (boolean): Whether the OS power-saver mode (Low Power Mode on iOS, Battery Saver on Android) was active when TTI was reported. Power-saver mode throttles CPU, GPU, and background activity, so a TTI regression that disappears once this flag is filtered out is environmental rather than a code change. +- **`expo.device.batteryLevel`** (number, 0–1): Fractional battery charge at TTI. Useful for ruling out thermal/throttling effects on devices that aggressively manage performance at low charge. Omitted when the OS does not report a value. +- **`expo.device.batteryCharging`** (boolean): Whether the device was plugged in or wirelessly charging. Charging tends to raise sustained CPU performance ceilings on iOS and some Android OEMs, so non-charging samples are the more conservative population to compare against. +- **`expo.device.thermalState`** (string): One of `nominal`, `fair`, `serious`, `critical`, `unknown`. Sustained `serious`/`critical` states cause the OS to throttle the CPU/GPU and can dramatically slow startup independent of any app change. +- **`expo.network.connected`** (boolean): Whether the device had an internet-capable network at TTI. If TTI degrades only when this is `true`, the cause is likely a network-bound startup path; if it degrades when `false`, the app is doing more work than it should before showing cached content. +- **`expo.network.type`** (string): One of `wifi`, `cellular`, `ethernet`, `none`, `other`, `unknown`. Use to compare cellular versus Wi-Fi populations — large gaps usually point to network-bound startup work. VPN traffic is reported as the underlying transport (typically `wifi` or `cellular`) since the VPN tunnels over it. The value set is intentionally the same on Android and iOS so dashboards don't need per-platform branching. **How to interpret them:** diff --git a/docs/pages/eas/workflows/pre-packaged-jobs.mdx b/docs/pages/eas/workflows/pre-packaged-jobs.mdx index cb38f1c3919a5f..21846482a4b707 100644 --- a/docs/pages/eas/workflows/pre-packaged-jobs.mdx +++ b/docs/pages/eas/workflows/pre-packaged-jobs.mdx @@ -1012,11 +1012,10 @@ jobs: include_tags: string | string[] # optional - tags to include in the tests. Will be passed to Maestro as `--include-tags`. exclude_tags: string | string[] # optional - tags to exclude from the tests. Will be passed to Maestro as `--exclude-tags`. maestro_version: string # optional - version of Maestro to use for the tests. If not provided, the latest version will be used. - android_api_level: string # optional - Android API level to use for the tests. Will be passed to Maestro as `--android-api-level`. maestro_config: string # optional - path to the Maestro `config.yaml` file to use for the tests. Will be passed to Maestro as `--config`. - device_locale: string # optional - device locale to use for the tests. Will be passed to Maestro as `--device-locale`. Run `maestro cloud --help` for a list of supported values. - device_model: string # optional - model of the device to use for the tests. Will be passed to Maestro as `--device-model`. Run `maestro cloud --help` for a list of supported values. - device_os: string # optional - OS of the device to use for the tests. Will be passed to Maestro as `--device-os`. Run `maestro cloud --help` for a list of supported values. + device_locale: string # optional - device locale to use for the tests. Will be passed to Maestro as `--device-locale`. + device_model: string # optional - model of the device to use for the tests. Will be passed to Maestro as `--device-model`. Run `maestro list-cloud-devices` for a list of supported values. + device_os: string # optional - OS of the device to use for the tests. Will be passed to Maestro as `--device-os`. Run `maestro list-cloud-devices` for a list of supported values. name: string # optional - name for the Maestro Cloud upload. Corresponds to `--name` param to `maestro cloud`. branch: string # optional - override for the branch the Maestro Cloud upload originated from. By default, if the workflow run has been triggered from GitHub, the branch of the workflow run will be used. Corresponds to `--branch` param to `maestro cloud`. async: boolean # optional - run the Maestro Cloud tests asynchronously. If true, the status of the job will only denote whether the upload was successful, _not_ whether the tests succeeded. Corresponds to `--async` param to `maestro cloud`. @@ -1035,11 +1034,10 @@ You can pass the following parameters into the `params` list: | include_tags | string | Optional. The tags to include in the tests. Corresponds to `--include-tags` param to `maestro cloud`. Example: `"pull,push"`. | | exclude_tags | string | Optional. The tags to exclude from the tests. Corresponds to `--exclude-tags` param to `maestro cloud`. Example: `"disabled"`. | | maestro_version | string | Optional. The version of Maestro to use. Example: `1.30.0`. | -| android_api_level | string | Optional. The Android API level to use. Corresponds to `--android-api-level` param to `maestro cloud`. Example: `29`. | | maestro_config | string | Optional. The path to the Maestro `config.yaml` file to use. Corresponds to `--config` param to `maestro cloud`. Example: `.maestro/config.yaml`. | | device_locale | string | Optional. The locale that will be set on devices used for the tests. Corresponds to `--device-locale` param to `maestro cloud`. Example: `pl_PL`. | -| device_model | string | Optional. The model of the device to use for the tests. Corresponds to `--device-model` param to `maestro cloud`. Example: `iPhone-11`. Run `maestro cloud --help` for a list of supported values. | -| device_os | string | Optional. The OS of the device to use for the tests. Corresponds to `--device-os` param to `maestro cloud`. Example: `iOS-18-2`. Run `maestro cloud --help` for a list of supported values. | +| device_model | string | Optional. The model of the device to use for the tests. Corresponds to `--device-model` param to `maestro cloud`. Example: `iPhone-11`. Run `maestro list-cloud-devices` for a list of supported values. | +| device_os | string | Optional. The OS of the device to use for the tests. Corresponds to `--device-os` param to `maestro cloud`. Example: `iOS-18-2`. Run `maestro list-cloud-devices` for a list of supported values. | | name | string | Optional. Name for the Maestro Cloud upload. Corresponds to `--name` param to `maestro cloud`. | | branch | string | Optional. Override for the branch the Maestro Cloud upload originated from. By default, if the workflow run has been triggered from GitHub, the branch of the workflow run will be used. Corresponds to `--branch` param to `maestro cloud`. | | async | boolean | Optional. Run the Maestro Cloud tests asynchronously. If true, the status of the job will only denote whether the upload was successful, _not_ whether the tests succeeded. Corresponds to `--async` param to `maestro cloud`. | diff --git a/docs/pages/eas/workflows/syntax.mdx b/docs/pages/eas/workflows/syntax.mdx index 9b25058ce5f5b6..93f605358e66f5 100644 --- a/docs/pages/eas/workflows/syntax.mdx +++ b/docs/pages/eas/workflows/syntax.mdx @@ -1271,11 +1271,10 @@ jobs: include_tags: string | string[] # optional. Tags to include in the tests. Will be passed to Maestro as `--include-tags`. exclude_tags: string | string[] # optional. Tags to exclude from the tests. Will be passed to Maestro as `--exclude-tags`. maestro_version: string # optional. Version of Maestro to use for the tests. If not provided, the latest version will be used. - android_api_level: string # optional. Android API level to use for the tests. Will be passed to Maestro as `--android-api-level`. maestro_config: string # optional. Path to the Maestro `config.yaml` file to use for the tests. Will be passed to Maestro as `--config`. - device_locale: string # optional. Device locale to use for the tests. Will be passed to Maestro as `--device-locale`. Run `maestro cloud --help` for a list of supported values. - device_model: string # optional. Model of the device to use for the tests. Will be passed to Maestro as `--device-model`. Run `maestro cloud --help` for a list of supported values. - device_os: string # optional. OS of the device to use for the tests. Will be passed to Maestro as `--device-os`. Run `maestro cloud --help` for a list of supported values. + device_locale: string # optional. Device locale to use for the tests. Will be passed to Maestro as `--device-locale`. + device_model: string # optional. Model of the device to use for the tests. Will be passed to Maestro as `--device-model`. Run `maestro list-cloud-devices` for a list of supported values. + device_os: string # optional. OS of the device to use for the tests. Will be passed to Maestro as `--device-os`. Run `maestro list-cloud-devices` for a list of supported values. name: string # optional. Name for the Maestro Cloud upload. Corresponds to `--name` param to `maestro cloud`. branch: string # optional. Override for the branch the Maestro Cloud upload originated from. By default, if the workflow run has been triggered from GitHub, the branch of the workflow run will be used. Corresponds to `--branch` param to `maestro cloud`. async: boolean # optional. Run the Maestro Cloud tests asynchronously. If true, the status of the job will only denote whether the upload was successful, *not* whether the tests succeeded. Corresponds to `--async` param to `maestro cloud`. diff --git a/docs/pages/tutorial/create-a-modal.mdx b/docs/pages/tutorial/create-a-modal.mdx index 15e1715882684f..7b49e65ac677ae 100644 --- a/docs/pages/tutorial/create-a-modal.mdx +++ b/docs/pages/tutorial/create-a-modal.mdx @@ -346,17 +346,17 @@ type Props = PropsWithChildren<{ export default function EmojiPicker({ isVisible, children, onClose }: Props) { return ( - - - - Choose a sticker - - - + + + + Choose a sticker + + + + + {children} - {children} - - + ); } diff --git a/docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx b/docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx new file mode 100644 index 00000000000000..1c92e125fac4b5 --- /dev/null +++ b/docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx @@ -0,0 +1,168 @@ +--- +title: BottomSheet +description: A bottom sheet compatible with @gorhom/bottom-sheet. +sourceCodeUrl: 'https://github.com/expo/expo/tree/main/packages/expo-ui' +packageName: '@expo/ui' +platforms: ['android', 'ios', 'web', 'expo-go'] +--- + +import APISection from '~/components/plugins/APISection'; +import { APIInstallSection } from '~/components/plugins/InstallSection'; + +A `BottomSheet` component with an API compatible with `@gorhom/bottom-sheet`. It wraps the platform-specific `@expo/ui` primitives: [Jetpack Compose ModalBottomSheet](../jetpack-compose/bottomsheet) on Android and [SwiftUI BottomSheet](../swift-ui/bottomsheet) on iOS. On web, it uses a [vaul](https://github.com/emilkowalski/vaul) drawer. + +If you need lower-level control over platform-specific styling, modifiers, or layout behavior, use the native primitives directly. + +## Installation + + + +## Migrating from `@gorhom/bottom-sheet` + +- Update imports from: + + ```tsx + import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'; + ``` + + To use `@expo/ui/community/bottom-sheet`: + + ```tsx + import BottomSheet, { BottomSheetView } from '@expo/ui/community/bottom-sheet'; + ``` + +- `GestureHandlerRootView` from `react-native-gesture-handler` is not required by this implementation. You can leave it in place if other parts of your app need it. +- Component and hook exports such as `BottomSheetBackdrop`, `BottomSheetHandle`, `BottomSheetFooter`, `BottomSheetDraggableView`, `BottomSheetVirtualizedList`, `BottomSheetFlashList`, `useBottomSheetModal`, `useBottomSheetSpringConfigs`, and `useBottomSheetTimingConfigs` are not supported. Some related prop types are exported for API compatibility. + +## Basic usage + +```tsx BottomSheetExample.tsx +import { useRef } from 'react'; +import { Button, Text, View } from 'react-native'; +import BottomSheet, { BottomSheetView } from '@expo/ui/community/bottom-sheet'; + +export default function BottomSheetExample() { + const sheetRef = useRef(null); + + return ( + + + + + + @@ -235,6 +243,50 @@ export default function ImperativeRefExample() { } ``` +### Worklet text masking + +When `onValueChange` is marked with the `'worklet'` directive, it runs synchronously on the UI thread, so writes to [`useNativeState`](usenativestate) observables inside the callback take effect before the next frame. There is no flicker between the typed text and the masked text. The example below masks a phone number as the user types and writes both `value` and `selection` from the worklet to keep the cursor at the end of the formatted value. + +> **Note:** Worklets require installing [`react-native-reanimated`](https://docs.swmansion.com/react-native-reanimated/) and [`react-native-worklets`](https://docs.swmansion.com/react-native-worklets/). + +```tsx WorkletPhoneMaskExample.tsx +import { Host, TextField, Text, useNativeState } from '@expo/ui/jetpack-compose'; +import { fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; + +export default function WorkletPhoneMaskExample() { + const phone = useNativeState(''); + const selection = useNativeState({ start: 0, end: 0 }); + + return ( + + { + 'worklet'; + const digits = v.replace(/\D/g, '').slice(0, 10); + let formatted = digits; + if (digits.length > 6) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } else if (digits.length > 3) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } + if (formatted !== v) { + phone.value = formatted; + selection.value = { start: formatted.length, end: formatted.length }; + } + }}> + + (555) 123-4567 + + + + ); +} +``` + ## API ```tsx diff --git a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx index 2a83c9c54159ce..70767a69360fc0 100644 --- a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx @@ -19,33 +19,26 @@ import { APIInstallSection } from '~/components/plugins/InstallSection'; > **Note:** Using worklets requires installing [`react-native-reanimated`](https://docs.swmansion.com/react-native-reanimated/) and [`react-native-worklets`](https://docs.swmansion.com/react-native-worklets/) in your project. `useNativeState` itself works without them, but the synchronous UI-thread updates shown below depend on the worklet runtime. -The example below masks a phone number as the user types. The formatting and the write to `maskedPhone.value` both happen synchronously on the UI thread, so there is no flicker between the typed value and the masked value. +The example below masks a phone number as the user types. The formatting and the writes to `maskedPhone.value` (text) and `selection.value` (cursor position) all happen synchronously on the UI thread, so there is no flicker between the typed value and the masked value. ```tsx WorkletPhoneMaskExample.tsx -import { - Host, - TextField, - TextFieldValue, - Text as ComposeText, - useNativeState, -} from '@expo/ui/jetpack-compose'; +import { Host, TextField, Text as ComposeText, useNativeState } from '@expo/ui/jetpack-compose'; import { fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; export default function WorkletPhoneMaskExample() { - const maskedPhone = useNativeState({ - text: '', - selection: { start: 0, end: 0 }, - }); + const maskedPhone = useNativeState(''); + const selection = useNativeState({ start: 0, end: 0 }); return ( { 'worklet'; - const digits = v.text.replace(/\D/g, '').slice(0, 10); + const digits = v.replace(/\D/g, '').slice(0, 10); let formatted: string; if (digits.length === 0) { formatted = ''; @@ -56,11 +49,9 @@ export default function WorkletPhoneMaskExample() { } else { formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; } - if (formatted !== v.text) { - maskedPhone.value = { - text: formatted, - selection: { start: formatted.length, end: formatted.length }, - }; + if (formatted !== v) { + maskedPhone.value = formatted; + selection.value = { start: formatted.length, end: formatted.length }; } }}> diff --git a/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx b/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx index 0093d744ccc3cb..c7e4f635870c53 100644 --- a/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx @@ -28,7 +28,7 @@ export default function BasicTextFieldExample() { return ( - + ); } @@ -51,7 +51,7 @@ export default function MultilineTextFieldExample() { @@ -75,7 +75,7 @@ export default function KeyboardTypeExample() { @@ -99,7 +99,7 @@ export default function SubmitHandlingExample() { console.log('Submitted:', value))]} /> @@ -111,18 +111,29 @@ export default function SubmitHandlingExample() { Use a `ref` to imperatively set text, focus, blur, or select text. +> **Note:** `setSelection` requires iOS 18.0+ / tvOS 18.0+. The other ref methods work on all supported versions. + ```tsx ImperativeRefExample.tsx import { useRef } from 'react'; -import { Host, TextField, TextFieldRef, Button, HStack, VStack } from '@expo/ui/swift-ui'; +import { + Host, + TextField, + TextFieldRef, + Button, + HStack, + VStack, + useNativeState, +} from '@expo/ui/swift-ui'; import { buttonStyle } from '@expo/ui/swift-ui/modifiers'; export default function ImperativeRefExample() { const ref = useRef(null); + const text = useNativeState('Select me!'); return ( - +