From ce2f75e17287c8014b08973088cf77447ac9648d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:41:46 -0400 Subject: [PATCH] refactor(mobile): the live input's composing range comes from a platform seam pair (#23037) #22958 made the page on Android report no composing range with a user-agent check inside the shared live-input hook. Host facts live in a `src/platform` pair, so the check moves to `live-input-composing-range.web.ts`; the native file passes the event's range through. Behaviour is unchanged. The hook test mocks the seam; the user-agent cases move to the seam's own test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../live-input-composing-range.test.ts | 44 +++++++++++++++++++ .../platform/live-input-composing-range.ts | 7 +++ .../live-input-composing-range.web.ts | 11 +++++ .../use-terminal-live-input-commit.test.ts | 13 ++++-- .../use-terminal-live-input-commit.ts | 13 +----- mobile/web-entry/web-overrides.json | 4 ++ 6 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 mobile/src/platform/live-input-composing-range.test.ts create mode 100644 mobile/src/platform/live-input-composing-range.ts create mode 100644 mobile/src/platform/live-input-composing-range.web.ts diff --git a/mobile/src/platform/live-input-composing-range.test.ts b/mobile/src/platform/live-input-composing-range.test.ts new file mode 100644 index 00000000000..f056f03e837 --- /dev/null +++ b/mobile/src/platform/live-input-composing-range.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { reportedLiveInputComposing } from './live-input-composing-range' +import { reportedLiveInputComposing as reportedPageLiveInputComposing } from './live-input-composing-range.web' + +const ANDROID_WEBVIEW = 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' +const IPHONE_WEBVIEW = 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15' + +describe('the composing range a live input reports', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('passes the native event through, whatever the user agent says', () => { + vi.stubGlobal('navigator', { userAgent: ANDROID_WEBVIEW }) + expect([true, false, undefined].map(reportedLiveInputComposing)).toEqual([ + true, + false, + undefined + ]) + }) + + it('reports no range in an Android WebView, as native Android does', () => { + vi.stubGlobal('navigator', { userAgent: ANDROID_WEBVIEW }) + expect([true, false, undefined].map(reportedPageLiveInputComposing)).toEqual([ + undefined, + undefined, + undefined + ]) + }) + + it("passes the DOM's range through in any other browser", () => { + vi.stubGlobal('navigator', { userAgent: IPHONE_WEBVIEW }) + expect([true, false, undefined].map(reportedPageLiveInputComposing)).toEqual([ + true, + false, + undefined + ]) + }) + + it('passes the range through where there is no navigator', () => { + vi.stubGlobal('navigator', undefined) + expect(reportedPageLiveInputComposing(true)).toBe(true) + }) +}) diff --git a/mobile/src/platform/live-input-composing-range.ts b/mobile/src/platform/live-input-composing-range.ts new file mode 100644 index 00000000000..99835eed7d6 --- /dev/null +++ b/mobile/src/platform/live-input-composing-range.ts @@ -0,0 +1,7 @@ +/** + * The marked-text range a live input's change event reports, as the host's text system means it. + * Native passes the event's own through: iOS reports its range, React Native Android reports none. + */ +export function reportedLiveInputComposing(isComposing: boolean | undefined): boolean | undefined { + return isComposing +} diff --git a/mobile/src/platform/live-input-composing-range.web.ts b/mobile/src/platform/live-input-composing-range.web.ts new file mode 100644 index 00000000000..61b46a3096e --- /dev/null +++ b/mobile/src/platform/live-input-composing-range.web.ts @@ -0,0 +1,11 @@ +/** + * Web sibling: the event is the DOM's, so its `isComposing` passes through except in an Android + * WebView, which reports none, as native Android does. + */ +export function reportedLiveInputComposing(isComposing: boolean | undefined): boolean | undefined { + // Why: Android keyboards compose every Latin word, so the DOM's range would hold each one unsent. + if (globalThis.navigator?.userAgent?.includes('Android')) { + return undefined + } + return isComposing +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index df693be45be..211454c87ad 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -6,6 +6,13 @@ import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS } from './terminal-live-preedit-mirror' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' +// The host's report is the seam's subject; here only whether it reports a range at all matters. +const composingRange = vi.hoisted(() => ({ hostReportsNone: false })) +vi.mock('../platform/live-input-composing-range', () => ({ + reportedLiveInputComposing: (isComposing: boolean | undefined) => + composingRange.hostReportsNone ? undefined : isComposing +})) + type TerminalLiveInputCommitHandlers = ReturnType> /** `isComposing` omitted models a platform that reports no marked-text range. */ @@ -121,7 +128,7 @@ function createTerminalLiveInputCommitHarness({ describe('terminal live input commit hook', () => { afterEach(() => { vi.useRealTimers() - vi.unstubAllGlobals() + composingRange.hostReportsNone = false }) it('Given Hangul composition and no marked-text report When steps arrive Then no jamo leaks', async () => { @@ -171,7 +178,7 @@ describe('terminal live input commit hook', () => { it('Given an Android WebView composing each word When letters follow a slash Then each reaches the terminal as typed', async () => { // Given: the page's field event is the DOM's, and an Android keyboard composes Latin words - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + composingRange.hostReportsNone = true const { handlers, sent } = createTerminalLiveInputCommitHarness() // When @@ -190,7 +197,7 @@ describe('terminal live input commit hook', () => { it('Given an Android WebView composing Hangul When steps arrive Then the non-ASCII run still settles on the timer', async () => { // Given vi.useFakeTimers() - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + composingRange.hostReportsNone = true const { handlers, sent } = createTerminalLiveInputCommitHarness() // When diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 237a78ca12a..485a6a1e425 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react' import type { TextInput } from 'react-native' +import { reportedLiveInputComposing } from '../platform/live-input-composing-range' import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' @@ -27,16 +28,6 @@ type TerminalLiveInputChangeEvent = { } } -/** Android keyboards compose every Latin word; report no range, as native Android does. */ -function reportedLiveInputComposing( - nativeEvent: TerminalLiveInputChangeEvent['nativeEvent'] -): boolean | undefined { - if (globalThis.navigator?.userAgent?.includes('Android')) { - return undefined - } - return nativeEvent.isComposing -} - type TerminalLiveInputCommitOptions = { readonly activeHandle: string | null readonly activeHandleRef: RefObject @@ -158,7 +149,7 @@ export function useTerminalLiveInputCommit({ void applyLiveInputMirror( activeHandle, normalizeTerminalTextInput(nativeEvent.text), - reportedLiveInputComposing(nativeEvent) + reportedLiveInputComposing(nativeEvent.isComposing) ) }, [ diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index 50a31ed61cb..afcc3c564d6 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -69,6 +69,10 @@ "file": "src/platform/keyboard-occlusion.web.ts", "reason": "react-native-web's Keyboard is a stub: addListener returns a subscription that never fires and isVisible() is always false, so a screen waiting for keyboardDidShow inside the page waits forever and the software keyboard covers whatever sits at the bottom of the document \u2014 the source-control commit bar, and the review note composer whose KeyboardAvoidingView is driven by those same events. The browser publishes the geometry a different way: the layout viewport keeps its size and visualViewport shrinks, so the occluded strip is innerHeight minus the visual viewport's height and offsetTop, tracked on its resize and scroll. A document with no visualViewport answers 0 rather than guessing." }, + { + "file": "src/platform/live-input-composing-range.web.ts", + "reason": "The terminal's Live input reads the change event's isComposing as the text system's marked-text range, and on the page that event is the DOM's. An Android WebView marks the keyboard's composing region, which Samsung and other Latin keyboards keep over every word, so the preedit mirror held each word unsent until the next keystroke or Enter. Native Android reports no range at all; this sibling answers the same in an Android WebView and passes the DOM's range through in any other browser. The native file passes the event's own through." + }, { "file": "src/platform/clipboard.web.ts", "reason": "expo-clipboard resolves to navigator.clipboard on the web, which needs a secure context; the iOS shell serves the page from the custom scheme orca-mobile-web:/// while Android serves https, so that path would work on one platform and silently not on the other. This one asks the shell through the native.clipboard.write and native.clipboard.read verbs, where the pasteboard is the device's, and rejects when the route was not granted one so the caller's own catch puts that on screen. An image is the same pasteboard reached a different way: native.clipboard.read admits only text, and a 24 MiB base64 image cannot cross an 8 MiB reply cap, so readImage runs native.media.pick with source 'clipboard', reads the staged bytes back a chunk at a time and gives the handle back. contents answers per grant without probing, because the shell serves no 'is there text' verb and reading to find out would raise iOS's paste-consent prompt on every mount."