From c4e31dfb81c840cbeb3069aeafe61f4f702f297a Mon Sep 17 00:00:00 2001 From: Jakub Grzywacz Date: Wed, 6 May 2026 12:00:21 +0200 Subject: [PATCH 01/20] [widgets] Add widget RedBox (#45402) # Why It's hard to debug widgets without access to native logs from different target. # How In debug, create a RedBox view with the error message gathered from JSC execution ## Widgets image ## Live Activities image # Test Plan Make invalid widget # Checklist - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-widgets/CHANGELOG.md | 1 + .../expo-widgets/ios/Widgets/AppIntent.swift | 22 +++++ .../ios/Widgets/DynamicView.swift | 10 +++ .../expo-widgets/ios/Widgets/RedBoxView.swift | 83 +++++++++++++++++++ packages/expo-widgets/ios/Widgets/Utils.swift | 18 ++++ .../ios/Widgets/WidgetContext.swift | 6 -- 6 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 packages/expo-widgets/ios/Widgets/RedBoxView.swift diff --git a/packages/expo-widgets/CHANGELOG.md b/packages/expo-widgets/CHANGELOG.md index 8117b3d7a987d4..b781a056447c9f 100644 --- a/packages/expo-widgets/CHANGELOG.md +++ b/packages/expo-widgets/CHANGELOG.md @@ -27,6 +27,7 @@ _This version does not introduce any user-facing changes._ - Support `PlatformColor`. ([#44193](https://github.com/expo/expo/pull/44193) by [@jakex7](https://github.com/jakex7)) - [plugin] Make incremental prebuilds work ([#45157](https://github.com/expo/expo/pull/45157) by [@jakex7](https://github.com/jakex7)) - [android] Add stub methods. ([#45335](https://github.com/expo/expo/pull/45335) by [@jakex7](https://github.com/jakex7)) +- Add widget RedBox. ([#45402](https://github.com/expo/expo/pull/45402) by [@jakex7](https://github.com/jakex7)) ### 🐛 Bug fixes diff --git a/packages/expo-widgets/ios/Widgets/AppIntent.swift b/packages/expo-widgets/ios/Widgets/AppIntent.swift index 2c45354c91f933..09aa9d82e6d5f5 100644 --- a/packages/expo-widgets/ios/Widgets/AppIntent.swift +++ b/packages/expo-widgets/ios/Widgets/AppIntent.swift @@ -1,6 +1,28 @@ import AppIntents import WidgetKit +struct WidgetReload: AppIntent { + // title is not used for non-discoverable intents, but it is required + static var title: LocalizedStringResource = "Reload widget" + static var isDiscoverable: Bool = false + @Parameter(title: "source") + var source: String? + + init() {} + init(source: String?) { + self.source = source + } + + func perform() async throws -> some IntentResult { + guard let source else { + return .result() + } + + WidgetCenter.shared.reloadTimelines(ofKind: source) + return .result() + } +} + @available(iOS 16.0, *) struct WidgetUserInteraction: AppIntent { // title is not used for non-discoverable intents, but it is required diff --git a/packages/expo-widgets/ios/Widgets/DynamicView.swift b/packages/expo-widgets/ios/Widgets/DynamicView.swift index 08d99457e9534a..75a5e5af75ac3f 100644 --- a/packages/expo-widgets/ios/Widgets/DynamicView.swift +++ b/packages/expo-widgets/ios/Widgets/DynamicView.swift @@ -104,11 +104,21 @@ public struct WidgetsDynamicView: View, ExpoSwiftUI.AnyChild { render(FragmentView.self, FragmentProps.self, updateProps: updateChildren) case "LinkView": render(LinkView.self, LinkViewProps.self, updateProps: updateChildren) +#if DEBUG + case "RedBoxView": + render(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in + redBoxProps.source = name + redBoxProps.kind = kind + } default: ZStack { Color.red.opacity(0.5) Text("Unable to get the view for: \(node["type"] as? String ?? "undefined")") } +#else + default: + EmptyView() +#endif } } diff --git a/packages/expo-widgets/ios/Widgets/RedBoxView.swift b/packages/expo-widgets/ios/Widgets/RedBoxView.swift new file mode 100644 index 00000000000000..b0f76a5e70d016 --- /dev/null +++ b/packages/expo-widgets/ios/Widgets/RedBoxView.swift @@ -0,0 +1,83 @@ +import ExpoModulesCore +import SwiftUI +import ExpoUI + +public final class RedBoxViewProps: UIBaseViewProps { + @Field var message: String + @Field var source: String? + @Field var stack: String? + var kind: WidgetsKind = .widget +} + +public struct RedBoxView: ExpoSwiftUI.View { + @ObservedObject public var props: RedBoxViewProps + + public init(props: RedBoxViewProps) { + self.props = props + } + + public var body: some View { + FullSizeZStack(kind: props.kind) { + Color.red + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + Text("Error").font(.headline) + Spacer() + if #available(iOS 17.0, *) { + Button(intent: WidgetReload(source: props.source)) { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .padding(4) + } + } + .foregroundStyle(.white) + + Text(props.message) + .font(.system(size: 14).bold().monospaced()) + .foregroundStyle(.white.opacity(0.9)) + .modifier(RedBoxBody()) + + if let stack = props.stack, !stack.isEmpty { + Text(stack) + .font(.system(size: 11).monospaced()) + .foregroundStyle(.white.opacity(0.75)) + .modifier(RedBoxBody()) + } + } + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +private struct RedBoxBody: ViewModifier { + @ViewBuilder + func body(content: Content) -> some View { + let base = content.lineLimit(nil).fixedSize(horizontal: false, vertical: true) + if #available(iOS 26.0, *) { + base.lineHeight(.tight) + } else { + base + } + } +} + +public struct FullSizeZStack: View { + let kind: WidgetsKind + let content: Content + + init(kind: WidgetsKind = .widget, @ViewBuilder _ content: () -> Content) { + self.kind = kind + self.content = content() + } + + public var body: some View { + if #available(iOS 17.0, *), kind == .widget { + ZStack { content }.containerRelativeFrame([.horizontal, .vertical]) + } else { + ZStack { content }.frame(maxWidth: .infinity) + } + } +} diff --git a/packages/expo-widgets/ios/Widgets/Utils.swift b/packages/expo-widgets/ios/Widgets/Utils.swift index 5d76d1a49deff8..0eb725d5e21a76 100644 --- a/packages/expo-widgets/ios/Widgets/Utils.swift +++ b/packages/expo-widgets/ios/Widgets/Utils.swift @@ -20,6 +20,14 @@ func parseTimeline(identifier: String, name: String, family: WidgetFamily) -> [W return entries.compactMap(\.self) } +func createRedBox(message: String, stack: String? = nil) -> [String: Any] { + var props: [String: Any] = ["message": message] + if let stack { + props["stack"] = stack + } + return ["type": "RedBoxView", "props": props] +} + func evaluateLayout( layout: String, props: [String: Any], @@ -32,6 +40,10 @@ func evaluateLayout( let result = context.objectForKeyedSubscript("__expoWidgetRender")?.call( withArguments: [props, environment] ) + if let exception = context.exception { + print("[ExpoWidgets] Layout evaluation failed: \(exception)") + return createRedBox(message: exception.toString()) + } return result?.toObject() as? [String: Any] } @@ -49,6 +61,12 @@ func getLiveActivityNodes(forName name: String, props: String = "{}", environmen let result = context.objectForKeyedSubscript("__expoWidgetRender")?.call( withArguments: [propsDict, environment] ) + + if let exception = context.exception { + print("[ExpoWidgets] Layout evaluation failed: \(exception)") + return ["banner": createRedBox(message: exception.toString())] + } + return result?.toObject() as? [String: Any] ?? [:] } diff --git a/packages/expo-widgets/ios/Widgets/WidgetContext.swift b/packages/expo-widgets/ios/Widgets/WidgetContext.swift index 68ff52a1a3fd8b..eb207a390b929b 100644 --- a/packages/expo-widgets/ios/Widgets/WidgetContext.swift +++ b/packages/expo-widgets/ios/Widgets/WidgetContext.swift @@ -6,12 +6,6 @@ func createWidgetContext(layout: String) -> JSContext? { return nil } - context.exceptionHandler = { _, exception in - if let exception { - print("[ExpoWidgets] Layout evaluation failed: \(exception)") - } - } - // Inject ExpoUI bundle guard let bundleURL = Bundle.main.url(forResource: "ExpoWidgets", withExtension: "bundle"), let bundle = Bundle(url: bundleURL), From aaca88ca2fb3972cfb1af1ae5cfe95f25f47a8cb Mon Sep 17 00:00:00 2001 From: Kudo Chien Date: Wed, 6 May 2026 18:07:36 +0800 Subject: [PATCH 02/20] [ui] document community bottom sheet (#45412) # Why add missing doc for community bottom sheet # How - [docs] add `@expo/ui/community/bottom-sheet` docs and API data - [ui] fixed regression #44642 for dynamic size bottom sheet --------- Co-authored-by: Aman Mittal --- .../ui/drop-in-replacements/bottomsheet.mdx | 168 +++++ .../sdk/ui/drop-in-replacements/index.mdx | 3 +- .../expo-ui/community/bottom-sheet.json | 668 ++++++++++++++++++ packages/expo-ui/CHANGELOG.md | 2 + .../bottom-sheet/BottomSheet.android.d.ts.map | 2 +- .../bottom-sheet/BottomSheet.ios.d.ts.map | 2 +- .../bottom-sheet/BottomSheet.android.tsx | 5 +- .../bottom-sheet/BottomSheet.ios.tsx | 5 +- 8 files changed, 848 insertions(+), 7 deletions(-) create mode 100644 docs/pages/versions/unversioned/sdk/ui/drop-in-replacements/bottomsheet.mdx create mode 100644 docs/public/static/data/unversioned/expo-ui/community/bottom-sheet.json 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 ( - +