1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 367945c..0ac7bf8 100644
3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts
5@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {
6 type KeyboardChatComposerRef = {
7 current: Pick<View, "measure"> | null;
8 };
9-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): {
10+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number, animationDuration?: number): {
11 contentInsetEndAdjustment: SharedValue<number>;
12 onComposerLayout: (event: LayoutChangeEvent) => void;
13 };
14@@ -288,8 +288,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb
15 scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>;
16 };
17 declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & {
18+ adjustedInsetCompensation?: number;
19 anchoredEndSpace?: AnchoredEndSpaceConfig;
20 contentInsetEndAdjustment?: SharedValue<number>;
21+ contentInsetEndStaticAdjustment?: number;
22 keyboardOffset?: number;
23 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
24
25diff --git a/keyboard.js b/keyboard.js
26index 6645bcb..321c4c3 100644
27--- a/keyboard.js
28+++ b/keyboard.js
29@@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
30 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
31 );
32 }
33-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
34+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) {
35 const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);
36 const lastHeightRef = React.useRef(void 0);
37 const reportHeight = React.useCallback(
38- (height) => {
39+ (rawHeight) => {
40+ const height = Math.max(0, rawHeight + heightAdjustment);
41 var _a;
42 if (Number.isFinite(height) && height !== lastHeightRef.current) {
43+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
44 lastHeightRef.current = height;
45- contentInsetEndAdjustment.value = height;
46- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
47+ contentInsetEndAdjustment.value = shouldAnimate ? reactNativeReanimated.withTiming(height, {
48+ duration: animationDuration
49+ }) : height;
50 }
51 },
52- [contentInsetEndAdjustment, listRef]
53+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
54 );
55 React.useLayoutEffect(() => {
56 var _a;
57@@ -84,9 +87,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
58 }
59 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
60 const {
61+ adjustedInsetCompensation,
62 anchoredEndSpace,
63 applyWorkaroundForContentInsetHitTestBug,
64 contentInsetEndAdjustment,
65+ contentInsetEndStaticAdjustment,
66 freeze,
67 keyboardLiftBehavior,
68 keyboardOffset,
69@@ -94,6 +99,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
70 } = props;
71 const refLegendList = React.useRef(null);
72 const combinedRef = useCombinedRef(forwardedRef, refLegendList);
73+ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment;
74 const blankSpace = reactNativeReanimated.useSharedValue(0);
75 React.useEffect(() => {
76 if (!anchoredEndSpace) {
77@@ -108,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
78 ...anchoredEndSpace,
79 onSizeChanged: (size) => {
80 var _a;
81- blankSpace.value = size;
82+ // The anchored blank is sized against the ADJUSTED viewport, but the
83+ // scroll component writes it as a raw contentInset that UIKit tops up
84+ // with the safe-area bottom — write it net of that or the end rest
85+ // (and the anchored message) sinks one safe-area under the header.
86+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
87 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
88 }
89 };
90- }, [anchoredEndSpace, blankSpace]);
91+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
92 const onContentInsetChange = React.useCallback((insets) => {
93 var _a;
94 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
95@@ -123,6 +133,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
96 reactNativeKeyboardController.KeyboardChatScrollView,
97 {
98 ...scrollProps,
99+ adjustedInsetCompensation,
100+ adjustedStartInsetCompensation,
101 applyWorkaroundForContentInsetHitTestBug,
102 blankSpace,
103 extraContentPadding: contentInsetEndAdjustment,
104@@ -134,6 +146,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
105 );
106 },
107 [
108+ adjustedInsetCompensation,
109+ adjustedStartInsetCompensation,
110 applyWorkaroundForContentInsetHitTestBug,
111 blankSpace,
112 contentInsetEndAdjustment,
113@@ -149,6 +163,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
114 {
115 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
116 anchoredEndSpaceOwnerInternal: "scroll",
117+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
118 ref: combinedRef,
119 renderScrollComponent: memoList,
120 ...rest
121diff --git a/keyboard.mjs b/keyboard.mjs
122index 87b38b9..111eb24 100644
123--- a/keyboard.mjs
124+++ b/keyboard.mjs
125@@ -1,7 +1,7 @@
126 import * as React from 'react';
127 import { useRef, useEffect, useMemo, useCallback, useLayoutEffect } from 'react';
128 import { KeyboardChatScrollView, KeyboardController } from 'react-native-keyboard-controller';
129-import { useSharedValue } from 'react-native-reanimated';
130+import { useSharedValue, withTiming } from 'react-native-reanimated';
131 import { internal } from '@legendapp/list/react-native';
132 import { AnimatedLegendList } from '@legendapp/list/reanimated';
133
134@@ -12,19 +12,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
135 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
136 );
137 }
138-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
139+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) {
140 const contentInsetEndAdjustment = useSharedValue(initialHeight);
141 const lastHeightRef = useRef(void 0);
142 const reportHeight = useCallback(
143- (height) => {
144+ (rawHeight) => {
145+ const height = Math.max(0, rawHeight + heightAdjustment);
146 var _a;
147 if (Number.isFinite(height) && height !== lastHeightRef.current) {
148+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
149 lastHeightRef.current = height;
150- contentInsetEndAdjustment.value = height;
151- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
152+ contentInsetEndAdjustment.value = shouldAnimate ? withTiming(height, {
153+ duration: animationDuration
154+ }) : height;
155 }
156 },
157- [contentInsetEndAdjustment, listRef]
158+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
159 );
160 useLayoutEffect(() => {
161 var _a;
162@@ -63,9 +66,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
163 }
164 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
165 const {
166+ adjustedInsetCompensation,
167 anchoredEndSpace,
168 applyWorkaroundForContentInsetHitTestBug,
169 contentInsetEndAdjustment,
170+ contentInsetEndStaticAdjustment,
171 freeze,
172 keyboardLiftBehavior,
173 keyboardOffset,
174@@ -73,6 +78,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
175 } = props;
176 const refLegendList = useRef(null);
177 const combinedRef = useCombinedRef(forwardedRef, refLegendList);
178+ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment;
179 const blankSpace = useSharedValue(0);
180 useEffect(() => {
181 if (!anchoredEndSpace) {
182@@ -87,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
183 ...anchoredEndSpace,
184 onSizeChanged: (size) => {
185 var _a;
186- blankSpace.value = size;
187+ // The anchored blank is sized against the ADJUSTED viewport, but the
188+ // scroll component writes it as a raw contentInset that UIKit tops up
189+ // with the safe-area bottom — write it net of that or the end rest
190+ // (and the anchored message) sinks one safe-area under the header.
191+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
192 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
193 }
194 };
195- }, [anchoredEndSpace, blankSpace]);
196+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
197 const onContentInsetChange = useCallback((insets) => {
198 var _a;
199 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
200@@ -102,6 +112,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
201 KeyboardChatScrollView,
202 {
203 ...scrollProps,
204+ adjustedInsetCompensation,
205+ adjustedStartInsetCompensation,
206 applyWorkaroundForContentInsetHitTestBug,
207 blankSpace,
208 extraContentPadding: contentInsetEndAdjustment,
209@@ -113,6 +125,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
210 );
211 },
212 [
213+ adjustedInsetCompensation,
214+ adjustedStartInsetCompensation,
215 applyWorkaroundForContentInsetHitTestBug,
216 blankSpace,
217 contentInsetEndAdjustment,
218@@ -128,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
219 {
220 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
221 anchoredEndSpaceOwnerInternal: "scroll",
222+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
223 ref: combinedRef,
224 renderScrollComponent: memoList,
225 ...rest
226diff --git a/react-native.d.ts b/react-native.d.ts
227index ce1fe00..3ccf6f1 100644
228--- a/react-native.d.ts
229+++ b/react-native.d.ts
230@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
231 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
232 */
233 contentInsetEndAdjustment?: number;
234+ /**
235+ * Width/height of a leading content inset applied natively outside the list's knowledge
236+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
237+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
238+ */
239+ contentInsetStartAdjustment?: number;
240 /**
241 * Number of columns to render items in.
242 * @default 1
243diff --git a/react-native.js b/react-native.js
244index b3c5a30..5ac6bbe 100644
245--- a/react-native.js
246+++ b/react-native.js
247@@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) {
248 return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;
249 }
250
251+// Size-only changes may not emit a scroll event to refresh the edge signal.
252+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {
253+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;
254+ if (!(contentSize > 0 && queuedInitialLayout)) {
255+ return peek$(ctx, "isAtEnd");
256+ }
257+ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON;
258+}
259+
260 // src/utils/checkAtBottom.ts
261 function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
262 var _a3;
263@@ -737,7 +746,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
264 const insetEnd = getContentInsetEnd(ctx);
265 const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd;
266 const isContentLess = contentSize < scrollLength;
267- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON);
268+ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize));
269 set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength);
270 set$(
271 ctx,
272@@ -954,7 +963,16 @@ function setInitialRenderState(ctx, {
273 if (didInitialScroll) {
274 state.didFinishInitialScroll = true;
275 }
276- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
277+ // Native contentOffset can seed an end target without dispatchInitialScroll.
278+ // Both overflow completion paths must wait for the native end landing.
279+ // Underflow needs no scroll: preserve UIKit's untouched resting position.
280+ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) {
281+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
282+ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) {
283+ startInsetEndSettleWatchdog(ctx);
284+ }
285+ }
286+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
287 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
288 set$(ctx, "readyToRender", true);
289 setAdaptiveRender(ctx, "normal", "ready");
290@@ -1090,7 +1108,7 @@ function getRawContentLength(ctx) {
291 function getAlignItemsAtEndPadding(ctx) {
292 const { state } = ctx;
293 const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0;
294- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
295+ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
296 }
297 function updateContentMetricsState(ctx) {
298 const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0;
299@@ -1115,6 +1133,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) {
300 totalSize += add;
301 }
302 if (prevTotalSize !== totalSize) {
303+ const now = Date.now();
304+ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0;
305+ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1;
306+ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300);
307 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
308 state.pendingTotalSize = totalSize;
309 } else {
310@@ -1304,18 +1326,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
311 }
312
313 // src/core/clampScrollOffset.ts
314+function getContentInsetStartAdjustment(ctx) {
315+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
316+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
317+}
318 function clampScrollOffset(ctx, offset, scrollTarget) {
319 const state = ctx.state;
320 const contentSize = getContentSize(ctx);
321+ const minOffset = -getContentInsetStartAdjustment(ctx);
322 let clampedOffset = offset;
323 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
324- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
325+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
326 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
327 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
328 const maxOffset = baseMaxOffset + extraEndOffset;
329 clampedOffset = Math.min(offset, maxOffset);
330 }
331- clampedOffset = Math.max(0, clampedOffset);
332+ clampedOffset = Math.max(minOffset, clampedOffset);
333 return clampedOffset;
334 }
335
336@@ -1451,10 +1478,10 @@ function checkFinishedScrollFrame(ctx) {
337 finishScrollTo(ctx);
338 }
339 }
340-function scrollToFallbackOffset(ctx, offset) {
341+function scrollToFallbackOffset(ctx, offset, animated) {
342 var _a3;
343 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
344- animated: false,
345+ animated: !!animated,
346 x: ctx.state.props.horizontal ? offset : 0,
347 y: ctx.state.props.horizontal ? 0 : offset
348 });
349@@ -1503,7 +1530,10 @@ function checkFinishedScrollFallback(ctx) {
350 );
351 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
352 } else if (shouldRetryUnalignedEndScroll) {
353- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
354+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
355+ if (!isActivelyAnimatingToEnd) {
356+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
357+ }
358 scheduleFallbackCheck(100);
359 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
360 finishScrollTo(ctx);
361@@ -1560,15 +1590,28 @@ function doMaintainScrollAtEnd(ctx) {
362 } = state;
363 const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
364 const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
365+ if (state.contentSizeAnimationActiveEpoch !== void 0) {
366+ state.pendingMaintainScrollAtEnd = false;
367+ return false;
368+ }
369 if (pendingNativeMVCPAdjust) {
370 state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
371 return false;
372 }
373 if (shouldMaintainScrollAtEnd) {
374 state.pendingMaintainScrollAtEnd = false;
375+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
376+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
377+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
378+ return false;
379+ }
380 const contentSize = getContentSize(ctx);
381+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
382 if (contentSize < state.scrollLength) {
383- state.scroll = 0;
384+ state.scroll = -maintainInsetStartAdjustment;
385+ if (maintainInsetStartAdjustment > 0) {
386+ return true;
387+ }
388 }
389 if (!state.maintainingScrollAtEnd) {
390 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
391@@ -1591,9 +1634,18 @@ function doMaintainScrollAtEnd(ctx) {
392 y: 0
393 });
394 } else {
395- scroller == null ? void 0 : scroller.scrollToEnd({
396- animated: maintainScrollAtEnd.animated
397- });
398+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
399+ if (insetStartAdjustment > 0) {
400+ scroller == null ? void 0 : scroller.scrollTo({
401+ animated: maintainScrollAtEnd.animated,
402+ x: 0,
403+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
404+ });
405+ } else {
406+ scroller == null ? void 0 : scroller.scrollToEnd({
407+ animated: maintainScrollAtEnd.animated
408+ });
409+ }
410 }
411 setTimeout(
412 () => {
413@@ -1624,6 +1676,10 @@ function doMaintainScrollAtEnd(ctx) {
414 function requestAdjust(ctx, positionDiff, dataChanged) {
415 const state = ctx.state;
416 if (Math.abs(positionDiff) > 0.1) {
417+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
418+ // size adjustment are already compensated by a contentOffset shift, so
419+ // animating them would make rows visibly lurch and slide back.
420+ state.lastMVCPAdjustTime = Date.now();
421 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
422 const doit = () => {
423 if (needsScrollWorkaround) {
424@@ -1728,7 +1784,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
425 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
426 return 0;
427 }
428- const maxScroll = Math.max(0, totalSize - state.scrollLength);
429+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
430+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
431+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
432 const clampDelta = maxScroll - state.scroll;
433 if (unresolvedAmount < 0) {
434 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
435@@ -1790,7 +1848,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
436 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
437 return true;
438 }
439- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
440+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
441 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
442 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
443 if (isAtExpectedNativeClamp) {
444@@ -1923,7 +1981,7 @@ function prepareMVCP(ctx, dataChanged) {
445 if (diff > 0) {
446 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
447 } else {
448- const maxScroll = Math.max(0, totalSize - state.scrollLength);
449+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
450 state.scroll = maxScroll;
451 state.scrollPending = maxScroll;
452 diff = 0;
453@@ -2320,8 +2378,121 @@ function scrollToIndex(ctx, {
454 }
455
456 // src/core/initialScroll.ts
457+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
458+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
459+var INSET_END_REVEAL_STABLE_FRAMES = 3;
460+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
461+function startInsetEndSettleWatchdog(ctx) {
462+ const state = ctx.state;
463+ if (state.insetEndSettleWatchdogStarted || state.didLoad) {
464+ return;
465+ }
466+ state.insetEndSettleWatchdogStarted = true;
467+ state.insetEndSettleWatchdogActive = true;
468+ // Hold the readyToRender opacity gate until the end landing is stable, so
469+ // the estimated-to-measured settle chase happens before first VISIBLE
470+ // paint instead of in front of the user. Capped so slow measurement can
471+ // never hide content for long.
472+ state.insetEndRevealHold = true;
473+ let frames = 0;
474+ let settledFrames = 0;
475+ let revealStableFrames = 0;
476+ let previousEndOffset;
477+ const releaseRevealHold = () => {
478+ if (state.insetEndRevealHold) {
479+ state.insetEndRevealHold = false;
480+ setInitialRenderState(ctx, {});
481+ }
482+ };
483+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
484+ // can move the end target one more time — do that upgrade while still
485+ // hidden (phase 1), then require stability again before revealing (phase 2)
486+ // so the first visible frame is the settled one.
487+ let revealPhase = 1;
488+ const onRevealStability = () => {
489+ var _a3;
490+ if (!state.insetEndRevealHold) {
491+ return;
492+ }
493+ if (revealPhase === 1) {
494+ revealPhase = 2;
495+ revealStableFrames = 0;
496+ setAdaptiveRender(ctx, "normal");
497+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
498+ return;
499+ }
500+ releaseRevealHold();
501+ };
502+ const tick = () => {
503+ // The user owns the scroll from their first drag — a re-pin here would
504+ // pin the list under their finger.
505+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
506+ state.insetEndSettleWatchdogActive = false;
507+ releaseRevealHold();
508+ return;
509+ }
510+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
511+ releaseRevealHold();
512+ }
513+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
514+ const contentSize = getContentSize(ctx);
515+ const scrollLength = state.scrollLength;
516+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
517+ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit
518+ // moves. Only a native scroll event proves that the requested offset landed.
519+ const nativeOffset = state.lastNativeScroll;
520+ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset);
521+ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity;
522+ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd;
523+ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize);
524+ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1;
525+ previousEndOffset = endOffset;
526+ if (isStable) {
527+ settledFrames++;
528+ revealStableFrames++;
529+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
530+ onRevealStability();
531+ }
532+ } else {
533+ settledFrames = 0;
534+ revealStableFrames = 0;
535+ // Keep the existing near-end correction limit. In-flight scrolls and
536+ // unknown native offsets wait; neither counts as a settled frame.
537+ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
538+ const scroller = state.refScroller.current;
539+ if (scroller) {
540+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
541+ }
542+ }
543+ }
544+ requestAnimationFrame(tick);
545+ };
546+ requestAnimationFrame(tick);
547+}
548 function dispatchInitialScroll(ctx, params) {
549 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
550+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
551+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
552+ if (ctx.state.scrollLength <= 0) {
553+ // The scroll length is unknown, so the resolved target is meaningless;
554+ // the initial-scroll machinery re-advances after layout.
555+ return;
556+ }
557+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
558+ // Content underflows the viewport and nothing has scrolled yet: the
559+ // untouched native rest position (UIKit's adjustedContentInset) IS the
560+ // end position. Dispatching a negative scroll here would race UIKit's
561+ // inset application during screen attach, so just finish the session.
562+ finishInitialScroll(ctx, {
563+ resolvedOffset
564+ });
565+ return;
566+ }
567+ ctx.state.didDispatchInsetInitialScroll = true;
568+ if (target.viewPosition === 1) {
569+ startInsetEndSettleWatchdog(ctx);
570+ }
571+ }
572 const requestedIndex = target.index;
573 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
574 const itemSize = getItemSizeAtIndex(ctx, index);
575@@ -2747,7 +2918,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
576 return;
577 }
578 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
579- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
580+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
581+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
582+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList);
583 if (!shouldKeepEndTargetAlive) {
584 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
585 clearPendingInitialScrollFooterLayout(ctx, {
586@@ -4672,7 +4845,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
587 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
588 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
589 isReady = !hasUnknownTailSize;
590- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
591+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
592+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
593 } else if (anchorIndex >= 0) {
594 isReady = false;
595 }
596@@ -4692,6 +4866,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
597 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
598 }
599 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
600+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
601+ set$(ctx, "anchoredEndSpaceSize", nextSize);
602+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
603+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
604+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
605+ }
606 }
607 return nextSize;
608 }
609@@ -5715,6 +5895,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
610 horizontal
611 }) {
612 const ctx = useStateContext();
613+ const SizeComponent = ctx.state.props.sizeComponentInternal;
614 const columnWrapperStyle = ctx.columnWrapperStyle;
615 const animSize = useValue$("totalSize");
616 const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
617@@ -5725,6 +5906,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
618 opacity: isVisible ? 1 : 0,
619 width: animSize
620 } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
621+ if (SizeComponent) {
622+ if (horizontal) {
623+ delete style.width;
624+ } else {
625+ delete style.height;
626+ }
627+ }
628 if (columnWrapperStyle) {
629 const { columnGap, rowGap, gap } = columnWrapperStyle;
630 const gapX = columnGap || gap || 0;
631@@ -5745,7 +5933,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
632 }
633 }
634 }
635- return /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children));
636+ const content = /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children);
637+ return SizeComponent ? /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content);
638 });
639 var Containers = typedMemo(function Containers2({
640 freshDataTransitionEpoch,
641@@ -5896,7 +6085,12 @@ var StyleSheet = ReactNative.StyleSheet;
642
643 // src/components/ListComponent.tsx
644 var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
645+ const ctx = useStateContext();
646 const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]);
647+ const SizeComponent = ctx.state.props.sizeComponentInternal;
648+ if (SizeComponent) {
649+ return /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } });
650+ }
651 if (alignItemsAtEndPadding <= 0) {
652 return null;
653 }
654@@ -5929,8 +6123,12 @@ var ListComponent = typedMemo(function ListComponent2({
655 refScrollView,
656 renderScrollComponent,
657 onLayoutFooter,
658+ onInternalMomentumScrollBegin,
659 onInternalScrollBeginDrag,
660+ onInternalScrollEndDrag,
661 onInternalScrollEnd,
662+ onMomentumScrollBegin,
663+ onScrollEndDrag,
664 scrollAdjustHandler,
665 snapToIndices,
666 stickyHeaderConfig,
667@@ -6001,7 +6199,17 @@ var ListComponent = typedMemo(function ListComponent2({
668 SnapOrScroll,
669 {
670 ...rest,
671- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag },
672+ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : {
673+ onMomentumScrollBegin: (event) => {
674+ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event);
675+ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event);
676+ },
677+ onScrollBeginDrag: onInternalScrollBeginDrag,
678+ onScrollEndDrag: (event) => {
679+ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event);
680+ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event);
681+ }
682+ },
683 contentContainerStyle: [
684 horizontal ? { height: "100%" } : {},
685 contentContainerStyle,
686@@ -6010,7 +6218,10 @@ var ListComponent = typedMemo(function ListComponent2({
687 ],
688 contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0,
689 horizontal,
690- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
691+ // Keep iOS anchored to ScrollAdjust even when JS position restoration is
692+ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor
693+ // with the 1e7 sentinel and scroll the entire list out of view.
694+ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
695 onLayout,
696 onScroll: onScroll2,
697 ref: refScrollView,
698@@ -6751,7 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
699 endBuffered: state.endBuffered,
700 getAverageItemSizes: () => getAverageItemSizes(state),
701 indexByKey: (key) => state.indexByKey.get(key),
702- isAtEnd: peek$(ctx, "isAtEnd"),
703+ isAtEnd: getIsAtEnd(ctx),
704 isAtStart: peek$(ctx, "isAtStart"),
705 isEndReached: state.isEndReached,
706 isNearEnd: peek$(ctx, "isNearEnd"),
707@@ -7075,6 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
708 dataVersion,
709 drawDistance = 250,
710 contentInsetEndAdjustment,
711+ contentInsetStartAdjustment,
712 estimatedItemSize = 100,
713 estimatedListSize,
714 extraData,
715@@ -7132,10 +7344,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
716 const animatedPropsInternal = props.animatedPropsInternal;
717 const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
718 const positionComponentInternal = props.positionComponentInternal;
719+ const sizeComponentInternal = props.sizeComponentInternal;
720 const stickyPositionComponentInternal = props.stickyPositionComponentInternal;
721 const {
722 anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal,
723 positionComponentInternal: _positionComponentInternal,
724+ sizeComponentInternal: _sizeComponentInternal,
725 stickyPositionComponentInternal: _stickyPositionComponentInternal,
726 ...restProps
727 } = rest;
728@@ -7200,7 +7414,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
729 const combinedRef = useCombinedRef(refScroller, refScrollView);
730 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
731 const stickyHeaderIndices = stickyHeaderIndicesProp;
732- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
733+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
734 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
735 const alwaysRenderIndices = React2.useMemo(() => {
736 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
737@@ -7341,6 +7555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
738 contentContainerAlignItems: contentContainerStyle.alignItems,
739 contentInset,
740 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
741+ contentInsetStartAdjustment,
742 data: dataProp,
743 dataKey,
744 dataVersion,
745@@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
746 renderItem,
747 rtl,
748 snapToIndices,
749+ sizeComponentInternal,
750 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
751 stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
752 stickyPositionComponentInternal,
753@@ -7423,6 +7639,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
754 return void 0;
755 }
756 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
757+ if (getContentInsetStartAdjustment(ctx) > 0) {
758+ // With a native leading inset (transparent header + automatic insets),
759+ // any mount-time contentOffset races UIKit's adjustedContentInset
760+ // application. The gated initial-scroll dispatch positions the list once
761+ // real sizes are known instead.
762+ return void 0;
763+ }
764 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
765 }, [usesBootstrapInitialScroll]);
766 React2.useLayoutEffect(() => {
767@@ -7547,6 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
768 [
769 dataKey,
770 dataVersion,
771+ contentInsetStartAdjustment,
772 memoizedLastItemKeys.join(","),
773 numColumnsProp,
774 nextScrollAxisGap,
775@@ -7643,6 +7867,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
776 () => ({
777 getRenderedItem: (key) => getRenderedItem(ctx, key),
778 onMomentumScrollEnd: (event) => {
779+ state.isMomentumScrolling = false;
780 checkFinishedScrollFallback(ctx);
781 if (state.props.onMomentumScrollEnd) {
782 state.props.onMomentumScrollEnd(event);
783@@ -7651,6 +7876,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
784 onScroll: (event) => onScroll(ctx, event),
785 onScrollBeginDrag: (event) => {
786 var _a4, _b2;
787+ ctx.state.didUserDrag = true;
788+ ctx.state.isUserDragging = true;
789 prepareReachedEdgeForNextUserScroll(ctx);
790 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
791 },
792@@ -7676,11 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
793 ListFooterComponent,
794 ListFooterComponentStyle,
795 ListHeaderComponent,
796+ onInternalMomentumScrollBegin: () => {
797+ ctx.state.isMomentumScrolling = true;
798+ },
799 onInternalScrollBeginDrag: fns.onScrollBeginDrag,
800+ onInternalScrollEndDrag: () => {
801+ ctx.state.isUserDragging = false;
802+ },
803 onInternalScrollEnd: fns.onScrollEnd,
804 onLayout,
805 onLayoutFooter,
806 onMomentumScrollEnd: fns.onMomentumScrollEnd,
807+ onScrollBeginDrag: fns.onScrollBeginDrag,
808 onScroll: onScrollHandler,
809 recycleItems,
810 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
811diff --git a/react-native.mjs b/react-native.mjs
812index 40e87cd..93aac74 100644
813--- a/react-native.mjs
814+++ b/react-native.mjs
815@@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) {
816 return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;
817 }
818
819+// Size-only changes may not emit a scroll event to refresh the edge signal.
820+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {
821+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;
822+ if (!(contentSize > 0 && queuedInitialLayout)) {
823+ return peek$(ctx, "isAtEnd");
824+ }
825+ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON;
826+}
827+
828 // src/utils/checkAtBottom.ts
829 function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
830 var _a3;
831@@ -716,7 +725,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
832 const insetEnd = getContentInsetEnd(ctx);
833 const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd;
834 const isContentLess = contentSize < scrollLength;
835- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON);
836+ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize));
837 set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength);
838 set$(
839 ctx,
840@@ -933,7 +942,16 @@ function setInitialRenderState(ctx, {
841 if (didInitialScroll) {
842 state.didFinishInitialScroll = true;
843 }
844- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
845+ // Native contentOffset can seed an end target without dispatchInitialScroll.
846+ // Both overflow completion paths must wait for the native end landing.
847+ // Underflow needs no scroll: preserve UIKit's untouched resting position.
848+ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) {
849+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
850+ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) {
851+ startInsetEndSettleWatchdog(ctx);
852+ }
853+ }
854+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
855 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
856 set$(ctx, "readyToRender", true);
857 setAdaptiveRender(ctx, "normal", "ready");
858@@ -1069,7 +1087,7 @@ function getRawContentLength(ctx) {
859 function getAlignItemsAtEndPadding(ctx) {
860 const { state } = ctx;
861 const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0;
862- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
863+ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
864 }
865 function updateContentMetricsState(ctx) {
866 const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0;
867@@ -1094,6 +1112,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) {
868 totalSize += add;
869 }
870 if (prevTotalSize !== totalSize) {
871+ const now = Date.now();
872+ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0;
873+ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1;
874+ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300);
875 if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
876 state.pendingTotalSize = totalSize;
877 } else {
878@@ -1283,18 +1305,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
879 }
880
881 // src/core/clampScrollOffset.ts
882+function getContentInsetStartAdjustment(ctx) {
883+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
884+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
885+}
886 function clampScrollOffset(ctx, offset, scrollTarget) {
887 const state = ctx.state;
888 const contentSize = getContentSize(ctx);
889+ const minOffset = -getContentInsetStartAdjustment(ctx);
890 let clampedOffset = offset;
891 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
892- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
893+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
894 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
895 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
896 const maxOffset = baseMaxOffset + extraEndOffset;
897 clampedOffset = Math.min(offset, maxOffset);
898 }
899- clampedOffset = Math.max(0, clampedOffset);
900+ clampedOffset = Math.max(minOffset, clampedOffset);
901 return clampedOffset;
902 }
903
904@@ -1430,10 +1457,10 @@ function checkFinishedScrollFrame(ctx) {
905 finishScrollTo(ctx);
906 }
907 }
908-function scrollToFallbackOffset(ctx, offset) {
909+function scrollToFallbackOffset(ctx, offset, animated) {
910 var _a3;
911 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
912- animated: false,
913+ animated: !!animated,
914 x: ctx.state.props.horizontal ? offset : 0,
915 y: ctx.state.props.horizontal ? 0 : offset
916 });
917@@ -1482,7 +1509,10 @@ function checkFinishedScrollFallback(ctx) {
918 );
919 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
920 } else if (shouldRetryUnalignedEndScroll) {
921- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
922+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
923+ if (!isActivelyAnimatingToEnd) {
924+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
925+ }
926 scheduleFallbackCheck(100);
927 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
928 finishScrollTo(ctx);
929@@ -1539,15 +1569,28 @@ function doMaintainScrollAtEnd(ctx) {
930 } = state;
931 const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
932 const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
933+ if (state.contentSizeAnimationActiveEpoch !== void 0) {
934+ state.pendingMaintainScrollAtEnd = false;
935+ return false;
936+ }
937 if (pendingNativeMVCPAdjust) {
938 state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
939 return false;
940 }
941 if (shouldMaintainScrollAtEnd) {
942 state.pendingMaintainScrollAtEnd = false;
943+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
944+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
945+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
946+ return false;
947+ }
948 const contentSize = getContentSize(ctx);
949+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
950 if (contentSize < state.scrollLength) {
951- state.scroll = 0;
952+ state.scroll = -maintainInsetStartAdjustment;
953+ if (maintainInsetStartAdjustment > 0) {
954+ return true;
955+ }
956 }
957 if (!state.maintainingScrollAtEnd) {
958 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
959@@ -1570,9 +1613,18 @@ function doMaintainScrollAtEnd(ctx) {
960 y: 0
961 });
962 } else {
963- scroller == null ? void 0 : scroller.scrollToEnd({
964- animated: maintainScrollAtEnd.animated
965- });
966+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
967+ if (insetStartAdjustment > 0) {
968+ scroller == null ? void 0 : scroller.scrollTo({
969+ animated: maintainScrollAtEnd.animated,
970+ x: 0,
971+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
972+ });
973+ } else {
974+ scroller == null ? void 0 : scroller.scrollToEnd({
975+ animated: maintainScrollAtEnd.animated
976+ });
977+ }
978 }
979 setTimeout(
980 () => {
981@@ -1603,6 +1655,10 @@ function doMaintainScrollAtEnd(ctx) {
982 function requestAdjust(ctx, positionDiff, dataChanged) {
983 const state = ctx.state;
984 if (Math.abs(positionDiff) > 0.1) {
985+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
986+ // size adjustment are already compensated by a contentOffset shift, so
987+ // animating them would make rows visibly lurch and slide back.
988+ state.lastMVCPAdjustTime = Date.now();
989 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
990 const doit = () => {
991 if (needsScrollWorkaround) {
992@@ -1707,7 +1763,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
993 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
994 return 0;
995 }
996- const maxScroll = Math.max(0, totalSize - state.scrollLength);
997+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
998+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
999+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
1000 const clampDelta = maxScroll - state.scroll;
1001 if (unresolvedAmount < 0) {
1002 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
1003@@ -1769,7 +1827,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
1004 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
1005 return true;
1006 }
1007- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
1008+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
1009 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
1010 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
1011 if (isAtExpectedNativeClamp) {
1012@@ -1902,7 +1960,7 @@ function prepareMVCP(ctx, dataChanged) {
1013 if (diff > 0) {
1014 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
1015 } else {
1016- const maxScroll = Math.max(0, totalSize - state.scrollLength);
1017+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
1018 state.scroll = maxScroll;
1019 state.scrollPending = maxScroll;
1020 diff = 0;
1021@@ -2299,8 +2357,121 @@ function scrollToIndex(ctx, {
1022 }
1023
1024 // src/core/initialScroll.ts
1025+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
1026+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
1027+var INSET_END_REVEAL_STABLE_FRAMES = 3;
1028+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
1029+function startInsetEndSettleWatchdog(ctx) {
1030+ const state = ctx.state;
1031+ if (state.insetEndSettleWatchdogStarted || state.didLoad) {
1032+ return;
1033+ }
1034+ state.insetEndSettleWatchdogStarted = true;
1035+ state.insetEndSettleWatchdogActive = true;
1036+ // Hold the readyToRender opacity gate until the end landing is stable, so
1037+ // the estimated-to-measured settle chase happens before first VISIBLE
1038+ // paint instead of in front of the user. Capped so slow measurement can
1039+ // never hide content for long.
1040+ state.insetEndRevealHold = true;
1041+ let frames = 0;
1042+ let settledFrames = 0;
1043+ let revealStableFrames = 0;
1044+ let previousEndOffset;
1045+ const releaseRevealHold = () => {
1046+ if (state.insetEndRevealHold) {
1047+ state.insetEndRevealHold = false;
1048+ setInitialRenderState(ctx, {});
1049+ }
1050+ };
1051+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
1052+ // can move the end target one more time — do that upgrade while still
1053+ // hidden (phase 1), then require stability again before revealing (phase 2)
1054+ // so the first visible frame is the settled one.
1055+ let revealPhase = 1;
1056+ const onRevealStability = () => {
1057+ var _a3;
1058+ if (!state.insetEndRevealHold) {
1059+ return;
1060+ }
1061+ if (revealPhase === 1) {
1062+ revealPhase = 2;
1063+ revealStableFrames = 0;
1064+ setAdaptiveRender(ctx, "normal");
1065+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
1066+ return;
1067+ }
1068+ releaseRevealHold();
1069+ };
1070+ const tick = () => {
1071+ // The user owns the scroll from their first drag — a re-pin here would
1072+ // pin the list under their finger.
1073+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
1074+ state.insetEndSettleWatchdogActive = false;
1075+ releaseRevealHold();
1076+ return;
1077+ }
1078+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
1079+ releaseRevealHold();
1080+ }
1081+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
1082+ const contentSize = getContentSize(ctx);
1083+ const scrollLength = state.scrollLength;
1084+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
1085+ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit
1086+ // moves. Only a native scroll event proves that the requested offset landed.
1087+ const nativeOffset = state.lastNativeScroll;
1088+ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset);
1089+ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity;
1090+ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd;
1091+ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize);
1092+ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1;
1093+ previousEndOffset = endOffset;
1094+ if (isStable) {
1095+ settledFrames++;
1096+ revealStableFrames++;
1097+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
1098+ onRevealStability();
1099+ }
1100+ } else {
1101+ settledFrames = 0;
1102+ revealStableFrames = 0;
1103+ // Keep the existing near-end correction limit. In-flight scrolls and
1104+ // unknown native offsets wait; neither counts as a settled frame.
1105+ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
1106+ const scroller = state.refScroller.current;
1107+ if (scroller) {
1108+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
1109+ }
1110+ }
1111+ }
1112+ requestAnimationFrame(tick);
1113+ };
1114+ requestAnimationFrame(tick);
1115+}
1116 function dispatchInitialScroll(ctx, params) {
1117 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
1118+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
1119+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
1120+ if (ctx.state.scrollLength <= 0) {
1121+ // The scroll length is unknown, so the resolved target is meaningless;
1122+ // the initial-scroll machinery re-advances after layout.
1123+ return;
1124+ }
1125+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
1126+ // Content underflows the viewport and nothing has scrolled yet: the
1127+ // untouched native rest position (UIKit's adjustedContentInset) IS the
1128+ // end position. Dispatching a negative scroll here would race UIKit's
1129+ // inset application during screen attach, so just finish the session.
1130+ finishInitialScroll(ctx, {
1131+ resolvedOffset
1132+ });
1133+ return;
1134+ }
1135+ ctx.state.didDispatchInsetInitialScroll = true;
1136+ if (target.viewPosition === 1) {
1137+ startInsetEndSettleWatchdog(ctx);
1138+ }
1139+ }
1140 const requestedIndex = target.index;
1141 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
1142 const itemSize = getItemSizeAtIndex(ctx, index);
1143@@ -2726,7 +2897,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
1144 return;
1145 }
1146 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
1147- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
1148+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
1149+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
1150+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList);
1151 if (!shouldKeepEndTargetAlive) {
1152 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
1153 clearPendingInitialScrollFooterLayout(ctx, {
1154@@ -4651,7 +4824,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1155 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
1156 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
1157 isReady = !hasUnknownTailSize;
1158- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
1159+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
1160+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
1161 } else if (anchorIndex >= 0) {
1162 isReady = false;
1163 }
1164@@ -4671,6 +4845,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1165 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
1166 }
1167 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
1168+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
1169+ set$(ctx, "anchoredEndSpaceSize", nextSize);
1170+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
1171+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
1172+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
1173+ }
1174 }
1175 return nextSize;
1176 }
1177@@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
1178 horizontal
1179 }) {
1180 const ctx = useStateContext();
1181+ const SizeComponent = ctx.state.props.sizeComponentInternal;
1182 const columnWrapperStyle = ctx.columnWrapperStyle;
1183 const animSize = useValue$("totalSize");
1184 const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
1185@@ -5704,6 +5885,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
1186 opacity: isVisible ? 1 : 0,
1187 width: animSize
1188 } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
1189+ if (SizeComponent) {
1190+ if (horizontal) {
1191+ delete style.width;
1192+ } else {
1193+ delete style.height;
1194+ }
1195+ }
1196 if (columnWrapperStyle) {
1197 const { columnGap, rowGap, gap } = columnWrapperStyle;
1198 const gapX = columnGap || gap || 0;
1199@@ -5724,7 +5912,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
1200 }
1201 }
1202 }
1203- return /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children));
1204+ const content = /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children);
1205+ return SizeComponent ? /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content);
1206 });
1207 var Containers = typedMemo(function Containers2({
1208 freshDataTransitionEpoch,
1209@@ -5875,7 +6064,12 @@ var StyleSheet = StyleSheet$1;
1210
1211 // src/components/ListComponent.tsx
1212 var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
1213+ const ctx = useStateContext();
1214 const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]);
1215+ const SizeComponent = ctx.state.props.sizeComponentInternal;
1216+ if (SizeComponent) {
1217+ return /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } });
1218+ }
1219 if (alignItemsAtEndPadding <= 0) {
1220 return null;
1221 }
1222@@ -5908,8 +6102,12 @@ var ListComponent = typedMemo(function ListComponent2({
1223 refScrollView,
1224 renderScrollComponent,
1225 onLayoutFooter,
1226+ onInternalMomentumScrollBegin,
1227 onInternalScrollBeginDrag,
1228+ onInternalScrollEndDrag,
1229 onInternalScrollEnd,
1230+ onMomentumScrollBegin,
1231+ onScrollEndDrag,
1232 scrollAdjustHandler,
1233 snapToIndices,
1234 stickyHeaderConfig,
1235@@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({
1236 SnapOrScroll,
1237 {
1238 ...rest,
1239- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag },
1240+ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : {
1241+ onMomentumScrollBegin: (event) => {
1242+ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event);
1243+ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event);
1244+ },
1245+ onScrollBeginDrag: onInternalScrollBeginDrag,
1246+ onScrollEndDrag: (event) => {
1247+ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event);
1248+ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event);
1249+ }
1250+ },
1251 contentContainerStyle: [
1252 horizontal ? { height: "100%" } : {},
1253 contentContainerStyle,
1254@@ -5989,7 +6197,10 @@ var ListComponent = typedMemo(function ListComponent2({
1255 ],
1256 contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0,
1257 horizontal,
1258- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
1259+ // Keep iOS anchored to ScrollAdjust even when JS position restoration is
1260+ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor
1261+ // with the 1e7 sentinel and scroll the entire list out of view.
1262+ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
1263 onLayout,
1264 onScroll: onScroll2,
1265 ref: refScrollView,
1266@@ -6730,7 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
1267 endBuffered: state.endBuffered,
1268 getAverageItemSizes: () => getAverageItemSizes(state),
1269 indexByKey: (key) => state.indexByKey.get(key),
1270- isAtEnd: peek$(ctx, "isAtEnd"),
1271+ isAtEnd: getIsAtEnd(ctx),
1272 isAtStart: peek$(ctx, "isAtStart"),
1273 isEndReached: state.isEndReached,
1274 isNearEnd: peek$(ctx, "isNearEnd"),
1275@@ -7054,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1276 dataVersion,
1277 drawDistance = 250,
1278 contentInsetEndAdjustment,
1279+ contentInsetStartAdjustment,
1280 estimatedItemSize = 100,
1281 estimatedListSize,
1282 extraData,
1283@@ -7111,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1284 const animatedPropsInternal = props.animatedPropsInternal;
1285 const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
1286 const positionComponentInternal = props.positionComponentInternal;
1287+ const sizeComponentInternal = props.sizeComponentInternal;
1288 const stickyPositionComponentInternal = props.stickyPositionComponentInternal;
1289 const {
1290 anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal,
1291 positionComponentInternal: _positionComponentInternal,
1292+ sizeComponentInternal: _sizeComponentInternal,
1293 stickyPositionComponentInternal: _stickyPositionComponentInternal,
1294 ...restProps
1295 } = rest;
1296@@ -7179,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1297 const combinedRef = useCombinedRef(refScroller, refScrollView);
1298 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
1299 const stickyHeaderIndices = stickyHeaderIndicesProp;
1300- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
1301+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
1302 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
1303 const alwaysRenderIndices = useMemo(() => {
1304 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
1305@@ -7320,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1306 contentContainerAlignItems: contentContainerStyle.alignItems,
1307 contentInset,
1308 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
1309+ contentInsetStartAdjustment,
1310 data: dataProp,
1311 dataKey,
1312 dataVersion,
1313@@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1314 renderItem,
1315 rtl,
1316 snapToIndices,
1317+ sizeComponentInternal,
1318 stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
1319 stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
1320 stickyPositionComponentInternal,
1321@@ -7402,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1322 return void 0;
1323 }
1324 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
1325+ if (getContentInsetStartAdjustment(ctx) > 0) {
1326+ // With a native leading inset (transparent header + automatic insets),
1327+ // any mount-time contentOffset races UIKit's adjustedContentInset
1328+ // application. The gated initial-scroll dispatch positions the list once
1329+ // real sizes are known instead.
1330+ return void 0;
1331+ }
1332 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
1333 }, [usesBootstrapInitialScroll]);
1334 useLayoutEffect(() => {
1335@@ -7526,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1336 [
1337 dataKey,
1338 dataVersion,
1339+ contentInsetStartAdjustment,
1340 memoizedLastItemKeys.join(","),
1341 numColumnsProp,
1342 nextScrollAxisGap,
1343@@ -7622,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1344 () => ({
1345 getRenderedItem: (key) => getRenderedItem(ctx, key),
1346 onMomentumScrollEnd: (event) => {
1347+ state.isMomentumScrolling = false;
1348 checkFinishedScrollFallback(ctx);
1349 if (state.props.onMomentumScrollEnd) {
1350 state.props.onMomentumScrollEnd(event);
1351@@ -7630,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1352 onScroll: (event) => onScroll(ctx, event),
1353 onScrollBeginDrag: (event) => {
1354 var _a4, _b2;
1355+ ctx.state.didUserDrag = true;
1356+ ctx.state.isUserDragging = true;
1357 prepareReachedEdgeForNextUserScroll(ctx);
1358 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
1359 },
1360@@ -7655,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
1361 ListFooterComponent,
1362 ListFooterComponentStyle,
1363 ListHeaderComponent,
1364+ onInternalMomentumScrollBegin: () => {
1365+ ctx.state.isMomentumScrolling = true;
1366+ },
1367 onInternalScrollBeginDrag: fns.onScrollBeginDrag,
1368+ onInternalScrollEndDrag: () => {
1369+ ctx.state.isUserDragging = false;
1370+ },
1371 onInternalScrollEnd: fns.onScrollEnd,
1372 onLayout,
1373 onLayoutFooter,
1374 onMomentumScrollEnd: fns.onMomentumScrollEnd,
1375+ onScrollBeginDrag: fns.onScrollBeginDrag,
1376 onScroll: onScrollHandler,
1377 recycleItems,
1378 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
1379diff --git a/react-native.web.js b/react-native.web.js
1380index 914d2da..8b88b09 100644
1381--- a/react-native.web.js
1382+++ b/react-native.web.js
1383@@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1384 });
1385
1386 // src/utils/reordering.ts
1387+// Element.moveBefore keeps the moved subtree's state and does not count as an
1388+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1389+// restart when the list reorders its containers. insertBefore is the fallback.
1390+function moveChildBefore(container, element, reference) {
1391+ if (typeof container.moveBefore === "function") {
1392+ container.moveBefore(element, reference);
1393+ } else if (reference) {
1394+ container.insertBefore(element, reference);
1395+ } else {
1396+ container.appendChild(element);
1397+ }
1398+}
1399 function sortDOMElements(container, indexByElement) {
1400 const elements = Array.from(container.children);
1401 if (elements.length <= 1) return elements;
1402@@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) {
1403 }
1404 }
1405 if (nextStableElement) {
1406- container.insertBefore(element, nextStableElement);
1407+ moveChildBefore(container, element, nextStableElement);
1408 } else {
1409- container.appendChild(element);
1410+ moveChildBefore(container, element, null);
1411 }
1412 }
1413 }
1414diff --git a/react-native.web.mjs b/react-native.web.mjs
1415index 95465f2..d7cbe1c 100644
1416--- a/react-native.web.mjs
1417+++ b/react-native.web.mjs
1418@@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1419 });
1420
1421 // src/utils/reordering.ts
1422+// Element.moveBefore keeps the moved subtree's state and does not count as an
1423+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1424+// restart when the list reorders its containers. insertBefore is the fallback.
1425+function moveChildBefore(container, element, reference) {
1426+ if (typeof container.moveBefore === "function") {
1427+ container.moveBefore(element, reference);
1428+ } else if (reference) {
1429+ container.insertBefore(element, reference);
1430+ } else {
1431+ container.appendChild(element);
1432+ }
1433+}
1434 function sortDOMElements(container, indexByElement) {
1435 const elements = Array.from(container.children);
1436 if (elements.length <= 1) return elements;
1437@@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) {
1438 }
1439 }
1440 if (nextStableElement) {
1441- container.insertBefore(element, nextStableElement);
1442+ moveChildBefore(container, element, nextStableElement);
1443 } else {
1444- container.appendChild(element);
1445+ moveChildBefore(container, element, null);
1446 }
1447 }
1448 }
1449diff --git a/react.js b/react.js
1450index 914d2da..0c2917d 100644
1451--- a/react.js
1452+++ b/react.js
1453@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1454 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
1455 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
1456 isReady = !hasUnknownTailSize;
1457- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
1458+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
1459+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
1460 } else if (anchorIndex >= 0) {
1461 isReady = false;
1462 }
1463@@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1464 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
1465 }
1466 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
1467+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
1468+ set$(ctx, "anchoredEndSpaceSize", nextSize);
1469+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
1470+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
1471+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
1472+ }
1473 }
1474 return nextSize;
1475 }
1476@@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1477 });
1478
1479 // src/utils/reordering.ts
1480+// Element.moveBefore keeps the moved subtree's state and does not count as an
1481+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1482+// restart when the list reorders its containers. insertBefore is the fallback.
1483+function moveChildBefore(container, element, reference) {
1484+ if (typeof container.moveBefore === "function") {
1485+ container.moveBefore(element, reference);
1486+ } else if (reference) {
1487+ container.insertBefore(element, reference);
1488+ } else {
1489+ container.appendChild(element);
1490+ }
1491+}
1492 function sortDOMElements(container, indexByElement) {
1493 const elements = Array.from(container.children);
1494 if (elements.length <= 1) return elements;
1495@@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) {
1496 }
1497 }
1498 if (nextStableElement) {
1499- container.insertBefore(element, nextStableElement);
1500+ moveChildBefore(container, element, nextStableElement);
1501 } else {
1502- container.appendChild(element);
1503+ moveChildBefore(container, element, null);
1504 }
1505 }
1506 }
1507@@ -6446,8 +6465,8 @@ function ScrollAdjust() {
1508 window.getComputedStyle(contentNode)[axis.paddingEndProp]
1509 );
1510 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
1511- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
1512 contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
1513+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
1514 void contentNode.offsetHeight;
1515 scrollBy();
1516 if (resetPaddingRafRef.current !== void 0) {
1517diff --git a/react.mjs b/react.mjs
1518index 95465f2..b73bfdf 100644
1519--- a/react.mjs
1520+++ b/react.mjs
1521@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1522 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
1523 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
1524 isReady = !hasUnknownTailSize;
1525- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
1526+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
1527+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
1528 } else if (anchorIndex >= 0) {
1529 isReady = false;
1530 }
1531@@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
1532 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
1533 }
1534 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
1535+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
1536+ set$(ctx, "anchoredEndSpaceSize", nextSize);
1537+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
1538+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
1539+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
1540+ }
1541 }
1542 return nextSize;
1543 }
1544@@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1545 });
1546
1547 // src/utils/reordering.ts
1548+// Element.moveBefore keeps the moved subtree's state and does not count as an
1549+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1550+// restart when the list reorders its containers. insertBefore is the fallback.
1551+function moveChildBefore(container, element, reference) {
1552+ if (typeof container.moveBefore === "function") {
1553+ container.moveBefore(element, reference);
1554+ } else if (reference) {
1555+ container.insertBefore(element, reference);
1556+ } else {
1557+ container.appendChild(element);
1558+ }
1559+}
1560 function sortDOMElements(container, indexByElement) {
1561 const elements = Array.from(container.children);
1562 if (elements.length <= 1) return elements;
1563@@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) {
1564 }
1565 }
1566 if (nextStableElement) {
1567- container.insertBefore(element, nextStableElement);
1568+ moveChildBefore(container, element, nextStableElement);
1569 } else {
1570- container.appendChild(element);
1571+ moveChildBefore(container, element, null);
1572 }
1573 }
1574 }
1575@@ -6425,8 +6444,8 @@ function ScrollAdjust() {
1576 window.getComputedStyle(contentNode)[axis.paddingEndProp]
1577 );
1578 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
1579- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
1580 contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
1581+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
1582 void contentNode.offsetHeight;
1583 scrollBy();
1584 if (resetPaddingRafRef.current !== void 0) {
1585diff --git a/reanimated.d.ts b/reanimated.d.ts
1586index e504332..2ce6383 100644
1587--- a/reanimated.d.ts
1588+++ b/reanimated.d.ts
1589@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
1590 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
1591 */
1592 contentInsetEndAdjustment?: number;
1593+ /**
1594+ * Width/height of a leading content inset applied natively outside the list's knowledge
1595+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
1596+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
1597+ */
1598+ contentInsetStartAdjustment?: number;
1599 /**
1600 * Number of columns to render items in.
1601 * @default 1
1602diff --git a/reanimated.js b/reanimated.js
1603index f1265fa..fc03be2 100644
1604--- a/reanimated.js
1605+++ b/reanimated.js
1606@@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1607 const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props;
1608 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
1609 const prevItemKeyRef = React__namespace.useRef(void 0);
1610+ const previousPositionRef = React__namespace.useRef(positionValue);
1611+ const lastContentSizeEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1612 let shouldSkipTransitionForRecycleReuse = false;
1613- if (recycleItems && layoutTransition) {
1614+ if (layoutTransition) {
1615 const itemKeySignal = `containerItemKey${id}`;
1616 const itemKey = peek$(ctx, itemKeySignal);
1617 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
1618@@ -130,10 +132,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1619 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
1620 [horizontal, positionValue, style]
1621 );
1622+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
1623+ // which are compensated by an equal contentOffset shift so the row should
1624+ // not visibly move — animating turns the invisible correction into a
1625+ // lurch-and-settle; (2) any reposition while the user is actively
1626+ // scrolling, where rows shifting under the finger reads as jank. The
1627+ // transition exists to smooth at-rest shifts (streaming text, work-log
1628+ // folds), so gate it to at-rest moments.
1629+ const now = Date.now();
1630+ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1631+ const didPositionChange = positionValue !== previousPositionRef.current;
1632+ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function";
1633+ React__namespace.useLayoutEffect(() => {
1634+ if (didPositionChange) {
1635+ previousPositionRef.current = positionValue;
1636+ lastContentSizeEpochRef.current = contentSizeEpoch;
1637+ }
1638+ }, [contentSizeEpoch, didPositionChange, positionValue]);
1639+ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300;
1640+ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition;
1641 return /* @__PURE__ */ React__namespace.createElement(
1642 Reanimated__default.default.View,
1643 {
1644- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1645+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
1646 ref: refView,
1647 style: viewStyle,
1648 ...rest
1649@@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1650 children
1651 );
1652 });
1653+var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) {
1654+ const ctx = useStateContext();
1655+ const { horizontal, signalName, style, children, ...rest } = props;
1656+ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]);
1657+ const baseSize = Reanimated.useSharedValue(logicalSize);
1658+ const animatedDelta = Reanimated.useSharedValue(0);
1659+ const previousLogicalSizeRef = React__namespace.useRef(logicalSize);
1660+ const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1661+ const animationRunRef = React__namespace.useRef(0);
1662+ const isAnimatingRef = React__namespace.useRef(false);
1663+ const transition = props.layoutTransition;
1664+ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
1665+ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1666+ // Keep the animated size attached after transitions finish. Reanimated
1667+ // retains removed animated props, so switching to a static size leaves
1668+ // native scroll bounds stuck while logical measurements keep changing.
1669+ const animatedStyle = Reanimated.useAnimatedStyle(() => {
1670+ const size = Math.max(0, baseSize.value + animatedDelta.value);
1671+ return horizontal ? { width: size } : { height: size };
1672+ }, [horizontal]);
1673+ const completeAnimation = React__namespace.useCallback((run, epoch) => {
1674+ if (animationRunRef.current !== run) {
1675+ return;
1676+ }
1677+ isAnimatingRef.current = false;
1678+ const state = ctx.state;
1679+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1680+ return;
1681+ }
1682+ state.contentSizeAnimationActiveSignals.delete(signalName);
1683+ if (state.contentSizeAnimationActiveSignals.size === 0) {
1684+ state.contentSizeAnimationActiveEpoch = void 0;
1685+ state.contentSizeAnimationEligible = false;
1686+ }
1687+ }, [ctx.state, signalName]);
1688+ React__namespace.useLayoutEffect(() => {
1689+ const state = ctx.state;
1690+ const epoch = state.contentSizeAnimationEpoch || 0;
1691+ const previousLogicalSize = previousLogicalSizeRef.current;
1692+ const delta = logicalSize - previousLogicalSize;
1693+ const isNewEpoch = epoch !== previousEpochRef.current;
1694+ // Retarget measurements received mid-animation, even in the same epoch.
1695+ // Updating only the base keeps the old delta and can shrink scroll bounds.
1696+ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible);
1697+ previousLogicalSizeRef.current = logicalSize;
1698+ previousEpochRef.current = epoch;
1699+ if (!delta) {
1700+ return;
1701+ }
1702+ if (!shouldAnimate) {
1703+ baseSize.value = logicalSize;
1704+ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
1705+ state.contentSizeAnimationEligible = false;
1706+ }
1707+ return;
1708+ }
1709+ const [animation, config] = transition.getAnimationAndConfig();
1710+ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value;
1711+ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0;
1712+ if (typeof animation !== "function" || typeof delayFunction !== "function") {
1713+ baseSize.value = logicalSize;
1714+ if (signalName === "totalSize") {
1715+ state.contentSizeAnimationEligible = false;
1716+ }
1717+ return;
1718+ }
1719+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1720+ state.contentSizeAnimationActiveEpoch = epoch;
1721+ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set();
1722+ }
1723+ state.contentSizeAnimationActiveSignals.add(signalName);
1724+ const wasAnimating = isAnimatingRef.current;
1725+ isAnimatingRef.current = true;
1726+ const run = ++animationRunRef.current;
1727+ Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
1728+ "worklet";
1729+ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize;
1730+ baseSize.value = absoluteSize;
1731+ animatedDelta.value = currentVisualSize - absoluteSize;
1732+ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => {
1733+ "worklet";
1734+ if (finished) {
1735+ Reanimated.runOnJS(completeAnimation)(animationRun, animationEpoch);
1736+ }
1737+ }));
1738+ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
1739+ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
1740+ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children);
1741+});
1742 function setSharedValueValue(sharedValue, value) {
1743 if (!sharedValue) {
1744 return;
1745@@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo(
1746 );
1747 };
1748 }, [hasItemLayoutAnimation, recycleItems]);
1749+ const sizeComponentInternal = React__namespace.useMemo(() => {
1750+ if (!hasItemLayoutAnimation) {
1751+ return void 0;
1752+ }
1753+ return function SizeComponent(sizeProps) {
1754+ return /* @__PURE__ */ React__namespace.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current });
1755+ };
1756+ }, [hasItemLayoutAnimation]);
1757 const legendListProps = {
1758 ...rest,
1759 positionComponentInternal,
1760 recycleItems,
1761+ sizeComponentInternal,
1762 ...{
1763 renderScrollComponent: renderReanimatedScrollComponent,
1764 ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}
1765diff --git a/reanimated.mjs b/reanimated.mjs
1766index 29a00d5..6ab9292 100644
1767--- a/reanimated.mjs
1768+++ b/reanimated.mjs
1769@@ -1,7 +1,7 @@
1770 import * as React from 'react';
1771 import { useCallback } from 'react';
1772 import { View } from 'react-native';
1773-import Reanimated, { useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated';
1774+import Reanimated, { runOnJS, runOnUI, useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated';
1775 import { internal, LegendList } from '@legendapp/list/react-native';
1776
1777 // src/integrations/reanimated.tsx
1778@@ -91,8 +91,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1779 const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props;
1780 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
1781 const prevItemKeyRef = React.useRef(void 0);
1782+ const previousPositionRef = React.useRef(positionValue);
1783+ const lastContentSizeEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1784 let shouldSkipTransitionForRecycleReuse = false;
1785- if (recycleItems && layoutTransition) {
1786+ if (layoutTransition) {
1787 const itemKeySignal = `containerItemKey${id}`;
1788 const itemKey = peek$(ctx, itemKeySignal);
1789 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
1790@@ -106,10 +108,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1791 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
1792 [horizontal, positionValue, style]
1793 );
1794+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
1795+ // which are compensated by an equal contentOffset shift so the row should
1796+ // not visibly move — animating turns the invisible correction into a
1797+ // lurch-and-settle; (2) any reposition while the user is actively
1798+ // scrolling, where rows shifting under the finger reads as jank. The
1799+ // transition exists to smooth at-rest shifts (streaming text, work-log
1800+ // folds), so gate it to at-rest moments.
1801+ const now = Date.now();
1802+ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1803+ const didPositionChange = positionValue !== previousPositionRef.current;
1804+ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function";
1805+ React.useLayoutEffect(() => {
1806+ if (didPositionChange) {
1807+ previousPositionRef.current = positionValue;
1808+ lastContentSizeEpochRef.current = contentSizeEpoch;
1809+ }
1810+ }, [contentSizeEpoch, didPositionChange, positionValue]);
1811+ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300;
1812+ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition;
1813 return /* @__PURE__ */ React.createElement(
1814 Reanimated.View,
1815 {
1816- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1817+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
1818 ref: refView,
1819 style: viewStyle,
1820 ...rest
1821@@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
1822 children
1823 );
1824 });
1825+var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) {
1826+ const ctx = useStateContext();
1827+ const { horizontal, signalName, style, children, ...rest } = props;
1828+ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]);
1829+ const baseSize = useSharedValue(logicalSize);
1830+ const animatedDelta = useSharedValue(0);
1831+ const previousLogicalSizeRef = React.useRef(logicalSize);
1832+ const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
1833+ const animationRunRef = React.useRef(0);
1834+ const isAnimatingRef = React.useRef(false);
1835+ const transition = props.layoutTransition;
1836+ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
1837+ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
1838+ // Keep the animated size attached after transitions finish. Reanimated
1839+ // retains removed animated props, so switching to a static size leaves
1840+ // native scroll bounds stuck while logical measurements keep changing.
1841+ const animatedStyle = useAnimatedStyle(() => {
1842+ const size = Math.max(0, baseSize.value + animatedDelta.value);
1843+ return horizontal ? { width: size } : { height: size };
1844+ }, [horizontal]);
1845+ const completeAnimation = useCallback((run, epoch) => {
1846+ if (animationRunRef.current !== run) {
1847+ return;
1848+ }
1849+ isAnimatingRef.current = false;
1850+ const state = ctx.state;
1851+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1852+ return;
1853+ }
1854+ state.contentSizeAnimationActiveSignals.delete(signalName);
1855+ if (state.contentSizeAnimationActiveSignals.size === 0) {
1856+ state.contentSizeAnimationActiveEpoch = void 0;
1857+ state.contentSizeAnimationEligible = false;
1858+ }
1859+ }, [ctx.state, signalName]);
1860+ React.useLayoutEffect(() => {
1861+ const state = ctx.state;
1862+ const epoch = state.contentSizeAnimationEpoch || 0;
1863+ const previousLogicalSize = previousLogicalSizeRef.current;
1864+ const delta = logicalSize - previousLogicalSize;
1865+ const isNewEpoch = epoch !== previousEpochRef.current;
1866+ // Retarget measurements received mid-animation, even in the same epoch.
1867+ // Updating only the base keeps the old delta and can shrink scroll bounds.
1868+ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible);
1869+ previousLogicalSizeRef.current = logicalSize;
1870+ previousEpochRef.current = epoch;
1871+ if (!delta) {
1872+ return;
1873+ }
1874+ if (!shouldAnimate) {
1875+ baseSize.value = logicalSize;
1876+ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
1877+ state.contentSizeAnimationEligible = false;
1878+ }
1879+ return;
1880+ }
1881+ const [animation, config] = transition.getAnimationAndConfig();
1882+ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value;
1883+ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0;
1884+ if (typeof animation !== "function" || typeof delayFunction !== "function") {
1885+ baseSize.value = logicalSize;
1886+ if (signalName === "totalSize") {
1887+ state.contentSizeAnimationEligible = false;
1888+ }
1889+ return;
1890+ }
1891+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
1892+ state.contentSizeAnimationActiveEpoch = epoch;
1893+ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set();
1894+ }
1895+ state.contentSizeAnimationActiveSignals.add(signalName);
1896+ const wasAnimating = isAnimatingRef.current;
1897+ isAnimatingRef.current = true;
1898+ const run = ++animationRunRef.current;
1899+ runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
1900+ "worklet";
1901+ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize;
1902+ baseSize.value = absoluteSize;
1903+ animatedDelta.value = currentVisualSize - absoluteSize;
1904+ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => {
1905+ "worklet";
1906+ if (finished) {
1907+ runOnJS(completeAnimation)(animationRun, animationEpoch);
1908+ }
1909+ }));
1910+ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
1911+ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
1912+ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children);
1913+});
1914 function setSharedValueValue(sharedValue, value) {
1915 if (!sharedValue) {
1916 return;
1917@@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo(
1918 );
1919 };
1920 }, [hasItemLayoutAnimation, recycleItems]);
1921+ const sizeComponentInternal = React.useMemo(() => {
1922+ if (!hasItemLayoutAnimation) {
1923+ return void 0;
1924+ }
1925+ return function SizeComponent(sizeProps) {
1926+ return /* @__PURE__ */ React.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current });
1927+ };
1928+ }, [hasItemLayoutAnimation]);
1929 const legendListProps = {
1930 ...rest,
1931 positionComponentInternal,
1932 recycleItems,
1933+ sizeComponentInternal,
1934 ...{
1935 renderScrollComponent: renderReanimatedScrollComponent,
1936 ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}