# Files changed · PR \#22978 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22978/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): retain resting scroll offsets through Duo inset changes](https://git.cafe/pingdotgg/t3code/pull/22978.md)

Version: 9

Head commit: 84df3e96d5f0fa65e3f873f5a8a6aab1d50566ac

Base commit: e4779fd19c54c4368e54f87ac163673f92e3a214

Comparison base: e4779fd19c54c4368e54f87ac163673f92e3a214

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/home/HomeScreen.tsx

Status: modified; +3; −1

```diff
@@ -42,6 +42,7 @@
 import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
 import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
 import { nativeHeaderScrollEdgeEffects } from "../../native/scrollEdgeEffects";
+import { useRestingTopOffset } from "../../native/useRestingTopOffset";
 import {
   useNativeColumnLayoutMetrics,
   useNativeLayoutMetrics,
@@ -216,6 +217,7 @@
 function HomeScrollView(props: ComponentProps<typeof ScrollView>) {
   const insets = useSafeAreaInsets();
   const primaryColumn = use(NativePrimaryColumnContext);
+  const restingOffset = useRestingTopOffset(insets.top, props);
   if (Platform.OS !== "ios") return <ScrollView {...props} />;
 
   // v5 needs the actual content scroll view registered with its screen controller.
@@ -230,6 +232,7 @@
     >
       <ScrollView
         {...props}
+        {...restingOffset}
         // UIKit's scroll-edge material needs the scroll view's own surface color.
         className={cn(props.className, primaryColumn ? "bg-drawer" : "bg-screen")}
         // Use the column's measured inset so UIKit recognizes the resting top.
@@ -237,7 +240,6 @@
         // past its edge, activating scroll-edge protection before any scroll.
         automaticallyAdjustContentInsets={false}
         contentInsetAdjustmentBehavior="never"
-        contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
       />
     </ScrollViewMarker>
   );
```

## apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx

Status: modified; +3; −1

```diff
@@ -20,6 +20,7 @@
 import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
 import { EmptyState } from "../../components/ui/EmptyState";
 import { Button } from "../../components/ui/Button";
+import { useRestingTopOffset } from "../../native/useRestingTopOffset";
 import { SymbolView } from "../../components/ui/AppSymbol";
 import { NativeStackScreenOptions } from "../../native/StackHeader";
 import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
@@ -35,14 +36,15 @@
 
 function ReviewerScrollView(props: ScrollViewProps) {
   const insets = useSafeAreaInsets();
+  const restingOffset = useRestingTopOffset(insets.top, props);
   return (
     <ScrollViewMarker style={{ flex: 1 }}>
       <ScrollView
         {...props}
+        {...restingOffset}
         automaticallyAdjustContentInsets={false}
         contentInsetAdjustmentBehavior="never"
         contentInset={{ top: insets.top }}
-        contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
       />
     </ScrollViewMarker>
   );
```

## apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx

Status: modified; +6; −2

```diff
@@ -56,6 +56,7 @@
   View,
 } from "react-native";
 import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker";
+import { useRestingTopOffset } from "../../native/useRestingTopOffset";
 import { useSafeAreaInsets } from "react-native-safe-area-context";
 
 import { AppText as Text } from "../../components/ui/AppText";
@@ -117,13 +118,14 @@
 
 function PullRequestTabScrollView(props: ScrollViewProps) {
   const insets = useSafeAreaInsets();
+  const restingOffset = useRestingTopOffset(insets.top, props);
   return (
     <ScrollViewMarker style={{ flex: 1 }}>
       <ScrollView
         {...props}
+        {...restingOffset}
         automaticallyAdjustContentInsets={false}
         contentInsetAdjustmentBehavior="never"
-        contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
       />
     </ScrollViewMarker>
   );
@@ -558,7 +560,9 @@
               >
                 <AnimatedLegendList
                   ref={listRefs[tab]}
-                  initialScrollOffset={-insets.top}
+                  // The scroll view owns its resting offset. LegendList keeps the first
+                  // initialScrollOffset it gets, and the first render has the window's top
+                  // inset (0 on the folded iPhone Duo), not the header's.
                   onScroll={(event) => {
                     const offset = event.nativeEvent.contentOffset.y;
                     scrollOffsets.current[tab] = offset;
```

## apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx

Status: modified; +3; −1

```diff
@@ -21,6 +21,7 @@
 
 import { AppText as Text } from "../../components/ui/AppText";
 import { EmptyState } from "../../components/ui/EmptyState";
+import { useRestingTopOffset } from "../../native/useRestingTopOffset";
 import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
 import { useWorkspaceState } from "../../state/workspace";
 import { useProjects } from "../../state/entities";
@@ -46,13 +47,14 @@
 
 function PullRequestScrollView(props: ScrollViewProps) {
   const insets = useSafeAreaInsets();
+  const restingOffset = useRestingTopOffset(insets.top, props);
   return (
     <ScrollViewMarker style={{ flex: 1 }}>
       <ScrollView
         {...props}
+        {...restingOffset}
         automaticallyAdjustContentInsets={false}
         contentInsetAdjustmentBehavior="never"
-        contentOffset={props.contentOffset ?? { x: 0, y: -insets.top }}
       />
     </ScrollViewMarker>
   );
```

## apps/mobile/src/features/threads/ThreadFeed.tsx

Status: modified; +91; −19

```diff
@@ -90,6 +90,7 @@
   useCallback,
   useContext,
   useEffect,
+  useEffectEvent,
   useLayoutEffect,
   useMemo,
   useRef,
@@ -202,7 +203,10 @@
 import type { ThreadContentPresentation } from "./threadContentPresentation";
 import {
   resolveThreadFeedLiveFollow,
+  resolveThreadFeedReflowAnchor,
+  resolveThreadFeedReflowScroll,
   type ThreadFeedLiveFollowEvent,
+  type ThreadFeedReflowAnchor,
   type ThreadWorkGroupScrollPosition,
 } from "./thread-feed-live-follow";
 import {
@@ -284,6 +288,11 @@
 }
 
 const TURN_FOLD_HEIGHT = 42;
+const FEED_PADDING_TOP = 12;
+// Rows re-measure within a commit or two of a width change. LegendList also defers imperative
+// scrolls for 100ms after it compensates a size change, and a deferred restore could land
+// under a drag that starts meanwhile.
+const FEED_REFLOW_SETTLE_MS = 200;
 const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5;
 const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5;
 const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
@@ -1972,6 +1981,20 @@
   const transitionEndFollow = (event: ThreadFeedLiveFollowEvent) => {
     setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event));
   };
+  // A width change re-wraps every row at once, and LegendList re-measures them over the next
+  // commits. Its end threshold and MVCP only see the new geometry, so the reader's place is
+  // taken before any row lands: the end while following, otherwise the row under the header.
+  // undefined: no reflow. null: a reflow with nothing to restore.
+  const reflowAnchorRef = useRef<ThreadFeedReflowAnchor | null | undefined>(undefined);
+  const [reflowFollowsEnd, setReflowFollowsEnd] = useState(false);
+  const [reflowSettleRevision, setReflowSettleRevision] = useState(0);
+  const listHeaderSizeRef = useRef(0);
+  const loadedListKeyRef = useRef<string | null>(null);
+  const cancelReflow = () => {
+    if (reflowAnchorRef.current === undefined) return;
+    reflowAnchorRef.current = undefined;
+    setReflowFollowsEnd(false);
+  };
   const [interactionState, setInteractionState] = useState<{
     readonly copiedRowId: string | null;
     readonly expandedWorkGroups: Record<string, boolean>;
@@ -2419,6 +2442,7 @@
   const handleScrollBeginDrag = () => {
     clearUserScrollSettle();
     userScrollSessionRef.current = true;
+    cancelReflow();
     // Pause before the first scroll event. Otherwise a stream update can run
     // maintainScrollAtEnd between touch-down and the drag leaving its threshold.
     transitionEndFollow({ type: "user-scroll-begin" });
@@ -2461,22 +2485,17 @@
   useEffect(() => {
     clearUserScrollSettle();
     userScrollSessionRef.current = false;
+    cancelReflow();
     transitionEndFollow({ type: "reset" });
-  }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
+  }, [cancelReflow, clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
   useEffect(() => {
     if (props.submittedMessageId !== null) {
       clearUserScrollSettle();
       userScrollSessionRef.current = false;
+      cancelReflow();
       transitionEndFollow({ type: "reset" });
     }
-  }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
-
-  const handleViewportLayout = (event: LayoutChangeEvent) => {
-    const nextWidth = Math.round(event.nativeEvent.layout.width);
-    const nextHeight = Math.round(event.nativeEvent.layout.height);
-    setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
-    setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
-  };
+  }, [cancelReflow, clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
 
   useEffect(() => {
     reportHeaderMaterialVisibility(false);
@@ -2498,6 +2517,54 @@
     }
   }, [listMountKey, props.contentInsetEndAdjustment, listRef]);
 
+  const reflowLayout = () => ({
+    rowsTop: FEED_PADDING_TOP + listHeaderSizeRef.current,
+    headerInset: anchorTopInset,
+  });
+  const beginReflow = () => {
+    // Until the list loads, its own initial end positioning owns the offset.
+    if (loadedListKeyRef.current !== listMountKey) return;
+    if (reflowAnchorRef.current === undefined) {
+      const listState = listRef.current?.getState();
+      const anchor =
+        listState && !userScrollSessionRef.current
+          ? resolveThreadFeedReflowAnchor(listState, reflowLayout(), endFollowEnabledRef.current)
+          : null;
+      reflowAnchorRef.current = anchor;
+      if (anchor === "end") {
+        // The list may already have judged the first new sizes against its own threshold, so
+        // pin the end once; the lifted threshold keeps it there as later rows re-measure.
+        setReflowFollowsEnd(true);
+        void listRef.current?.scrollToEnd({ animated: false });
+      }
+    }
+    setReflowSettleRevision((revision) => revision + 1);
+  };
+  const settleReflow = useEffectEvent(() => {
+    const anchor = reflowAnchorRef.current;
+    cancelReflow();
+    const list = listRef.current;
+    if (!anchor || anchor === "end" || !list || userScrollSessionRef.current) return;
+    if (loadedListKeyRef.current !== listMountKey) return;
+    const target = resolveThreadFeedReflowScroll(anchor, list.getState(), reflowLayout());
+    if (target) void list.scrollToIndex({ ...target, viewPosition: 0, animated: false });
+  });
+  useEffect(() => {
+    if (reflowSettleRevision === 0) return;
+    const timeout = setTimeout(() => settleReflow(), FEED_REFLOW_SETTLE_MS);
+    return () => clearTimeout(timeout);
+  }, [reflowSettleRevision]);
+
+  const handleViewportLayout = (event: LayoutChangeEvent) => {
+    const nextWidth = Math.round(event.nativeEvent.layout.width);
+    const nextHeight = Math.round(event.nativeEvent.layout.height);
+    // A native resize re-wraps the rows before JS hears of it. This event reaches JS ahead
+    // of the row measurements it caused, since layout events are sent parent first.
+    if (Math.abs(measuredViewportWidth - nextWidth) > 1) beginReflow();
+    setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
+    setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
+  };
+
   const anchoredEndSpace = resolveChatListAnchoredEndSpace(
     presentedFeed,
     props.anchorMessageId,
@@ -2580,6 +2647,7 @@
   };
 
   const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string | null) => {
+    cancelReflow();
     disclosureAnchorKeyRef.current = anchorKey;
     setDisclosureToggleSettling(true);
   };
@@ -2724,16 +2792,10 @@
   useLayoutEffect(() => {
     if (previousContentWidth.current === contentWidth) return;
     previousContentWidth.current = contentWidth;
+    // Rows re-rendered at this width are measured in the next commit, after this effect.
+    beginReflow();
     if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" });
-    // A reflow can grow the visible rows past LegendList's near-end threshold in one batch, and
-    // maintainScrollAtEnd then stops following. Pin the end the feed was following once the new
-    // sizes are in.
-    if (!endFollowEnabledRef.current || !listRef.current?.getState().isAtEnd) return;
-    const frame = requestAnimationFrame(() => {
+  }, [beginReflow, contentWidth, hasHtmlRenders, listRef]);
-      if (endFollowEnabledRef.current) void listRef.current?.scrollToEnd({ animated: false });
-    });
-    return () => cancelAnimationFrame(frame);
-  }, [contentWidth, hasHtmlRenders, listRef]);
 
   const renderItem = (info: { item: PendingThreadFeedEntry; index: number }) => (
     <Animated.View
@@ -2881,6 +2943,9 @@
                     },
                   }
             }
+            // A reflow grows every visible row at once, well past the near-end threshold
+            // that otherwise lets a status-bar tap leave the end.
+            maintainScrollAtEndThreshold={reflowFollowsEnd ? Number.POSITIVE_INFINITY : undefined}
             maintainVisibleContentPosition={
               endFollowEnabled && !disclosureToggleSettling ? false : maintainVisibleContentPosition
             }
@@ -2921,6 +2986,13 @@
             estimatedItemSize={180}
             alignItemsAtEnd
             initialScrollAtEnd
+            onLoad={() => {
+              loadedListKeyRef.current = listMountKey;
+            }}
+            // Row positions start below the header; the reflow anchor converts between them.
+            onMetricsChange={(metrics) => {
+              listHeaderSizeRef.current = metrics.headerSize;
+            }}
             onScroll={handleScroll}
             onScrollBeginDrag={handleScrollBeginDrag}
             onScrollEndDrag={handleScrollEndDrag}
@@ -2950,7 +3022,7 @@
               ) : null
             }
             contentContainerStyle={{
+              paddingTop: FEED_PADDING_TOP,
-              paddingTop: 12,
               paddingLeft: contentHorizontalPadding + contentLeftInset,
               paddingRight: contentHorizontalPadding + contentRightInset,
             }}
```

## apps/mobile/src/features/threads/thread-feed-live-follow.test.ts

Status: modified; +87; −0

```diff
@@ -2,6 +2,8 @@
 
 import {
   resolveThreadFeedLiveFollow,
+  resolveThreadFeedReflowAnchor,
+  resolveThreadFeedReflowScroll,
   resolveThreadFeedSubmissionAnchor,
   resolveThreadWorkGroupInitialScroll,
   shouldFollowThreadWorkGroupAppend,
@@ -178,3 +180,88 @@
     ).toBe(false);
   });
 });
+
+describe("width reflow anchors", () => {
+  const layout = { rowsTop: 50, headerInset: 100 };
+  const feedState = (
+    rows: ReadonlyArray<{ readonly id: string; readonly size: number }>,
+    scroll: number,
+    isWithinMaintainScrollAtEndThreshold = false,
+  ) => {
+    const positions = rows.map((_, index) =>
+      rows.slice(0, index).reduce((top, row) => top + row.size, 0),
+    );
+    return {
+      data: rows,
+      scroll,
+      isWithinMaintainScrollAtEndThreshold,
+      indexByKey: (key: string) => {
+        const index = rows.findIndex((row) => row.id === key);
+        return index < 0 ? undefined : index;
+      },
+      positionAtIndex: (index: number) => positions[index],
+      sizes: new Map(rows.map((row) => [row.id, row.size])),
+    };
+  };
+  const wide = [
+    { id: "question", size: 400 },
+    { id: "reply", size: 2_000 },
+    { id: "follow-up", size: 300 },
+  ];
+  // The line under the header sits 1,200px, 60%, into the reply.
+  const readingReply = feedState(wide, 1_550);
+
+  it("keeps the end for a feed that is following it", () => {
+    expect(resolveThreadFeedReflowAnchor(feedState(wide, 1_550, true), layout, true)).toBe("end");
+  });
+
+  it("keeps the reader's row after a status-bar scroll left follow on far from the end", () => {
+    expect(resolveThreadFeedReflowAnchor(readingReply, layout, true)).toEqual({
+      key: "reply",
+      offsetWithinRow: 1_200,
+      size: 2_000,
+    });
+  });
+
+  it.each([
+    { change: "narrows", replySize: 4_000 },
+    { change: "widens", replySize: 1_000 },
+  ])("keeps the reader 60% into a reply when the feed $change", ({ replySize }) => {
+    const anchor = resolveThreadFeedReflowAnchor(readingReply, layout, false);
+    if (anchor === null || anchor === "end") throw new Error("expected a row anchor");
+    const reflowed = feedState(
+      [
+        { id: "question", size: 400 * (replySize / 2_000) },
+        { id: "reply", size: replySize },
+        { id: "follow-up", size: 300 },
+      ],
+      readingReply.scroll,
+    );
+    expect(resolveThreadFeedReflowScroll(anchor, reflowed, layout)).toEqual({
+      index: 1,
+      viewOffset: layout.headerInset - 0.6 * replySize,
+    });
+  });
+
+  it("leaves a reader at the top of the thread where they are", () => {
+    expect(resolveThreadFeedReflowAnchor(feedState(wide, -layout.headerInset), layout, false)).toBe(
+      null,
+    );
+  });
+
+  it("does not scroll when the row did not change size", () => {
+    const anchor = resolveThreadFeedReflowAnchor(readingReply, layout, false);
+    if (anchor === null || anchor === "end") throw new Error("expected a row anchor");
+    expect(resolveThreadFeedReflowScroll(anchor, readingReply, layout)).toBeNull();
+  });
+
+  it("does not restore a row that left the feed", () => {
+    expect(
+      resolveThreadFeedReflowScroll(
+        { key: "reply", offsetWithinRow: 1_200, size: 2_000 },
+        feedState([{ id: "question", size: 400 }], 0),
+        layout,
+      ),
+    ).toBeNull();
+  });
+});
```

## apps/mobile/src/features/threads/thread-feed-live-follow.ts

Status: modified; +68; −0

```diff
@@ -1,3 +1,5 @@
+import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
+
 export type ThreadFeedLiveFollowEvent =
   | { readonly type: "reset" }
   | { readonly type: "user-scroll-begin" }
@@ -88,3 +90,69 @@
       return current;
   }
 }
+
+/** The LegendList state a width reflow reads before rows re-measure and after they settle. */
+export interface ThreadFeedReflowListState {
+  readonly data: ReadonlyArray<{ readonly id: string }>;
+  readonly scroll: number;
+  readonly isWithinMaintainScrollAtEndThreshold: boolean;
+  readonly indexByKey: (key: string) => number | undefined;
+  readonly positionAtIndex: (index: number) => number | undefined;
+  readonly sizes: ReadonlyMap<string, number>;
+}
+
+export interface ThreadFeedReflowLayout {
+  /** List padding and header; row positions start below them. */
+  readonly rowsTop: number;
+  /** The top of the viewport covered by the navigation header. */
+  readonly headerInset: number;
+}
+
+export interface ThreadFeedReflowRowAnchor {
+  readonly key: string;
+  readonly offsetWithinRow: number;
+  readonly size: number;
+}
+
+/** A feed that follows keeps the end through a reflow; a reader keeps the row they are in. */
+export type ThreadFeedReflowAnchor = "end" | ThreadFeedReflowRowAnchor;
+
+function readingLine(state: ThreadFeedReflowListState, layout: ThreadFeedReflowLayout) {
+  return state.scroll + layout.headerInset - layout.rowsTop;
+}
+
+export function resolveThreadFeedReflowAnchor(
+  state: ThreadFeedReflowListState,
+  layout: ThreadFeedReflowLayout,
+  following: boolean,
+): ThreadFeedReflowAnchor | null {
+  // Follow alone is not enough: a status-bar tap scrolls to the top without a drag, so it
+  // leaves follow on while the reader is far from the end.
+  if (following && state.isWithinMaintainScrollAtEndThreshold) return "end";
+  const line = readingLine(state, layout);
+  // A reader at the top of the thread stays there without help.
+  if (line <= 0) return null;
+  const row = resolveWorkGroupScrollAnchor({
+    data: state.data,
+    scroll: line,
+    positionAtIndex: state.positionAtIndex,
+  });
+  const size = row ? state.sizes.get(row.rowId) : undefined;
+  return row && size ? { key: row.rowId, offsetWithinRow: row.offsetWithinRow, size } : null;
+}
+
+export function resolveThreadFeedReflowScroll(
+  anchor: ThreadFeedReflowRowAnchor,
+  state: ThreadFeedReflowListState,
+  layout: ThreadFeedReflowLayout,
+): { readonly index: number; readonly viewOffset: number } | null {
+  const index = state.indexByKey(anchor.key);
+  const top = index === undefined ? undefined : state.positionAtIndex(index);
+  const size = state.sizes.get(anchor.key);
+  if (index === undefined || top === undefined || size === undefined) return null;
+  // Rewrapped text grows with its row, so the reader's line sits at the same share of the
+  // row rather than the same pixel offset into it.
+  const offsetWithinRow = (anchor.offsetWithinRow * size) / anchor.size;
+  if (Math.abs(readingLine(state, layout) - (top + offsetWithinRow)) < 1) return null;
+  return { index, viewOffset: layout.headerInset - offsetWithinRow };
+}
```

## apps/mobile/src/native/useRestingTopOffset.test.tsx

Status: added; +73; −0

```diff
@@ -1,0 +1,73 @@
+import { act, useLayoutEffect } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { useRestingTopOffset } from "./useRestingTopOffset";
+
+let root: Root | null = null;
+let latest: ReturnType<typeof useRestingTopOffset> | null = null;
+
+function Probe(props: { readonly topInset: number; readonly restoredOffset?: number }) {
+  const restingOffset = useRestingTopOffset(
+    props.topInset,
+    props.restoredOffset === undefined ? {} : { contentOffset: { x: 0, y: props.restoredOffset } },
+  );
+  useLayoutEffect(() => {
+    latest = restingOffset;
+  });
+  return null;
+}
+
+function render(topInset: number, restoredOffset?: number) {
+  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+  root ??= createRoot(document.createElement("div"));
+  act(() => root!.render(<Probe topInset={topInset} restoredOffset={restoredOffset} />));
+}
+
+function scrollTo(y: number) {
+  const event = { nativeEvent: { contentOffset: { x: 0, y } } } as Parameters<
+    ReturnType<typeof useRestingTopOffset>["onScroll"]
+  >[0];
+  act(() => latest!.onScroll(event));
+}
+
+function offset() {
+  return latest!.contentOffset.y;
+}
+
+afterEach(() => {
+  act(() => root?.unmount());
+  root = null;
+  latest = null;
+});
+
+describe("useRestingTopOffset", () => {
+  it("keeps an untouched list below the header once the column's inset arrives", () => {
+    // The folded iPhone Duo's window has no top inset; the header's arrives after mount.
+    render(0);
+    render(54);
+    expect(offset()).toBe(-54);
+  });
+
+  it("does not move a list the reader scrolled when the inset changes", () => {
+    render(54);
+    scrollTo(300);
+    render(90);
+    expect(offset()).toBe(-54);
+  });
+
+  it("follows the inset again once the list is back at the top", () => {
+    render(54);
+    scrollTo(300);
+    render(90);
+    scrollTo(-90);
+    render(54);
+    expect(offset()).toBe(-54);
+  });
+
+  it("holds a restored position through an inset change", () => {
+    render(0, 1_200);
+    render(54);
+    expect(offset()).toBe(1_200);
+  });
+});
```

## apps/mobile/src/native/useRestingTopOffset.ts

Status: added; +29; −0

```diff
@@ -1,0 +1,29 @@
+import { useState } from "react";
+import type { NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps } from "react-native";
+
+/**
+ * A scroll view with a manual top inset rests at `-topInset`, just below the header. The inset
+ * can change after mount: a column's safe area starts as the window's until UIKit measures it,
+ * and rotation or folding changes it again. While the view rests below the header it follows the
+ * inset; once scrolled away it holds the last resting offset, because RN re-applies a changed
+ * `contentOffset` prop and would move the reader.
+ */
+export function holdRestingTopOffset(held: number | null, scrollY: number, topInset: number) {
+  return Math.abs(scrollY + topInset) <= 1 ? null : (held ?? -topInset);
+}
+
+export function useRestingTopOffset(
+  topInset: number,
+  props: Pick<ScrollViewProps, "contentOffset" | "onScroll">,
+) {
+  // A list restoring a saved position mounts away from its resting offset.
+  const [held, setHeld] = useState(props.contentOffset?.y ?? null);
+  return {
+    contentOffset: { x: 0, y: held ?? -topInset },
+    onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => {
+      const scrollY = event.nativeEvent.contentOffset.y;
+      setHeld((current) => holdRestingTopOffset(current, scrollY, topInset));
+      props.onScroll?.(event);
+    },
+  };
+}
```

## apps/mobile/vite.config.ts

Status: modified; +1; −0

```diff
@@ -6,6 +6,7 @@
   "src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
   "src/features/settings/components/BranchNamingSettings.test.tsx",
   "src/native/NativeHeaderToolbar.test.tsx",
+  "src/native/useRestingTopOffset.test.tsx",
   "src/state/use-selected-thread-git-actions.test.tsx",
 ];
 
```
