From 152577a0b10eaba56f06fa5d97a5f8cb861a50a7 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Thu, 29 Oct 2026 21:10:26 +0000 Subject: [PATCH 1/3] fix(mobile): Home opens below the header Home restored its saved scroll offset on mount as well as when search closed. React Native's iOS scrollTo clamps against the raw contentInset, which excludes the header inset UIKit adds, so that scroll could leave the first rows under the header. Restore only when search returns to resting, and let programmatic scrolls reach the automatic header inset, as the thread feed already does. --- apps/mobile/src/features/home/HomeScreen.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1edb92f0d556b..df2b2ab4221e2 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -316,9 +316,14 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const searchMode = workspace?.state.tabs.threads.searchMode; const restoredOffset = workspace?.state.tabs.threads.scrollOffset; + // Only a return from search restores. On mount, UIKit already rests the list + // below the header, and a scroll here would use an unmeasured header inset. + const previousSearchMode = useRef(searchMode); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the thread list's saved scroll offset through the list ref when search goes back to resting useEffect(() => { - if (searchMode !== "resting" || restoredOffset === undefined) return; + const restoringSearch = previousSearchMode.current !== "resting" && searchMode === "resting"; + previousSearchMode.current = searchMode; + if (!restoringSearch || restoredOffset === undefined) return; listRef.current?.scrollToOffset({ offset: restoredOffset - (Platform.OS === "ios" ? insets.top : 0), animated: false, @@ -813,6 +818,8 @@ export function HomeScreen(props: HomeScreenProps) { // the list in the same frame as the bar. contentInsetAdjustmentBehavior="automatic" contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} + // RN scrollTo clamps against raw insets and ignores the automatic bar inset. + scrollToOverflowEnabled initialScrollOffset={ initialScrollOffset > 0 ? initialScrollOffset - (Platform.OS === "ios" ? insets.top : 0) From ff9c79e2524caddbfee37ca9b048bcc2b7fa4335 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Thu, 29 Oct 2026 21:39:30 +0000 Subject: [PATCH 2/3] test(mobile): Home restores its offset only when search returns to rest Move the restore into useRestoreOffsetAfterSearch so its mount and search-return behavior is tested without rendering all of Home. --- apps/mobile/src/features/home/HomeScreen.tsx | 16 ++---- .../home/useRestoreOffsetAfterSearch.test.tsx | 53 +++++++++++++++++++ .../home/useRestoreOffsetAfterSearch.ts | 22 ++++++++ apps/mobile/vite.config.ts | 1 + 4 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx create mode 100644 apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index df2b2ab4221e2..7b55f14ab52e3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -85,6 +85,7 @@ import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-sw import { useFabScroll } from "./FabScrollContext"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { ThreadListFilters } from "./ThreadListFilters"; +import { useRestoreOffsetAfterSearch } from "./useRestoreOffsetAfterSearch"; interface HomeScreenProps { readonly projects: ReadonlyArray; @@ -316,19 +317,12 @@ export function HomeScreen(props: HomeScreenProps) { const listRef = useRef(null); const searchMode = workspace?.state.tabs.threads.searchMode; const restoredOffset = workspace?.state.tabs.threads.scrollOffset; - // Only a return from search restores. On mount, UIKit already rests the list - // below the header, and a scroll here would use an unmeasured header inset. - const previousSearchMode = useRef(searchMode); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the thread list's saved scroll offset through the list ref when search goes back to resting - useEffect(() => { - const restoringSearch = previousSearchMode.current !== "resting" && searchMode === "resting"; - previousSearchMode.current = searchMode; - if (!restoringSearch || restoredOffset === undefined) return; + useRestoreOffsetAfterSearch(searchMode, restoredOffset, (offset) => listRef.current?.scrollToOffset({ - offset: restoredOffset - (Platform.OS === "ios" ? insets.top : 0), + offset: offset - (Platform.OS === "ios" ? insets.top : 0), animated: false, - }); - }, [searchMode, insets.top, restoredOffset]); + }), + ); const swipeRowActivation = createSwipeRowActivation(); const activateVisibleRows = (rows: ReadonlyArray) => { const state = listRef.current?.getState(); diff --git a/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx new file mode 100644 index 0000000000000..c82446cd02d70 --- /dev/null +++ b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx @@ -0,0 +1,53 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import type { WorkspaceSearchMode } from "../workspace/workspace-tabs-state"; +import { useRestoreOffsetAfterSearch } from "./useRestoreOffsetAfterSearch"; + +type Props = { mode: WorkspaceSearchMode; offset: number | undefined }; + +function mountProbe() { + const restore = vi.fn(); + function Probe(props: Props) { + useRestoreOffsetAfterSearch(props.mode, props.offset, restore); + return null; + } + const root = createRoot(document.createElement("div")); + return { + restore, + render: (props: Props) => act(async () => root.render()), + [Symbol.dispose]: () => act(() => root.unmount()), + }; +} + +it("leaves the list where UIKit rested it when Home mounts at rest", async () => { + using screen = mountProbe(); + await screen.render({ mode: "resting", offset: 0 }); + await screen.render({ mode: "resting", offset: 0 }); + expect(screen.restore).not.toHaveBeenCalled(); +}); + +it("restores the saved offset when active or expanded search returns to resting", async () => { + using screen = mountProbe(); + await screen.render({ mode: "resting", offset: 120 }); + await screen.render({ mode: "active", offset: 120 }); + await screen.render({ mode: "expanded", offset: 120 }); + expect(screen.restore).not.toHaveBeenCalled(); + + await screen.render({ mode: "resting", offset: 80 }); + expect(screen.restore).toHaveBeenCalledExactlyOnceWith(80); + + await screen.render({ mode: "active", offset: 80 }); + await screen.render({ mode: "resting", offset: 200 }); + expect(screen.restore).toHaveBeenCalledTimes(2); + expect(screen.restore).toHaveBeenLastCalledWith(200); +}); + +it("does not scroll again when the saved offset changes while resting", async () => { + using screen = mountProbe(); + await screen.render({ mode: "active", offset: 50 }); + await screen.render({ mode: "resting", offset: 50 }); + await screen.render({ mode: "resting", offset: 300 }); + expect(screen.restore).toHaveBeenCalledExactlyOnceWith(50); +}); diff --git a/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts new file mode 100644 index 0000000000000..071be7f22fe78 --- /dev/null +++ b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from "react"; + +import type { WorkspaceSearchMode } from "../workspace/workspace-tabs-state"; + +/** + * Restores a list's saved offset when search returns to resting. Mounting at rest restores + * nothing: UIKit already rests the list below the header, and a scroll there would use an + * unmeasured header inset. + */ +export function useRestoreOffsetAfterSearch( + searchMode: WorkspaceSearchMode | undefined, + savedOffset: number | undefined, + restore: (offset: number) => void, +) { + const previousSearchMode = useRef(searchMode); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the list to the offset saved when search opened once the tab returns to resting + useEffect(() => { + const restoringSearch = previousSearchMode.current !== "resting" && searchMode === "resting"; + previousSearchMode.current = searchMode; + if (restoringSearch && savedOffset !== undefined) restore(savedOffset); + }, [searchMode, savedOffset, restore]); +} diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 9ddb84efbc832..1413eb2993fac 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -31,6 +31,7 @@ const reactTests = [ "src/features/terminal/useTerminalExitNavigation.test.tsx", "src/features/threads/use-thread-list-now.test.tsx", "src/features/home/home-list-options.test.tsx", + "src/features/home/useRestoreOffsetAfterSearch.test.tsx", "src/features/connection/ConnectionsNewRouteScreen.test.tsx", ]; From 11c94d84aaec7ecad348b7df278fdd3fcf0683a9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 04:16:46 +0000 Subject: [PATCH 3/3] fix(mobile): restore Home only after an actual search --- .../home/useRestoreOffsetAfterSearch.test.tsx | 5 ++++- .../src/features/home/useRestoreOffsetAfterSearch.ts | 11 +++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx index c82446cd02d70..e190806a3f43d 100644 --- a/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx +++ b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.test.tsx @@ -5,7 +5,7 @@ import { expect, it, vi } from "vite-plus/test"; import type { WorkspaceSearchMode } from "../workspace/workspace-tabs-state"; import { useRestoreOffsetAfterSearch } from "./useRestoreOffsetAfterSearch"; -type Props = { mode: WorkspaceSearchMode; offset: number | undefined }; +type Props = { mode: WorkspaceSearchMode | undefined; offset: number | undefined }; function mountProbe() { const restore = vi.fn(); @@ -26,6 +26,9 @@ it("leaves the list where UIKit rested it when Home mounts at rest", async () => await screen.render({ mode: "resting", offset: 0 }); await screen.render({ mode: "resting", offset: 0 }); expect(screen.restore).not.toHaveBeenCalled(); + await screen.render({ mode: undefined, offset: undefined }); + await screen.render({ mode: "resting", offset: 0 }); + expect(screen.restore).not.toHaveBeenCalled(); }); it("restores the saved offset when active or expanded search returns to resting", async () => { diff --git a/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts index 071be7f22fe78..8a0f7accb63ff 100644 --- a/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts +++ b/apps/mobile/src/features/home/useRestoreOffsetAfterSearch.ts @@ -2,11 +2,7 @@ import { useEffect, useRef } from "react"; import type { WorkspaceSearchMode } from "../workspace/workspace-tabs-state"; -/** - * Restores a list's saved offset when search returns to resting. Mounting at rest restores - * nothing: UIKit already rests the list below the header, and a scroll there would use an - * unmeasured header inset. - */ +// UIKit positions a fresh list below its header; only closing search needs a restore. export function useRestoreOffsetAfterSearch( searchMode: WorkspaceSearchMode | undefined, savedOffset: number | undefined, @@ -15,7 +11,10 @@ export function useRestoreOffsetAfterSearch( const previousSearchMode = useRef(searchMode); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the list to the offset saved when search opened once the tab returns to resting useEffect(() => { - const restoringSearch = previousSearchMode.current !== "resting" && searchMode === "resting"; + const restoringSearch = + previousSearchMode.current !== undefined && + previousSearchMode.current !== "resting" && + searchMode === "resting"; previousSearchMode.current = searchMode; if (restoringSearch && savedOffset !== undefined) restore(savedOffset); }, [searchMode, savedOffset, restore]);