From 8617f934c5f60d220286db53c9fcffab9f141d57 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Wed, 21 Oct 2026 00:00:32 +0000 Subject: [PATCH 1/6] fix(mobile): iOS rename prompt text field no longer overflows the dialog On iOS 26, UIAlertController lays out its text field past the alert's trailing edge, so Alert.prompt-based prompts (Rename thread, Link pull request) render a misaligned input. Present iOS prompts in-app through Dialog.Host instead, keeping native alerts and confirms. A React Native Modal presents from its nearest view controller, which cannot present over a native form sheet, so the PR and git sheets mount their own Dialog.SheetHost; the most recently mounted host presents. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ui/Dialog.android.tsx | 5 + apps/mobile/src/components/ui/Dialog.test.tsx | 101 +++++++++++++ apps/mobile/src/components/ui/Dialog.tsx | 137 ++++++++++++++++-- .../threads/ThreadPullRequestsSheet.tsx | 2 + .../features/threads/git/GitOverviewSheet.tsx | 7 +- apps/mobile/vite.config.ts | 1 + 6 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/components/ui/Dialog.test.tsx diff --git a/apps/mobile/src/components/ui/Dialog.android.tsx b/apps/mobile/src/components/ui/Dialog.android.tsx index 626c6c305a5b9..83765ed9f6156 100644 --- a/apps/mobile/src/components/ui/Dialog.android.tsx +++ b/apps/mobile/src/components/ui/Dialog.android.tsx @@ -128,6 +128,11 @@ export function Host() { ) : null; } +/** Compose dialogs open in their own window, so sheets need no extra host on Android. */ +export function SheetHost() { + return null; +} + function DialogPresentation(props: DialogPresentationProps) { const { themeVariables: colors } = useAppearancePreferences(); const titleTypography = useScaledTextRole("title"); diff --git a/apps/mobile/src/components/ui/Dialog.test.tsx b/apps/mobile/src/components/ui/Dialog.test.tsx new file mode 100644 index 0000000000000..91fb647650ccf --- /dev/null +++ b/apps/mobile/src/components/ui/Dialog.test.tsx @@ -0,0 +1,101 @@ +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +vi.mock("react-native", () => { + const Slot = (props: { readonly children?: ReactNode }) =>
{props.children}
; + return { + Alert: { alert: vi.fn() }, + KeyboardAvoidingView: Slot, + Modal: Slot, + ScrollView: Slot, + View: Slot, + useWindowDimensions: () => ({ width: 390, height: 844 }), + }; +}); +vi.mock("./AppText", () => ({ + AppText: (props: { readonly children?: ReactNode }) => {props.children}, + AppTextInput: (props: { readonly value: string; readonly onChangeText: (v: string) => void }) => ( + props.onChangeText(event.target.value)} /> + ), +})); +vi.mock("./Button", () => ({ + Button: (props: { readonly label: string; readonly onPress: () => void }) => ( + + ), +})); +vi.mock("./GlassSurface", () => ({ + GlassSurface: (props: { readonly children?: ReactNode }) =>
{props.children}
, +})); +vi.mock("../../features/settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ themeVariables: {} }), +})); + +import * as Dialog from "./Dialog"; + +const roots: Root[] = []; + +function mountHost(host: () => ReactNode = () => ) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render(host())); + return { root, container }; +} + +function button(container: HTMLElement, label: string) { + return [...container.querySelectorAll("button")].find((node) => node.textContent === label)!; +} + +function promptRename(onConfirm: (value: string) => void) { + act(() => + Dialog.prompt({ + title: "Rename thread", + initialValue: "old", + confirmText: "Rename", + onConfirm, + }), + ); +} + +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.innerHTML = ""; +}); + +it("confirms the edited value exactly once", () => { + const { container } = mountHost(); + const onConfirm = vi.fn(); + promptRename(onConfirm); + + const input = container.querySelector("input")!; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "new"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + const rename = button(container, "Rename"); + act(() => { + rename.click(); + rename.click(); + }); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith("new"); + expect(container.textContent).toBe(""); +}); + +it("presents from a mounted sheet host, then falls back to the root host", () => { + const app = mountHost(); + const sheet = mountHost(() => ); + + promptRename(vi.fn()); + expect(sheet.container.textContent).toContain("Rename thread"); + expect(app.container.textContent).toBe(""); + act(() => button(sheet.container, "Cancel").click()); + + roots.splice(roots.indexOf(sheet.root), 1); + act(() => sheet.root.unmount()); + promptRename(vi.fn()); + expect(app.container.textContent).toContain("Rename thread"); +}); diff --git a/apps/mobile/src/components/ui/Dialog.tsx b/apps/mobile/src/components/ui/Dialog.tsx index dbbc1096e1e13..7ca0d628449d5 100644 --- a/apps/mobile/src/components/ui/Dialog.tsx +++ b/apps/mobile/src/components/ui/Dialog.tsx @@ -1,4 +1,18 @@ -import { Alert } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Modal, + ScrollView, + useWindowDimensions, + View, + type TextInputInstance, +} from "react-native"; + +import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; +import { AppText, AppTextInput } from "./AppText"; +import { Button } from "./Button"; +import { GlassSurface } from "./GlassSurface"; import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; export type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types"; @@ -19,20 +33,117 @@ export function confirm(request: ConfirmDialogRequest): void { ]); } +// The most recently mounted host presents, so a sheet's host wins while the sheet is open. +const promptPresenters: Array<(request: TextInputDialogRequest) => void> = []; + +// UIAlertController's text field lays out past the alert's edge on iOS 26 (Liquid Glass), +// so prompts render in-app instead of through Alert.prompt. export function prompt(request: TextInputDialogRequest): void { - Alert.prompt( - request.title, - request.message, - [ - { text: request.cancelText ?? "Cancel", style: "cancel", onPress: request.onCancel }, - { text: request.confirmText, onPress: (value) => request.onConfirm(value ?? "") }, - ], - "plain-text", - request.initialValue, - ); + promptPresenters.at(-1)?.(request); } -/** iOS alerts are presented directly by React Native. */ +/** iOS presenter for Dialog.prompt; alerts and confirms are presented directly by React Native. */ export function Host() { - return null; + const [presented, setPresented] = useState< + (TextInputDialogRequest & { readonly id: number }) | null + >(null); + const nextPresentationId = useRef(0); + useEffect(() => { + const present = (request: TextInputDialogRequest) => + setPresented({ ...request, id: ++nextPresentationId.current }); + promptPresenters.push(present); + return () => { + promptPresenters.splice(promptPresenters.indexOf(present), 1); + }; + }, []); + + return presented ? ( + setPresented(null)} /> + ) : null; +} + +/** + * A React Native Modal presents from its nearest view controller, and the root one cannot present + * over a native sheet. Mount this inside sheets that open prompts. + */ +export const SheetHost = Host; + +function PromptDialog(props: { + readonly request: TextInputDialogRequest; + readonly onClose: () => void; +}) { + const { request } = props; + const { width, height, fontScale } = useWindowDimensions(); + const stackActions = fontScale > 1.3; + const { themeVariables } = useAppearancePreferences(); + const inputRef = useRef(null); + const settled = useRef(false); + const [value, setValue] = useState(request.initialValue); + // A second tap can land before the modal unmounts; only the first one counts. + const settle = (run: () => void) => { + if (settled.current) return; + settled.current = true; + props.onClose(); + run(); + }; + const cancel = () => settle(() => request.onCancel?.()); + const submit = () => settle(() => request.onConfirm(value)); + + return ( + inputRef.current?.focus()} + onRequestClose={cancel} + > + + + + + {request.title} + + {request.message ? ( + + {request.message} + + ) : null} + + + + + + + ), ScrollView: Slot, View: Slot, useWindowDimensions: () => ({ width: 390, height: 844 }), @@ -66,7 +77,7 @@ afterEach(() => { document.body.innerHTML = ""; }); -it("confirms the edited value exactly once", () => { +it("confirms the edited value exactly once after native dismissal", () => { const { container } = mountHost(); const onConfirm = vi.fn(); promptRename(onConfirm); @@ -82,6 +93,12 @@ it("confirms the edited value exactly once", () => { rename.click(); }); + expect(onConfirm).not.toHaveBeenCalled(); + const dismiss = button(container, "Dismiss modal"); + act(() => { + dismiss.click(); + dismiss.click(); + }); expect(onConfirm).toHaveBeenCalledExactlyOnceWith("new"); expect(container.textContent).toBe(""); }); @@ -100,3 +117,37 @@ it("presents from a mounted sheet host, then falls back to the root host", () => promptRename(vi.fn()); expect(app.container.textContent).toContain("Rename thread"); }); + +it.each(["Cancel", "Request close"])( + "waits for dismissal before %s opens another dialog", + (action) => { + const { container } = mountHost(); + const onCancel = vi.fn(() => + Dialog.prompt({ + title: "Second prompt", + initialValue: "", + confirmText: "Save", + onConfirm: vi.fn(), + }), + ); + act(() => + Dialog.prompt({ + title: "First prompt", + initialValue: "", + confirmText: "Save", + onConfirm: vi.fn(), + onCancel, + }), + ); + act(() => button(container, action).click()); + expect(onCancel).not.toHaveBeenCalled(); + const dismiss = button(container, "Dismiss modal"); + act(() => { + dismiss.click(); + dismiss.click(); + }); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Second prompt"); + expect(container.textContent).not.toContain("First prompt"); + }, +); diff --git a/apps/mobile/src/components/ui/DialogHost.tsx b/apps/mobile/src/components/ui/DialogHost.tsx index 958ea5c5769c3..95fd5d3fda004 100644 --- a/apps/mobile/src/components/ui/DialogHost.tsx +++ b/apps/mobile/src/components/ui/DialogHost.tsx @@ -14,12 +14,12 @@ import { Button } from "./Button"; import { registerPromptPresenter, type TextInputDialogRequest } from "./Dialog"; import { GlassSurface } from "./GlassSurface"; -/** iOS presenter for Dialog.prompt; alerts and confirms are presented directly by React Native. */ export function DialogHost() { const [presented, setPresented] = useState< (TextInputDialogRequest & { readonly id: number }) | null >(null); const nextPresentationId = useRef(0); + // oxlint-disable-next-line t3code/react-effect-budget -- Register the native prompt presenter for this host's lifetime. useEffect(() => { const present = (request: TextInputDialogRequest) => setPresented({ ...request, id: ++nextPresentationId.current }); @@ -46,13 +46,20 @@ function PromptDialog(props: { const stackActions = fontScale > 1.3; const { themeVariables } = useAppearancePreferences(); const inputRef = useRef(null); - const settled = useRef(false); + const pendingAction = useRef<(() => void) | null>(null); + const [visible, setVisible] = useState(true); const [value, setValue] = useState(request.initialValue); - // A second tap can land before the modal unmounts; only the first one counts. const settle = (run: () => void) => { - if (settled.current) return; - settled.current = true; + if (pendingAction.current) return; + pendingAction.current = run; + setVisible(false); + }; + const dismissed = () => { + const run = pendingAction.current; + if (!run) return; + pendingAction.current = null; props.onClose(); + // Callbacks can present another native alert or sheet only after UIKit dismisses this modal. run(); }; const cancel = () => settle(() => request.onCancel?.()); @@ -60,11 +67,12 @@ function PromptDialog(props: { return ( inputRef.current?.focus()} onRequestClose={cancel} + onDismiss={dismissed} > Date: Mon, 26 Oct 2026 02:40:12 +0000 Subject: [PATCH 4/6] fix(mobile): keep hidden git inspectors out of prompt hosts --- apps/mobile/src/features/threads/git/GitOverviewSheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 0f1c7a78647d4..757fc6b6e689b 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -74,7 +74,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { return ( <> - + {props.presentation !== "inspector" ? : null} ); } From 5a8ba82cafcf632bf0cc418dc9147c58d295668e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 07:59:09 +0000 Subject: [PATCH 5/6] fix(mobile): update prompt host effect lint directive --- apps/mobile/src/components/ui/DialogHost.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/ui/DialogHost.tsx b/apps/mobile/src/components/ui/DialogHost.tsx index 95fd5d3fda004..cfa7fb0449fc1 100644 --- a/apps/mobile/src/components/ui/DialogHost.tsx +++ b/apps/mobile/src/components/ui/DialogHost.tsx @@ -19,7 +19,7 @@ export function DialogHost() { (TextInputDialogRequest & { readonly id: number }) | null >(null); const nextPresentationId = useRef(0); - // oxlint-disable-next-line t3code/react-effect-budget -- Register the native prompt presenter for this host's lifetime. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Register the native prompt presenter for this host's lifetime. useEffect(() => { const present = (request: TextInputDialogRequest) => setPresented({ ...request, id: ++nextPresentationId.current }); From 1beb1fec75b8853362fa6c1c790a97dbbd9ab1c5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 08:11:41 +0000 Subject: [PATCH 6/6] fix(mobile): preserve fullscreen prompts and orientation --- apps/mobile/src/components/ui/DialogHost.tsx | 1 + apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/mobile/src/components/ui/DialogHost.tsx b/apps/mobile/src/components/ui/DialogHost.tsx index cfa7fb0449fc1..2a22aa6811c6a 100644 --- a/apps/mobile/src/components/ui/DialogHost.tsx +++ b/apps/mobile/src/components/ui/DialogHost.tsx @@ -70,6 +70,7 @@ function PromptDialog(props: { visible={visible} transparent animationType="fade" + supportedOrientations={["portrait", "portrait-upside-down", "landscape"]} onShow={() => inputRef.current?.focus()} onRequestClose={cancel} onDismiss={dismissed} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 737e1aff90ada..e092be53a3491 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,4 +1,5 @@ import * as Dialog from "../../components/ui/Dialog"; +import { DialogSheetHost } from "../../components/ui/DialogHost"; import { useRememberThreadProject } from "./useRememberThreadProject"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; @@ -1114,6 +1115,7 @@ export function FullscreenThreadRouteScreen( route={props.route} inspectorPresentation={{ onClose: () => navigation.goBack() }} /> + ); }