diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index b3387297a..5efeee308 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -47,6 +47,7 @@ import { type BrowserZoomState } from './browser-touch-geometry' import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url' +import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync' export type MobileBrowserTab = { type: 'browser' @@ -135,6 +136,10 @@ export function MobileBrowserPane({ const cachedInitialFrame = peekCachedBrowserFrame(cacheKey) const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url)) const [addressFocused, setAddressFocused] = useState(false) + const [addressSyncState, setAddressSyncState] = useState({ + focused: false, + url: tab.url + }) const [keyboardValue, setKeyboardValue] = useState('') const [frameUri, setFrameUri] = useState(cachedInitialFrame?.uri ?? null) const [frameMetadata, setFrameMetadata] = useState( @@ -222,11 +227,18 @@ export function MobileBrowserPane({ } }, [worktreeId]) - useEffect(() => { - if (!addressFocused) { + const addressSync = resolveMobileBrowserAddressSync(addressSyncState, { + focused: addressFocused, + url: tab.url + }) + if (addressSync.nextState !== addressSyncState) { + setAddressSyncState(addressSync.nextState) + if (addressSync.shouldSyncValue) { + // Why: keep browser stream/goto address updates intact, but avoid a + // stale post-blur paint when the tab URL is the source of truth. setAddressValue(displayBrowserUrl(tab.url)) } - }, [addressFocused, tab.url]) + } useLayoutEffect(() => { // Why: gesture and stream handlers need committed values before passive diff --git a/mobile/src/browser/mobile-browser-address-sync.test.ts b/mobile/src/browser/mobile-browser-address-sync.test.ts new file mode 100644 index 000000000..18945a9d7 --- /dev/null +++ b/mobile/src/browser/mobile-browser-address-sync.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync' + +describe('resolveMobileBrowserAddressSync', () => { + it('syncs the tab URL when the input is not focused', () => { + const result = resolveMobileBrowserAddressSync( + { focused: false, url: 'https://old.example/' }, + { focused: false, url: 'https://new.example/' } + ) + + expect(result).toEqual({ + nextState: { focused: false, url: 'https://new.example/' }, + shouldSyncValue: true + }) + }) + + it('defers tab URL sync while the user is editing', () => { + const result = resolveMobileBrowserAddressSync( + { focused: false, url: 'https://old.example/' }, + { focused: true, url: 'https://new.example/' } + ) + + expect(result).toEqual({ + nextState: { focused: true, url: 'https://new.example/' }, + shouldSyncValue: false + }) + }) + + it('syncs the latest tab URL when editing ends', () => { + const result = resolveMobileBrowserAddressSync( + { focused: true, url: 'https://new.example/' }, + { focused: false, url: 'https://new.example/' } + ) + + expect(result).toEqual({ + nextState: { focused: false, url: 'https://new.example/' }, + shouldSyncValue: true + }) + }) + + it('preserves externally updated address text when focus and tab URL are unchanged', () => { + const previous = { focused: false, url: 'https://new.example/' } + const result = resolveMobileBrowserAddressSync(previous, { + focused: false, + url: 'https://new.example/' + }) + + expect(result).toEqual({ + nextState: previous, + shouldSyncValue: false + }) + }) +}) diff --git a/mobile/src/browser/mobile-browser-address-sync.ts b/mobile/src/browser/mobile-browser-address-sync.ts new file mode 100644 index 000000000..702739590 --- /dev/null +++ b/mobile/src/browser/mobile-browser-address-sync.ts @@ -0,0 +1,20 @@ +export type MobileBrowserAddressSyncState = { + focused: boolean + url: string +} + +export type MobileBrowserAddressSyncResult = { + nextState: MobileBrowserAddressSyncState + shouldSyncValue: boolean +} + +export function resolveMobileBrowserAddressSync( + previous: MobileBrowserAddressSyncState, + next: MobileBrowserAddressSyncState +): MobileBrowserAddressSyncResult { + const changed = previous.focused !== next.focused || previous.url !== next.url + return { + nextState: changed ? next : previous, + shouldSyncValue: changed && !next.focused + } +}