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

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

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

Visibility: public

[fix\(mobile\): folding the Duo keeps the thread's end or the reader's line in place](https://git.cafe/pingdotgg/t3code/pull/23149.md)

Version: 14

Head commit: 2b3f7fbf39e2eb82d540e41553db84c46c8760f7

Base commit: fdfc6ea1c154022451eda16aed4ef66b5d19b610

Comparison base: fdfc6ea1c154022451eda16aed4ef66b5d19b610

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

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

Status: modified; +32; −12

```diff
@@ -204,6 +204,7 @@
 import {
   resolveThreadFeedLiveFollow,
   resolveThreadFeedReflowAnchor,
+  resolveThreadFeedReflowOffset,
   resolveThreadFeedReflowScroll,
   type ThreadFeedLiveFollowEvent,
   type ThreadFeedReflowAnchor,
@@ -293,6 +294,9 @@
 // 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;
+// A fold resizes the feed in steps about 200ms apart. Holding the place taken at the first step
+// across all of them keeps a passing moment off the end from re-anchoring the rest to a row.
+const FEED_REFLOW_HOLD_MS = 600;
 const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5;
 const ASSISTANT_ROW_HORIZONTAL_PADDING = 3.5;
 const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
@@ -1984,8 +1988,11 @@
   // 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.
+  // On iOS, LegendList keeps that place in the same commit as every re-measure. Android's
+  // list moves only while its MVCP is on, which following turns off, so it restores after.
   // undefined: no reflow. null: a reflow with nothing to restore.
   const reflowAnchorRef = useRef<ThreadFeedReflowAnchor | null | undefined>(undefined);
+  const reflowAnchorsSizeChanges = Platform.OS === "ios";
   const [reflowFollowsEnd, setReflowFollowsEnd] = useState(false);
   const [reflowSettleRevision, setReflowSettleRevision] = useState(0);
   const listHeaderSizeRef = useRef(0);
@@ -2482,20 +2489,19 @@
 
   const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId);
   const workGroupScrollPositions = new Map<string, ThreadWorkGroupScrollPosition>();
+  // Only a new thread or a send resets following. Listed as dependencies, these handlers also ran
+  // whenever a parent passed a new follow callback, as a fold does: following re-armed under a
-  useEffect(() => {
+  // reader and a width reflow lost its place mid-fold.
+  const resetEndFollow = useEffectEvent((_trigger: unknown) => {
     clearUserScrollSettle();
     userScrollSessionRef.current = false;
     cancelReflow();
     transitionEndFollow({ type: "reset" });
+  });
-  }, [cancelReflow, clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
+  useEffect(() => resetEndFollow(feedThreadKey), [feedThreadKey]);
   useEffect(() => {
-    if (props.submittedMessageId !== null) {
-      clearUserScrollSettle();
-      userScrollSessionRef.current = false;
-      cancelReflow();
-      transitionEndFollow({ type: "reset" });
-    }
-  }, [cancelReflow, clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
+    if (props.submittedMessageId !== null) resetEndFollow(props.submittedMessageId);
+  }, [props.submittedMessageId]);
 
   useEffect(() => {
     reportHeaderMaterialVisibility(false);
@@ -2531,7 +2537,7 @@
           ? resolveThreadFeedReflowAnchor(listState, reflowLayout(), endFollowEnabledRef.current)
           : null;
       reflowAnchorRef.current = anchor;
-      if (anchor === "end") {
+      if (anchor === "end" && !reflowAnchorsSizeChanges) {
         // 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);
@@ -2544,17 +2550,30 @@
     const anchor = reflowAnchorRef.current;
     cancelReflow();
     const list = listRef.current;
+    if (reflowAnchorsSizeChanges || !anchor || anchor === "end" || !list) return;
+    if (userScrollSessionRef.current) return;
-    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(),
-    const timeout = setTimeout(() => settleReflow(), FEED_REFLOW_SETTLE_MS);
+      reflowAnchorsSizeChanges ? FEED_REFLOW_HOLD_MS : FEED_REFLOW_SETTLE_MS,
+    );
     return () => clearTimeout(timeout);
   }, [reflowSettleRevision]);
 
+  const holdReflowAnchor = () => {
+    const anchor = reflowAnchorRef.current;
+    if (anchor === "end") return Number.POSITIVE_INFINITY;
+    const list = listRef.current;
+    return anchor && list
+      ? resolveThreadFeedReflowOffset(anchor, list.getState(), reflowLayout())
+      : undefined;
+  };
+
   const handleViewportLayout = (event: LayoutChangeEvent) => {
     const nextWidth = Math.round(event.nativeEvent.layout.width);
     const nextHeight = Math.round(event.nativeEvent.layout.height);
@@ -2946,6 +2965,7 @@
             // 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}
+            sizeChangeAnchor={reflowAnchorsSizeChanges ? holdReflowAnchor : undefined}
             maintainVisibleContentPosition={
               endFollowEnabled && !disclosureToggleSettling ? false : maintainVisibleContentPosition
             }
```

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

Status: modified; +28; −0

```diff
@@ -3,6 +3,7 @@
 import {
   resolveThreadFeedLiveFollow,
   resolveThreadFeedReflowAnchor,
+  resolveThreadFeedReflowOffset,
   resolveThreadFeedReflowScroll,
   resolveThreadFeedSubmissionAnchor,
   resolveThreadWorkGroupInitialScroll,
@@ -243,6 +244,33 @@
     });
   });
 
+  it.each([
+    { change: "narrows", replySize: 4_000 },
+    { change: "widens", replySize: 1_000 },
+  ])(
+    "holds the reader 60% into a reply on each re-measure as the feed $change",
+    ({ replySize }) => {
+      const anchor = resolveThreadFeedReflowAnchor(readingReply, layout, false);
+      if (anchor === null || anchor === "end") throw new Error("expected a row anchor");
+      const rows = [
+        { id: "question", size: 400 * (replySize / 2_000) },
+        { id: "reply", size: replySize },
+        { id: "follow-up", size: 300 },
+      ];
+      const offset = resolveThreadFeedReflowOffset(
+        anchor,
+        feedState(rows, readingReply.scroll),
+        layout,
+      );
+      if (offset === undefined) throw new Error("expected an offset");
+      expect(resolveThreadFeedReflowAnchor(feedState(rows, offset), layout, false)).toEqual({
+        key: "reply",
+        offsetWithinRow: 0.6 * replySize,
+        size: replySize,
+      });
+    },
+  );
+
   it("leaves a reader at the top of the thread where they are", () => {
     expect(resolveThreadFeedReflowAnchor(feedState(wide, -layout.headerInset), layout, false)).toBe(
       null,
```

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

Status: modified; +26; −6

```diff
@@ -141,18 +141,38 @@
   return row && size ? { key: row.rowId, offsetWithinRow: row.offsetWithinRow, size } : null;
 }
 
-export function resolveThreadFeedReflowScroll(
+function resolveReflowAnchorPoint(
   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 };
+  return { index, top, offsetWithinRow: (anchor.offsetWithinRow * size) / anchor.size };
 }
+
+/** The scroll offset that puts the reader's line back under the header at the current sizes. */
+export function resolveThreadFeedReflowOffset(
+  anchor: ThreadFeedReflowRowAnchor,
+  state: ThreadFeedReflowListState,
+  layout: ThreadFeedReflowLayout,
+): number | undefined {
+  const point = resolveReflowAnchorPoint(anchor, state);
+  return point
+    ? point.top + point.offsetWithinRow - layout.headerInset + layout.rowsTop
+    : undefined;
+}
+
+export function resolveThreadFeedReflowScroll(
+  anchor: ThreadFeedReflowRowAnchor,
+  state: ThreadFeedReflowListState,
+  layout: ThreadFeedReflowLayout,
+): { readonly index: number; readonly viewOffset: number } | null {
+  const point = resolveReflowAnchorPoint(anchor, state);
+  if (!point) return null;
+  if (Math.abs(readingLine(state, layout) - (point.top + point.offsetWithinRow)) < 1) return null;
+  return { index: point.index, viewOffset: layout.headerInset - point.offsetWithinRow };
+}
```

## patches/@legendapp\_\_list@3.3.5.patch

Status: modified; +257; −73

```diff
@@ -1,5 +1,21 @@
+diff --git a/animated.d.ts b/animated.d.ts
+index 14beb98c0bfed9c60611b7d164670798011182ac..9fb0ca056d9dda4d4abeef2bb9bafcf70d0fa243 100644
+--- a/animated.d.ts
++++ b/animated.d.ts
+@@ -284,6 +284,11 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
+      * - true enables both behaviors; false disables both.
+      */
+     maintainVisibleContentPosition?: boolean | MaintainVisibleContentPositionConfig<ItemT>;
++    /**
++     * T3 patch: the scroll offset a change in item or list size should leave, read in the same pass
++     * that repositions the items so the adjustment commits with them. `undefined` keeps the default.
++     */
++    sizeChangeAnchor?: () => number | undefined;
+     /**
+      * Keeps an item visually anchored to the start by adding trailing space when the content below it underflows.
+      */
 diff --git a/keyboard.d.ts b/keyboard.d.ts
-index 367945c..0ac7bf8 100644
+index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644
 --- a/keyboard.d.ts
 +++ b/keyboard.d.ts
 @@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {
@@ -23,7 +39,7 @@
  } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
  
 diff --git a/keyboard.js b/keyboard.js
+index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644
-index 6645bcb..321c4c3 100644
 --- a/keyboard.js
 +++ b/keyboard.js
 @@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
@@ -119,7 +135,7 @@
        renderScrollComponent: memoList,
        ...rest
 diff --git a/keyboard.mjs b/keyboard.mjs
-index 87b38b9..111eb24 100644
+index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644
 --- a/keyboard.mjs
 +++ b/keyboard.mjs
 @@ -1,7 +1,7 @@
@@ -224,10 +240,22 @@
        renderScrollComponent: memoList,
        ...rest
 diff --git a/react-native.d.ts b/react-native.d.ts
+index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..ca6e596aa53327a097a388ffd545a4c6191f24e6 100644
-index ce1fe00..3ccf6f1 100644
 --- a/react-native.d.ts
 +++ b/react-native.d.ts
+@@ -284,6 +284,11 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
+      * - true enables both behaviors; false disables both.
+      */
+     maintainVisibleContentPosition?: boolean | MaintainVisibleContentPositionConfig<ItemT>;
++    /**
++     * T3 patch: the scroll offset a change in item or list size should leave, read in the same pass
++     * that repositions the items so the adjustment commits with them. `undefined` keeps the default.
++     */
++    sizeChangeAnchor?: () => number | undefined;
+     /**
+      * Keeps an item visually anchored to the start by adding trailing space when the content below it underflows.
-@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
+      */
+@@ -293,6 +298,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
       * The adjustment is also rendered as real content padding so the browser scroll range includes it.
       */
      contentInsetEndAdjustment?: number;
@@ -241,7 +269,7 @@
       * Number of columns to render items in.
       * @default 1
 diff --git a/react-native.js b/react-native.js
+index b3c5a306b293f797a8b338adfca3060c0f6db22b..3c67a536d850d8c508336cf0f10c0c898ea67f12 100644
-index b3c5a30..5ac6bbe 100644
 --- a/react-native.js
 +++ b/react-native.js
 @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) {
@@ -358,7 +386,7 @@
          scheduleFallbackCheck(100);
        } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
          finishScrollTo(ctx);
-@@ -1560,15 +1590,28 @@ function doMaintainScrollAtEnd(ctx) {
+@@ -1560,15 +1590,33 @@ function doMaintainScrollAtEnd(ctx) {
    } = state;
    const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
    const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
@@ -370,6 +398,11 @@
      state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
      return false;
    }
++  // T3 patch: a caller anchor already placed this pass's offset; a later scroll would move it.
++  if (state.props.sizeChangeAnchor && !state.mvcpDataChanged && state.props.sizeChangeAnchor() !== void 0) {
++    state.pendingMaintainScrollAtEnd = false;
++    return shouldMaintainScrollAtEnd;
++  }
    if (shouldMaintainScrollAtEnd) {
      state.pendingMaintainScrollAtEnd = false;
 +    const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
@@ -388,7 +421,7 @@
      }
      if (!state.maintainingScrollAtEnd) {
        const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
+@@ -1591,9 +1639,18 @@ function doMaintainScrollAtEnd(ctx) {
-@@ -1591,9 +1634,18 @@ function doMaintainScrollAtEnd(ctx) {
                y: 0
              });
            } else {
@@ -410,7 +443,7 @@
            }
            setTimeout(
              () => {
+@@ -1624,6 +1681,10 @@ function doMaintainScrollAtEnd(ctx) {
-@@ -1624,6 +1676,10 @@ function doMaintainScrollAtEnd(ctx) {
  function requestAdjust(ctx, positionDiff, dataChanged) {
    const state = ctx.state;
    if (Math.abs(positionDiff) > 0.1) {
@@ -421,7 +454,7 @@
      const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
      const doit = () => {
        if (needsScrollWorkaround) {
-@@ -1728,7 +1784,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
+@@ -1728,7 +1789,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
    if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
      return 0;
    }
@@ -432,7 +465,7 @@
    const clampDelta = maxScroll - state.scroll;
    if (unresolvedAmount < 0) {
      return Math.max(unresolvedAmount, Math.min(0, clampDelta));
-@@ -1790,7 +1848,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
+@@ -1790,7 +1853,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
      settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
      return true;
    }
@@ -441,7 +474,43 @@
    const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
    const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
    if (isAtExpectedNativeClamp) {
+@@ -1811,7 +1874,35 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
+   }
+   return false;
+ }
++// T3 patch: a caller anchor decides where a size change leaves the scroll position. It is read in
++// the same pass that repositions the items, so the adjustment commits with the new positions.
+ function prepareMVCP(ctx, dataChanged) {
++  // T3 patch: a data change skips the caller anchor, so end maintenance must still run after it.
++  ctx.state.mvcpDataChanged = !!dataChanged;
++  const sizeChangeAnchor = dataChanged ? void 0 : ctx.state.props.sizeChangeAnchor;
++  const restore = prepareDefaultMVCP(ctx, dataChanged);
++  if (!sizeChangeAnchor || ctx.state.scrollingTo !== void 0) {
++    return restore;
++  }
++  return () => {
++    const target = sizeChangeAnchor();
++    if (target === void 0) {
++      restore == null ? void 0 : restore();
++      return;
++    }
++    // Content that shrinks under an offset past its new end pulls the native offset back to
++    // that end by itself; only the rest of the way to the target is adjusted.
++    const endOffset = clampScrollOffset(ctx, Number.POSITIVE_INFINITY);
++    if (ctx.state.scroll > endOffset) {
++      ctx.state.scroll = endOffset;
++    }
++    const positionDiff = clampScrollOffset(ctx, target) - ctx.state.scroll;
++    if (Math.abs(positionDiff) > MVCP_POSITION_EPSILON) {
++      requestAdjust(ctx, positionDiff, false);
++    }
++  };
++}
++function prepareDefaultMVCP(ctx, dataChanged) {
+   const state = ctx.state;
+   const { idsInView, positions, props } = state;
+   const {
+@@ -1923,7 +2014,7 @@ function prepareMVCP(ctx, dataChanged) {
-@@ -1923,7 +1981,7 @@ function prepareMVCP(ctx, dataChanged) {
              if (diff > 0) {
                diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
              } else {
@@ -450,7 +519,7 @@
                state.scroll = maxScroll;
                state.scrollPending = maxScroll;
                diff = 0;
+@@ -2320,8 +2411,121 @@ function scrollToIndex(ctx, {
-@@ -2320,8 +2378,121 @@ function scrollToIndex(ctx, {
  }
  
  // src/core/initialScroll.ts
@@ -572,7 +641,7 @@
    const requestedIndex = target.index;
    const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
    const itemSize = getItemSizeAtIndex(ctx, index);
+@@ -2747,7 +2951,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
-@@ -2747,7 +2918,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
      return;
    }
    if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
@@ -583,7 +652,7 @@
      if (!shouldKeepEndTargetAlive) {
        if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
          clearPendingInitialScrollFooterLayout(ctx, {
-@@ -4672,7 +4845,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
+@@ -4672,7 +4878,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
        contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
        contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
        isReady = !hasUnknownTailSize;
@@ -593,7 +662,7 @@
      } else if (anchorIndex >= 0) {
        isReady = false;
      }
-@@ -4692,6 +4866,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
+@@ -4692,6 +4899,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
        updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
      }
      (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
@@ -606,15 +675,20 @@
    }
    return nextSize;
  }
+@@ -5715,8 +5928,11 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
-@@ -5715,6 +5895,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
    horizontal
  }) {
    const ctx = useStateContext();
 +  const SizeComponent = ctx.state.props.sizeComponentInternal;
    const columnWrapperStyle = ctx.columnWrapperStyle;
+-  const animSize = useValue$("totalSize");
++  // T3 patch: on the new architecture an animated value commits on its own, ahead of the item
++  // positions and their scroll compensation; a rendered size commits with them.
++  const animSize = IsNewArchitecture ? useArr$(["totalSize"])[0] || 0 : useValue$("totalSize");
-   const animSize = useValue$("totalSize");
    const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
-@@ -5725,6 +5906,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
+   const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
+   const style = horizontal ? {
+@@ -5725,6 +5941,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
      opacity: isVisible ? 1 : 0,
      width: animSize
    } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
@@ -628,7 +702,7 @@
    if (columnWrapperStyle) {
      const { columnGap, rowGap, gap } = columnWrapperStyle;
      const gapX = columnGap || gap || 0;
-@@ -5745,7 +5933,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
+@@ -5745,7 +5968,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
        }
      }
    }
@@ -638,7 +712,7 @@
  });
  var Containers = typedMemo(function Containers2({
    freshDataTransitionEpoch,
-@@ -5896,7 +6085,12 @@ var StyleSheet = ReactNative.StyleSheet;
+@@ -5896,7 +6120,12 @@ var StyleSheet = ReactNative.StyleSheet;
  
  // src/components/ListComponent.tsx
  var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
@@ -651,7 +725,7 @@
    if (alignItemsAtEndPadding <= 0) {
      return null;
    }
+@@ -5929,8 +6158,12 @@ var ListComponent = typedMemo(function ListComponent2({
-@@ -5929,8 +6123,12 @@ var ListComponent = typedMemo(function ListComponent2({
    refScrollView,
    renderScrollComponent,
    onLayoutFooter,
@@ -664,7 +738,7 @@
    scrollAdjustHandler,
    snapToIndices,
    stickyHeaderConfig,
-@@ -6001,7 +6199,17 @@ var ListComponent = typedMemo(function ListComponent2({
+@@ -6001,7 +6234,17 @@ var ListComponent = typedMemo(function ListComponent2({
      SnapOrScroll,
      {
        ...rest,
@@ -683,7 +757,7 @@
        contentContainerStyle: [
          horizontal ? { height: "100%" } : {},
          contentContainerStyle,
+@@ -6010,7 +6253,10 @@ var ListComponent = typedMemo(function ListComponent2({
-@@ -6010,7 +6218,10 @@ var ListComponent = typedMemo(function ListComponent2({
        ],
        contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0,
        horizontal,
@@ -695,7 +769,17 @@
        onLayout,
        onScroll: onScroll2,
        ref: refScrollView,
-@@ -6751,7 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
+@@ -6267,7 +6513,8 @@ function handleLayout(ctx, layoutParam, setCanRender) {
+     if (scrollLength > 0) {
+       doInitialAllocateContainers(ctx);
+     }
+-    if (needsCalculate) {
++    // T3 patch: a shorter list keeps a caller anchor too, such as the end.
++    if (needsCalculate || state.props.sizeChangeAnchor) {
+       calculateItemsInView(ctx, { doMVCP: true });
+     }
+     if (didChange || otherAxisSize !== prevOtherAxisSize) {
+@@ -6751,7 +6998,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
        endBuffered: state.endBuffered,
        getAverageItemSizes: () => getAverageItemSizes(state),
        indexByKey: (key) => state.indexByKey.get(key),
@@ -704,7 +788,7 @@
        isAtStart: peek$(ctx, "isAtStart"),
        isEndReached: state.isEndReached,
        isNearEnd: peek$(ctx, "isNearEnd"),
+@@ -7075,6 +7322,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7075,6 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      dataVersion,
      drawDistance = 250,
      contentInsetEndAdjustment,
@@ -712,7 +796,15 @@
      estimatedItemSize = 100,
      estimatedListSize,
      extraData,
+@@ -7096,6 +7344,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+     maintainScrollAtEnd = false,
+     maintainScrollAtEndThreshold = 0.1,
+     maintainVisibleContentPosition: maintainVisibleContentPositionProp,
++    sizeChangeAnchor,
+     numColumns: numColumnsProp = 1,
+     overrideItemLayout,
+     onEndReached,
+@@ -7132,10 +7381,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7132,10 +7344,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
    const animatedPropsInternal = props.animatedPropsInternal;
    const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
    const positionComponentInternal = props.positionComponentInternal;
@@ -725,7 +817,7 @@
      stickyPositionComponentInternal: _stickyPositionComponentInternal,
      ...restProps
    } = rest;
+@@ -7200,7 +7451,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7200,7 +7414,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
    const combinedRef = useCombinedRef(refScroller, refScrollView);
    const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
    const stickyHeaderIndices = stickyHeaderIndicesProp;
@@ -734,7 +826,7 @@
    const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
    const alwaysRenderIndices = React2.useMemo(() => {
      const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
-@@ -7341,6 +7555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7341,6 +7592,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      contentContainerAlignItems: contentContainerStyle.alignItems,
      contentInset,
      contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
@@ -742,7 +834,15 @@
      data: dataProp,
      dataKey,
      dataVersion,
+@@ -7354,6 +7606,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+     maintainScrollAtEnd: maintainScrollAtEndConfig,
+     maintainScrollAtEndThreshold,
-@@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+     maintainVisibleContentPosition: maintainVisibleContentPositionConfig,
++    sizeChangeAnchor,
+     numColumns: numColumnsProp,
+     onEndReached,
+     onEndReachedThreshold,
+@@ -7372,6 +7625,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      renderItem,
      rtl,
      snapToIndices,
@@ -750,7 +850,7 @@
      stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
      stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
      stickyPositionComponentInternal,
-@@ -7423,6 +7639,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7423,6 +7677,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
        return void 0;
      }
      const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
@@ -764,7 +864,7 @@
      return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
    }, [usesBootstrapInitialScroll]);
    React2.useLayoutEffect(() => {
+@@ -7547,6 +7808,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7547,6 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      [
        dataKey,
        dataVersion,
@@ -772,7 +872,7 @@
        memoizedLastItemKeys.join(","),
        numColumnsProp,
        nextScrollAxisGap,
-@@ -7643,6 +7867,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7643,6 +7905,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      () => ({
        getRenderedItem: (key) => getRenderedItem(ctx, key),
        onMomentumScrollEnd: (event) => {
@@ -780,7 +880,7 @@
          checkFinishedScrollFallback(ctx);
          if (state.props.onMomentumScrollEnd) {
            state.props.onMomentumScrollEnd(event);
+@@ -7651,6 +7914,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7651,6 +7876,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
        onScroll: (event) => onScroll(ctx, event),
        onScrollBeginDrag: (event) => {
          var _a4, _b2;
@@ -789,7 +889,7 @@
          prepareReachedEdgeForNextUserScroll(ctx);
          (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
        },
-@@ -7676,11 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7676,11 +7941,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
        ListFooterComponent,
        ListFooterComponentStyle,
        ListHeaderComponent,
@@ -809,7 +909,7 @@
        recycleItems,
        refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
 diff --git a/react-native.mjs b/react-native.mjs
+index 40e87cda8c9bc79a889e5542f29af429a24b24d4..c1b68df0f53d566c98a4820b39079396a09f1208 100644
-index 40e87cd..93aac74 100644
 --- a/react-native.mjs
 +++ b/react-native.mjs
 @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) {
@@ -926,7 +1026,7 @@
          scheduleFallbackCheck(100);
        } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
          finishScrollTo(ctx);
+@@ -1539,15 +1569,33 @@ function doMaintainScrollAtEnd(ctx) {
-@@ -1539,15 +1569,28 @@ function doMaintainScrollAtEnd(ctx) {
    } = state;
    const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
    const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
@@ -938,6 +1038,11 @@
      state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
      return false;
    }
++  // T3 patch: a caller anchor already placed this pass's offset; a later scroll would move it.
++  if (state.props.sizeChangeAnchor && !state.mvcpDataChanged && state.props.sizeChangeAnchor() !== void 0) {
++    state.pendingMaintainScrollAtEnd = false;
++    return shouldMaintainScrollAtEnd;
++  }
    if (shouldMaintainScrollAtEnd) {
      state.pendingMaintainScrollAtEnd = false;
 +    const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
@@ -956,7 +1061,7 @@
      }
      if (!state.maintainingScrollAtEnd) {
        const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
-@@ -1570,9 +1613,18 @@ function doMaintainScrollAtEnd(ctx) {
+@@ -1570,9 +1618,18 @@ function doMaintainScrollAtEnd(ctx) {
                y: 0
              });
            } else {
@@ -978,7 +1083,7 @@
            }
            setTimeout(
              () => {
-@@ -1603,6 +1655,10 @@ function doMaintainScrollAtEnd(ctx) {
+@@ -1603,6 +1660,10 @@ function doMaintainScrollAtEnd(ctx) {
  function requestAdjust(ctx, positionDiff, dataChanged) {
    const state = ctx.state;
    if (Math.abs(positionDiff) > 0.1) {
@@ -989,7 +1094,7 @@
      const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
      const doit = () => {
        if (needsScrollWorkaround) {
+@@ -1707,7 +1768,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
-@@ -1707,7 +1763,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
    if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
      return 0;
    }
@@ -1000,7 +1105,7 @@
    const clampDelta = maxScroll - state.scroll;
    if (unresolvedAmount < 0) {
      return Math.max(unresolvedAmount, Math.min(0, clampDelta));
-@@ -1769,7 +1827,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
+@@ -1769,7 +1832,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
      settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
      return true;
    }
@@ -1009,7 +1114,43 @@
    const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
    const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
    if (isAtExpectedNativeClamp) {
+@@ -1790,7 +1853,35 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
+   }
+   return false;
+ }
++// T3 patch: a caller anchor decides where a size change leaves the scroll position. It is read in
++// the same pass that repositions the items, so the adjustment commits with the new positions.
+ function prepareMVCP(ctx, dataChanged) {
++  // T3 patch: a data change skips the caller anchor, so end maintenance must still run after it.
++  ctx.state.mvcpDataChanged = !!dataChanged;
++  const sizeChangeAnchor = dataChanged ? void 0 : ctx.state.props.sizeChangeAnchor;
++  const restore = prepareDefaultMVCP(ctx, dataChanged);
++  if (!sizeChangeAnchor || ctx.state.scrollingTo !== void 0) {
++    return restore;
++  }
++  return () => {
++    const target = sizeChangeAnchor();
++    if (target === void 0) {
++      restore == null ? void 0 : restore();
++      return;
++    }
++    // Content that shrinks under an offset past its new end pulls the native offset back to
++    // that end by itself; only the rest of the way to the target is adjusted.
++    const endOffset = clampScrollOffset(ctx, Number.POSITIVE_INFINITY);
++    if (ctx.state.scroll > endOffset) {
++      ctx.state.scroll = endOffset;
++    }
++    const positionDiff = clampScrollOffset(ctx, target) - ctx.state.scroll;
++    if (Math.abs(positionDiff) > MVCP_POSITION_EPSILON) {
++      requestAdjust(ctx, positionDiff, false);
++    }
++  };
++}
++function prepareDefaultMVCP(ctx, dataChanged) {
+   const state = ctx.state;
-@@ -1902,7 +1960,7 @@ function prepareMVCP(ctx, dataChanged) {
+   const { idsInView, positions, props } = state;
+   const {
+@@ -1902,7 +1993,7 @@ function prepareMVCP(ctx, dataChanged) {
              if (diff > 0) {
                diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
              } else {
@@ -1018,7 +1159,7 @@
                state.scroll = maxScroll;
                state.scrollPending = maxScroll;
                diff = 0;
+@@ -2299,8 +2390,121 @@ function scrollToIndex(ctx, {
-@@ -2299,8 +2357,121 @@ function scrollToIndex(ctx, {
  }
  
  // src/core/initialScroll.ts
@@ -1140,7 +1281,7 @@
    const requestedIndex = target.index;
    const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
    const itemSize = getItemSizeAtIndex(ctx, index);
+@@ -2726,7 +2930,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
-@@ -2726,7 +2897,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
      return;
    }
    if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
@@ -1151,7 +1292,7 @@
      if (!shouldKeepEndTargetAlive) {
        if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
          clearPendingInitialScrollFooterLayout(ctx, {
+@@ -4651,7 +4857,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
-@@ -4651,7 +4824,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
        contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
        contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
        isReady = !hasUnknownTailSize;
@@ -1161,7 +1302,7 @@
      } else if (anchorIndex >= 0) {
        isReady = false;
      }
-@@ -4671,6 +4845,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
+@@ -4671,6 +4878,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
        updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
      }
      (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
@@ -1174,15 +1315,20 @@
    }
    return nextSize;
  }
-@@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
+@@ -5694,8 +5907,11 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
    horizontal
  }) {
    const ctx = useStateContext();
 +  const SizeComponent = ctx.state.props.sizeComponentInternal;
    const columnWrapperStyle = ctx.columnWrapperStyle;
+-  const animSize = useValue$("totalSize");
-   const animSize = useValue$("totalSize");
++  // T3 patch: on the new architecture an animated value commits on its own, ahead of the item
++  // positions and their scroll compensation; a rendered size commits with them.
++  const animSize = IsNewArchitecture ? useArr$(["totalSize"])[0] || 0 : useValue$("totalSize");
    const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
+   const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
-@@ -5704,6 +5885,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
+   const style = horizontal ? {
+@@ -5704,6 +5920,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
      opacity: isVisible ? 1 : 0,
      width: animSize
    } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
@@ -1196,7 +1342,7 @@
    if (columnWrapperStyle) {
      const { columnGap, rowGap, gap } = columnWrapperStyle;
      const gapX = columnGap || gap || 0;
+@@ -5724,7 +5947,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
-@@ -5724,7 +5912,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
        }
      }
    }
@@ -1206,7 +1352,7 @@
  });
  var Containers = typedMemo(function Containers2({
    freshDataTransitionEpoch,
-@@ -5875,7 +6064,12 @@ var StyleSheet = StyleSheet$1;
+@@ -5875,7 +6099,12 @@ var StyleSheet = StyleSheet$1;
  
  // src/components/ListComponent.tsx
  var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
@@ -1219,7 +1365,7 @@
    if (alignItemsAtEndPadding <= 0) {
      return null;
    }
-@@ -5908,8 +6102,12 @@ var ListComponent = typedMemo(function ListComponent2({
+@@ -5908,8 +6137,12 @@ var ListComponent = typedMemo(function ListComponent2({
    refScrollView,
    renderScrollComponent,
    onLayoutFooter,
@@ -1232,7 +1378,7 @@
    scrollAdjustHandler,
    snapToIndices,
    stickyHeaderConfig,
-@@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({
+@@ -5980,7 +6213,17 @@ var ListComponent = typedMemo(function ListComponent2({
      SnapOrScroll,
      {
        ...rest,
@@ -1251,7 +1397,7 @@
        contentContainerStyle: [
          horizontal ? { height: "100%" } : {},
          contentContainerStyle,
+@@ -5989,7 +6232,10 @@ var ListComponent = typedMemo(function ListComponent2({
-@@ -5989,7 +6197,10 @@ var ListComponent = typedMemo(function ListComponent2({
        ],
        contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0,
        horizontal,
@@ -1263,7 +1409,17 @@
        onLayout,
        onScroll: onScroll2,
        ref: refScrollView,
+@@ -6246,7 +6492,8 @@ function handleLayout(ctx, layoutParam, setCanRender) {
+     if (scrollLength > 0) {
+       doInitialAllocateContainers(ctx);
+     }
+-    if (needsCalculate) {
++    // T3 patch: a shorter list keeps a caller anchor too, such as the end.
++    if (needsCalculate || state.props.sizeChangeAnchor) {
+       calculateItemsInView(ctx, { doMVCP: true });
-@@ -6730,7 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
+     }
+     if (didChange || otherAxisSize !== prevOtherAxisSize) {
+@@ -6730,7 +6977,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
        endBuffered: state.endBuffered,
        getAverageItemSizes: () => getAverageItemSizes(state),
        indexByKey: (key) => state.indexByKey.get(key),
@@ -1272,7 +1428,7 @@
        isAtStart: peek$(ctx, "isAtStart"),
        isEndReached: state.isEndReached,
        isNearEnd: peek$(ctx, "isNearEnd"),
+@@ -7054,6 +7301,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7054,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      dataVersion,
      drawDistance = 250,
      contentInsetEndAdjustment,
@@ -1280,7 +1436,15 @@
      estimatedItemSize = 100,
      estimatedListSize,
      extraData,
+@@ -7075,6 +7323,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+     maintainScrollAtEnd = false,
+     maintainScrollAtEndThreshold = 0.1,
+     maintainVisibleContentPosition: maintainVisibleContentPositionProp,
-@@ -7111,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
++    sizeChangeAnchor,
+     numColumns: numColumnsProp = 1,
+     overrideItemLayout,
+     onEndReached,
+@@ -7111,10 +7360,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
    const animatedPropsInternal = props.animatedPropsInternal;
    const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
    const positionComponentInternal = props.positionComponentInternal;
@@ -1293,7 +1457,7 @@
      stickyPositionComponentInternal: _stickyPositionComponentInternal,
      ...restProps
    } = rest;
-@@ -7179,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7179,7 +7430,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
    const combinedRef = useCombinedRef(refScroller, refScrollView);
    const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
    const stickyHeaderIndices = stickyHeaderIndicesProp;
@@ -1302,7 +1466,7 @@
    const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
    const alwaysRenderIndices = useMemo(() => {
      const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
+@@ -7320,6 +7571,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7320,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      contentContainerAlignItems: contentContainerStyle.alignItems,
      contentInset,
      contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
@@ -1310,7 +1474,15 @@
      data: dataProp,
      dataKey,
      dataVersion,
-@@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7333,6 +7585,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+     maintainScrollAtEnd: maintainScrollAtEndConfig,
+     maintainScrollAtEndThreshold,
+     maintainVisibleContentPosition: maintainVisibleContentPositionConfig,
++    sizeChangeAnchor,
+     numColumns: numColumnsProp,
+     onEndReached,
+     onEndReachedThreshold,
+@@ -7351,6 +7604,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      renderItem,
      rtl,
      snapToIndices,
@@ -1318,7 +1490,7 @@
      stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
      stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
      stickyPositionComponentInternal,
-@@ -7402,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7402,6 +7656,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
        return void 0;
      }
      const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
@@ -1332,7 +1504,7 @@
      return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
    }, [usesBootstrapInitialScroll]);
    useLayoutEffect(() => {
+@@ -7526,6 +7787,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7526,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      [
        dataKey,
        dataVersion,
@@ -1340,7 +1512,7 @@
        memoizedLastItemKeys.join(","),
        numColumnsProp,
        nextScrollAxisGap,
+@@ -7622,6 +7884,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
-@@ -7622,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
      () => ({
        getRenderedItem: (key) => getRenderedItem(ctx, key),
        onMomentumScrollEnd: (event) => {
@@ -1348,7 +1520,7 @@
          checkFinishedScrollFallback(ctx);
          if (state.props.onMomentumScrollEnd) {
            state.props.onMomentumScrollEnd(event);
-@@ -7630,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7630,6 +7893,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
        onScroll: (event) => onScroll(ctx, event),
        onScrollBeginDrag: (event) => {
          var _a4, _b2;
@@ -1357,7 +1529,7 @@
          prepareReachedEdgeForNextUserScroll(ctx);
          (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
        },
-@@ -7655,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+@@ -7655,11 +7920,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
        ListFooterComponent,
        ListFooterComponentStyle,
        ListHeaderComponent,
@@ -1377,7 +1549,7 @@
        recycleItems,
        refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
 diff --git a/react-native.web.js b/react-native.web.js
+index 914d2dafaafa001c9ab6791a0a0581659295e333..8b88b09d1269acef77c6ce0f8aaa1049d4fa26e9 100644
-index 914d2da..8b88b09 100644
 --- a/react-native.web.js
 +++ b/react-native.web.js
 @@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
@@ -1412,7 +1584,7 @@
      }
    }
 diff --git a/react-native.web.mjs b/react-native.web.mjs
-index 95465f2..d7cbe1c 100644
+index 95465f2ab89ce41a10553f58af83618f7310e83c..d7cbe1cb43449cc29822b2646deabbddd67f1e7d 100644
 --- a/react-native.web.mjs
 +++ b/react-native.web.mjs
 @@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
@@ -1447,7 +1619,7 @@
      }
    }
 diff --git a/react.js b/react.js
+index 914d2dafaafa001c9ab6791a0a0581659295e333..86dbd9a5c7345577e4a8a435041ecb68257ae9f1 100644
-index 914d2da..86dbd9a 100644
 --- a/react.js
 +++ b/react.js
 @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
@@ -1525,7 +1697,7 @@
              scrollBy();
              if (resetPaddingRafRef.current !== void 0) {
 diff --git a/react.mjs b/react.mjs
-index 95465f2..cff49dc 100644
+index 95465f2ab89ce41a10553f58af83618f7310e83c..cff49dc5c769cab264b8527a268851295c44aa33 100644
 --- a/react.mjs
 +++ b/react.mjs
 @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
@@ -1603,10 +1775,22 @@
              scrollBy();
              if (resetPaddingRafRef.current !== void 0) {
 diff --git a/reanimated.d.ts b/reanimated.d.ts
-index e504332..2ce6383 100644
+index e5043320700b12f34f4c0babbc341f85ca8135c1..e8cbd461325a2e2e9ffd61de26a85ae4d108555c 100644
 --- a/reanimated.d.ts
 +++ b/reanimated.d.ts
+@@ -285,6 +285,11 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
-@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
+      * - true enables both behaviors; false disables both.
+      */
+     maintainVisibleContentPosition?: boolean | MaintainVisibleContentPositionConfig<ItemT>;
++    /**
++     * T3 patch: the scroll offset a change in item or list size should leave, read in the same pass
++     * that repositions the items so the adjustment commits with them. `undefined` keeps the default.
++     */
++    sizeChangeAnchor?: () => number | undefined;
+     /**
+      * Keeps an item visually anchored to the start by adding trailing space when the content below it underflows.
+      */
+@@ -294,6 +299,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
       * The adjustment is also rendered as real content padding so the browser scroll range includes it.
       */
      contentInsetEndAdjustment?: number;
@@ -1620,7 +1804,7 @@
       * Number of columns to render items in.
       * @default 1
 diff --git a/reanimated.js b/reanimated.js
+index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644
-index f1265fa..fc03be2 100644
 --- a/reanimated.js
 +++ b/reanimated.js
 @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
@@ -1783,7 +1967,7 @@
          renderScrollComponent: renderReanimatedScrollComponent,
          ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}
 diff --git a/reanimated.mjs b/reanimated.mjs
-index 29a00d5..6ab9292 100644
+index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644
 --- a/reanimated.mjs
 +++ b/reanimated.mjs
 @@ -1,7 +1,7 @@
```

## pnpm-lock.yaml

Status: modified; +4; −4

```diff
@@ -333,7 +333,7 @@
   '@expo/metro-config@58.0.14': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760
   '@expo/ui@58.0.20': 5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e
   '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c
-  '@legendapp/list@3.3.5': e6383c516a0d67b65333d6fd38d2203734bf24b054b0b0767b034189205aed32
+  '@legendapp/list@3.3.5': 7b62e3a8814279aef02146d986ba3b50763ffd04e38ff36ce132e2065600ade3
   '@pierre/diffs@1.5.2': 2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc
   '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
   '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba
@@ -515,7 +515,7 @@
         version: 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)
       '@legendapp/list':
         specifier: 'catalog:'
-        version: 3.3.5(patch_hash=e6383c516a0d67b65333d6fd38d2203734bf24b054b0b0767b034189205aed32)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)
+        version: 3.3.5(patch_hash=7b62e3a8814279aef02146d986ba3b50763ffd04e38ff36ce132e2065600ade3)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)
       '@material/material-color-utilities':
         specifier: 0.3.0
         version: 0.3.0
@@ -1012,7 +1012,7 @@
         version: 0.9.0
       '@legendapp/list':
         specifier: 'catalog:'
-        version: 3.3.5(patch_hash=e6383c516a0d67b65333d6fd38d2203734bf24b054b0b0767b034189205aed32)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)
+        version: 3.3.5(patch_hash=7b62e3a8814279aef02146d986ba3b50763ffd04e38ff36ce132e2065600ade3)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)
       '@noble/hashes':
         specifier: 'catalog:'
         version: 1.8.0
@@ -14926,7 +14926,7 @@
     dependencies:
       jsbi: 4.3.2
 
-  '@legendapp/list@3.3.5(patch_hash=e6383c516a0d67b65333d6fd38d2203734bf24b054b0b0767b034189205aed32)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)':
+  '@legendapp/list@3.3.5(patch_hash=7b62e3a8814279aef02146d986ba3b50763ffd04e38ff36ce132e2065600ade3)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)':
     dependencies:
       react: 19.3.0
       use-sync-external-store: 1.6.0(react@19.3.0)
```
