authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 03:05:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 03:05:25-07:00
log90f3913a81380af7aeafac62f8132aeb98345a12
treeddfdf3728bdd6695e41337b5f3c00a02b5f0a130
parent2652fee49bdb0a7d286124cd2bd318155d4a7931
signature Signed by PGP key B5690EEEBB952194

fix(mobile): stop long iOS threads from jumping while scrolling up (#4867)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 1040 insertions(+), 954 deletions(-)

apps/mobile/package.json+1-1
...@@ -49,7 +49,7 @@...@@ -49,7 +49,7 @@
49 "@expo-google-fonts/dm-sans": "^0.4.2",49 "@expo-google-fonts/dm-sans": "^0.4.2",
50 "@expo/metro-runtime": "~56.0.15",50 "@expo/metro-runtime": "~56.0.15",
51 "@expo/ui": "~56.0.18",51 "@expo/ui": "~56.0.18",
52 "@legendapp/list": "3.2.0",52 "@legendapp/list": "3.3.3",
53 "@noble/curves": "catalog:",53 "@noble/curves": "catalog:",
54 "@noble/hashes": "catalog:",54 "@noble/hashes": "catalog:",
55 "@pierre/diffs": "catalog:",55 "@pierre/diffs": "catalog:",
patches/@legendapp__list@3.2.0.patch deleted-940
...@@ -1,940 +0,0 @@
1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 5a115ea..2c65d31 100644
3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts
5@@ -269,7 +269,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): {
11 contentInsetEndAdjustment: SharedValue<number>;
12 onComposerLayout: (event: LayoutChangeEvent) => void;
13 };
14@@ -278,8 +278,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 736286a..8218172 100644
27--- a/keyboard.js
28+++ b/keyboard.js
29@@ -33,19 +33,19 @@ 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) {
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 lastHeightRef.current = height;
44 contentInsetEndAdjustment.value = height;
45- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
46 }
47 },
48- [contentInsetEndAdjustment, listRef]
49+ [contentInsetEndAdjustment, heightAdjustment, listRef]
50 );
51 React.useLayoutEffect(() => {
52 var _a;
53@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
54 }
55 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
56 const {
57+ adjustedInsetCompensation,
58 anchoredEndSpace,
59 applyWorkaroundForContentInsetHitTestBug,
60 contentInsetEndAdjustment,
61+ contentInsetEndStaticAdjustment,
62 freeze,
63 keyboardLiftBehavior,
64 keyboardOffset,
65@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
66 includeInEndInset: true,
67 onSizeChanged: (size) => {
68 var _a;
69- blankSpace.value = size;
70+ // The anchored blank is sized against the ADJUSTED viewport, but the
71+ // scroll component writes it as a raw contentInset that UIKit tops up
72+ // with the safe-area bottom — write it net of that or the end rest
73+ // (and the anchored message) sinks one safe-area under the header.
74+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
75 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
76 }
77 };
78- }, [anchoredEndSpace, blankSpace]);
79+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
80 const onContentInsetChange = React.useCallback((insets) => {
81 var _a;
82 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
83@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
84 reactNativeKeyboardController.KeyboardChatScrollView,
85 {
86 ...scrollProps,
87+ adjustedInsetCompensation,
88 applyWorkaroundForContentInsetHitTestBug,
89 blankSpace,
90 extraContentPadding: contentInsetEndAdjustment,
91@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
92 );
93 },
94 [
95+ adjustedInsetCompensation,
96 applyWorkaroundForContentInsetHitTestBug,
97 blankSpace,
98 contentInsetEndAdjustment,
99@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
100 AnimatedLegendListInternal,
101 {
102 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
103+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
104 ref: combinedRef,
105 renderScrollComponent: memoList,
106 ...rest
107diff --git a/keyboard.mjs b/keyboard.mjs
108index c1dd270..cb0d142 100644
109--- a/keyboard.mjs
110+++ b/keyboard.mjs
111@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
112 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
113 );
114 }
115-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
116+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
117 const contentInsetEndAdjustment = useSharedValue(initialHeight);
118 const lastHeightRef = useRef(void 0);
119 const reportHeight = useCallback(
120- (height) => {
121+ (rawHeight) => {
122+ const height = Math.max(0, rawHeight + heightAdjustment);
123 var _a;
124 if (Number.isFinite(height) && height !== lastHeightRef.current) {
125 lastHeightRef.current = height;
126 contentInsetEndAdjustment.value = height;
127- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
128 }
129 },
130- [contentInsetEndAdjustment, listRef]
131+ [contentInsetEndAdjustment, heightAdjustment, listRef]
132 );
133 useLayoutEffect(() => {
134 var _a;
135@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
136 }
137 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
138 const {
139+ adjustedInsetCompensation,
140 anchoredEndSpace,
141 applyWorkaroundForContentInsetHitTestBug,
142 contentInsetEndAdjustment,
143+ contentInsetEndStaticAdjustment,
144 freeze,
145 keyboardLiftBehavior,
146 keyboardOffset,
147@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
148 includeInEndInset: true,
149 onSizeChanged: (size) => {
150 var _a;
151- blankSpace.value = size;
152+ // The anchored blank is sized against the ADJUSTED viewport, but the
153+ // scroll component writes it as a raw contentInset that UIKit tops up
154+ // with the safe-area bottom — write it net of that or the end rest
155+ // (and the anchored message) sinks one safe-area under the header.
156+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
157 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
158 }
159 };
160- }, [anchoredEndSpace, blankSpace]);
161+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
162 const onContentInsetChange = useCallback((insets) => {
163 var _a;
164 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
165@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
166 KeyboardChatScrollView,
167 {
168 ...scrollProps,
169+ adjustedInsetCompensation,
170 applyWorkaroundForContentInsetHitTestBug,
171 blankSpace,
172 extraContentPadding: contentInsetEndAdjustment,
173@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
174 );
175 },
176 [
177+ adjustedInsetCompensation,
178 applyWorkaroundForContentInsetHitTestBug,
179 blankSpace,
180 contentInsetEndAdjustment,
181@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
182 AnimatedLegendListInternal,
183 {
184 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
185+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
186 ref: combinedRef,
187 renderScrollComponent: memoList,
188 ...rest
189diff --git a/react-native.d.ts b/react-native.d.ts
190index 72d3f59..435a5fc 100644
191--- a/react-native.d.ts
192+++ b/react-native.d.ts
193@@ -284,6 +284,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
194 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
195 */
196 contentInsetEndAdjustment?: number;
197+ /**
198+ * Width/height of a leading content inset applied natively outside the list's knowledge
199+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
200+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
201+ */
202+ contentInsetStartAdjustment?: number;
203 /**
204 * Number of columns to render items in.
205 * @default 1
206diff --git a/react-native.js b/react-native.js
207index 8d4ff89..003b2f7 100644
208--- a/react-native.js
209+++ b/react-native.js
210@@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, {
211 if (didInitialScroll) {
212 state.didFinishInitialScroll = true;
213 }
214- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
215+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
216 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
217 set$(ctx, "readyToRender", true);
218 setAdaptiveRender(ctx, "normal");
219@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
220 }
221
222 // src/core/clampScrollOffset.ts
223+function getContentInsetStartAdjustment(ctx) {
224+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
225+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
226+}
227 function clampScrollOffset(ctx, offset, scrollTarget) {
228 const state = ctx.state;
229 const contentSize = getContentSize(ctx);
230+ const minOffset = -getContentInsetStartAdjustment(ctx);
231 let clampedOffset = offset;
232 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
233- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
234+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
235 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
236 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
237 const maxOffset = baseMaxOffset + extraEndOffset;
238 clampedOffset = Math.min(offset, maxOffset);
239 }
240- clampedOffset = Math.max(0, clampedOffset);
241+ clampedOffset = Math.max(minOffset, clampedOffset);
242 return clampedOffset;
243 }
244
245@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) {
246 finishScrollTo(ctx);
247 }
248 }
249-function scrollToFallbackOffset(ctx, offset) {
250+function scrollToFallbackOffset(ctx, offset, animated) {
251 var _a3;
252 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
253- animated: false,
254+ animated: !!animated,
255 x: ctx.state.props.horizontal ? offset : 0,
256 y: ctx.state.props.horizontal ? 0 : offset
257 });
258@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) {
259 });
260 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
261 } else if (shouldRetryUnalignedEndScroll) {
262- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
263+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
264+ if (!isActivelyAnimatingToEnd) {
265+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
266+ }
267 scheduleFallbackCheck(100);
268 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
269 finishScrollTo(ctx);
270@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) {
271 }
272 state.pendingMaintainScrollAtEnd = false;
273 if (shouldMaintainScrollAtEnd) {
274+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
275+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
276+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
277+ return false;
278+ }
279 const contentSize = getContentSize(ctx);
280+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
281 if (contentSize < state.scrollLength) {
282- state.scroll = 0;
283+ state.scroll = -maintainInsetStartAdjustment;
284+ if (maintainInsetStartAdjustment > 0) {
285+ return true;
286+ }
287 }
288 if (!state.maintainingScrollAtEnd) {
289 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
290@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) {
291 y: 0
292 });
293 } else {
294- scroller == null ? void 0 : scroller.scrollToEnd({
295- animated: maintainScrollAtEnd.animated
296- });
297+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
298+ if (insetStartAdjustment > 0) {
299+ scroller == null ? void 0 : scroller.scrollTo({
300+ animated: maintainScrollAtEnd.animated,
301+ x: 0,
302+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
303+ });
304+ } else {
305+ scroller == null ? void 0 : scroller.scrollToEnd({
306+ animated: maintainScrollAtEnd.animated
307+ });
308+ }
309 }
310 setTimeout(
311 () => {
312@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
313 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
314 return 0;
315 }
316- const maxScroll = Math.max(0, totalSize - state.scrollLength);
317+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
318+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
319+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
320 const clampDelta = maxScroll - state.scroll;
321 if (unresolvedAmount < 0) {
322 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
323@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
324 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
325 return true;
326 }
327- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
328+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
329 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
330 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
331 if (isAtExpectedNativeClamp) {
332@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) {
333 if (diff > 0) {
334 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
335 } else {
336- const maxScroll = Math.max(0, totalSize - state.scrollLength);
337+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
338 state.scroll = maxScroll;
339 state.scrollPending = maxScroll;
340 diff = 0;
341@@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, {
342 }
343
344 // src/core/initialScroll.ts
345+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
346+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
347+var INSET_END_REVEAL_STABLE_FRAMES = 3;
348+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
349+function startInsetEndSettleWatchdog(ctx) {
350+ const state = ctx.state;
351+ if (state.insetEndSettleWatchdogActive) {
352+ return;
353+ }
354+ state.insetEndSettleWatchdogActive = true;
355+ state.didUserDrag = false;
356+ // Hold the readyToRender opacity gate until the end landing is stable, so
357+ // the estimated-to-measured settle chase happens before first VISIBLE
358+ // paint instead of in front of the user. Capped so slow measurement can
359+ // never hide content for long.
360+ state.insetEndRevealHold = true;
361+ let frames = 0;
362+ let settledFrames = 0;
363+ let revealStableFrames = 0;
364+ const releaseRevealHold = () => {
365+ if (state.insetEndRevealHold) {
366+ state.insetEndRevealHold = false;
367+ setInitialRenderState(ctx, {});
368+ }
369+ };
370+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
371+ // can move the end target one more time — do that upgrade while still
372+ // hidden (phase 1), then require stability again before revealing (phase 2)
373+ // so the first visible frame is the settled one.
374+ let revealPhase = 1;
375+ const onRevealStability = () => {
376+ var _a3;
377+ if (!state.insetEndRevealHold) {
378+ return;
379+ }
380+ if (revealPhase === 1) {
381+ revealPhase = 2;
382+ revealStableFrames = 0;
383+ setAdaptiveRender(ctx, "normal");
384+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
385+ return;
386+ }
387+ releaseRevealHold();
388+ };
389+ const tick = () => {
390+ // The user owns the scroll from their first drag — a re-pin here would
391+ // pin the list under their finger.
392+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
393+ state.insetEndSettleWatchdogActive = false;
394+ releaseRevealHold();
395+ return;
396+ }
397+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
398+ releaseRevealHold();
399+ }
400+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
401+ const contentSize = getContentSize(ctx);
402+ const scrollLength = state.scrollLength;
403+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
404+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
405+ const distance = endOffset - state.scroll;
406+ // Estimated row sizes converging to measured ones can strand the initial
407+ // end landing when the library's own end-anchor bookkeeping gives up.
408+ // While still near the end (never fighting a user who scrolled away),
409+ // re-pin to the current true end until sizes stop changing.
410+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
411+ settledFrames = 0;
412+ revealStableFrames = 0;
413+ const scroller = state.refScroller.current;
414+ if (scroller) {
415+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
416+ }
417+ } else {
418+ settledFrames++;
419+ revealStableFrames++;
420+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
421+ onRevealStability();
422+ }
423+ }
424+ } else {
425+ // Conditions that make re-pinning unnecessary (underflow, in-flight
426+ // programmatic scroll) count toward stability for the reveal.
427+ revealStableFrames++;
428+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
429+ onRevealStability();
430+ }
431+ }
432+ requestAnimationFrame(tick);
433+ };
434+ requestAnimationFrame(tick);
435+}
436 function dispatchInitialScroll(ctx, params) {
437 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
438+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
439+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
440+ if (ctx.state.scrollLength <= 0) {
441+ // The scroll length is unknown, so the resolved target is meaningless;
442+ // the initial-scroll machinery re-advances after layout.
443+ return;
444+ }
445+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
446+ // Content underflows the viewport and nothing has scrolled yet: the
447+ // untouched native rest position (UIKit's adjustedContentInset) IS the
448+ // end position. Dispatching a negative scroll here would race UIKit's
449+ // inset application during screen attach, so just finish the session.
450+ finishInitialScroll(ctx, {
451+ resolvedOffset
452+ });
453+ return;
454+ }
455+ ctx.state.didDispatchInsetInitialScroll = true;
456+ if (target.viewPosition === 1) {
457+ startInsetEndSettleWatchdog(ctx);
458+ }
459+ }
460 const requestedIndex = target.index;
461 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
462 const itemSize = getItemSizeAtIndex(ctx, index);
463@@ -2804,7 +2945,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
464 return;
465 }
466 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
467- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
468+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
469+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
470+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
471 if (!shouldKeepEndTargetAlive) {
472 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
473 clearPendingInitialScrollFooterLayout(ctx, {
474@@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
475 }
476 contentBelowAnchor += footerSize + stylePaddingBottom;
477 isReady = !hasUnknownTailSize;
478- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
479+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
480+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
481 } else if (anchorIndex >= 0) {
482 isReady = false;
483 }
484@@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
485 updateScroll(ctx, state.scroll, true);
486 }
487 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
488+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
489+ set$(ctx, "anchoredEndSpaceSize", nextSize);
490+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
491+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
492+ updateScroll(ctx, state.scroll, true);
493+ }
494 }
495 return nextSize;
496 }
497@@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
498 dataVersion,
499 drawDistance = 250,
500 contentInsetEndAdjustment,
501+ contentInsetStartAdjustment,
502 estimatedItemSize = 100,
503 estimatedListSize,
504 extraData,
505@@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
506 onLayout: onLayoutProp,
507 onLoad,
508 onMomentumScrollEnd,
509+ onScrollBeginDrag,
510 onRefresh,
511 onScroll: onScrollProp,
512 onStartReached,
513@@ -6577,7 +6729,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
514 const combinedRef = useCombinedRef(refScroller, refScrollView);
515 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
516 const stickyHeaderIndices = stickyHeaderIndicesProp;
517- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
518+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
519 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
520 const alwaysRenderIndices = React2.useMemo(() => {
521 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
522@@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
523 contentContainerAlignItems: contentContainerStyle.alignItems,
524 contentInset,
525 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
526+ contentInsetStartAdjustment,
527 data: dataProp,
528 dataVersion,
529 drawDistance,
530@@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
531 return void 0;
532 }
533 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
534+ if (getContentInsetStartAdjustment(ctx) > 0) {
535+ // With a native leading inset (transparent header + automatic insets),
536+ // any mount-time contentOffset races UIKit's adjustedContentInset
537+ // application. The gated initial-scroll dispatch positions the list once
538+ // real sizes are known instead.
539+ return void 0;
540+ }
541 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
542 }, [usesBootstrapInitialScroll]);
543 React2.useLayoutEffect(() => {
544@@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
545 onMomentumScrollEnd(event);
546 }
547 },
548+ onScrollBeginDrag: (event) => {
549+ ctx.state.didUserDrag = true;
550+ if (onScrollBeginDrag) {
551+ onScrollBeginDrag(event);
552+ }
553+ },
554 onScroll: (event) => onScroll(ctx, event)
555 }),
556 []
557@@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
558 onLayout,
559 onLayoutFooter,
560 onMomentumScrollEnd: fns.onMomentumScrollEnd,
561+ onScrollBeginDrag: fns.onScrollBeginDrag,
562 onScroll: onScrollHandler,
563 recycleItems,
564 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
565diff --git a/react-native.mjs b/react-native.mjs
566index 2e96ca7..fc88d4b 100644
567--- a/react-native.mjs
568+++ b/react-native.mjs
569@@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, {
570 if (didInitialScroll) {
571 state.didFinishInitialScroll = true;
572 }
573- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
574+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
575 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
576 set$(ctx, "readyToRender", true);
577 setAdaptiveRender(ctx, "normal");
578@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
579 }
580
581 // src/core/clampScrollOffset.ts
582+function getContentInsetStartAdjustment(ctx) {
583+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
584+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
585+}
586 function clampScrollOffset(ctx, offset, scrollTarget) {
587 const state = ctx.state;
588 const contentSize = getContentSize(ctx);
589+ const minOffset = -getContentInsetStartAdjustment(ctx);
590 let clampedOffset = offset;
591 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
592- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
593+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
594 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
595 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
596 const maxOffset = baseMaxOffset + extraEndOffset;
597 clampedOffset = Math.min(offset, maxOffset);
598 }
599- clampedOffset = Math.max(0, clampedOffset);
600+ clampedOffset = Math.max(minOffset, clampedOffset);
601 return clampedOffset;
602 }
603
604@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) {
605 finishScrollTo(ctx);
606 }
607 }
608-function scrollToFallbackOffset(ctx, offset) {
609+function scrollToFallbackOffset(ctx, offset, animated) {
610 var _a3;
611 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
612- animated: false,
613+ animated: !!animated,
614 x: ctx.state.props.horizontal ? offset : 0,
615 y: ctx.state.props.horizontal ? 0 : offset
616 });
617@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) {
618 });
619 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
620 } else if (shouldRetryUnalignedEndScroll) {
621- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
622+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
623+ if (!isActivelyAnimatingToEnd) {
624+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
625+ }
626 scheduleFallbackCheck(100);
627 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
628 finishScrollTo(ctx);
629@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) {
630 }
631 state.pendingMaintainScrollAtEnd = false;
632 if (shouldMaintainScrollAtEnd) {
633+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
634+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
635+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
636+ return false;
637+ }
638 const contentSize = getContentSize(ctx);
639+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
640 if (contentSize < state.scrollLength) {
641- state.scroll = 0;
642+ state.scroll = -maintainInsetStartAdjustment;
643+ if (maintainInsetStartAdjustment > 0) {
644+ return true;
645+ }
646 }
647 if (!state.maintainingScrollAtEnd) {
648 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
649@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) {
650 y: 0
651 });
652 } else {
653- scroller == null ? void 0 : scroller.scrollToEnd({
654- animated: maintainScrollAtEnd.animated
655- });
656+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
657+ if (insetStartAdjustment > 0) {
658+ scroller == null ? void 0 : scroller.scrollTo({
659+ animated: maintainScrollAtEnd.animated,
660+ x: 0,
661+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
662+ });
663+ } else {
664+ scroller == null ? void 0 : scroller.scrollToEnd({
665+ animated: maintainScrollAtEnd.animated
666+ });
667+ }
668 }
669 setTimeout(
670 () => {
671@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
672 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
673 return 0;
674 }
675- const maxScroll = Math.max(0, totalSize - state.scrollLength);
676+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
677+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
678+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
679 const clampDelta = maxScroll - state.scroll;
680 if (unresolvedAmount < 0) {
681 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
682@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
683 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
684 return true;
685 }
686- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
687+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
688 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
689 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
690 if (isAtExpectedNativeClamp) {
691@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) {
692 if (diff > 0) {
693 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
694 } else {
695- const maxScroll = Math.max(0, totalSize - state.scrollLength);
696+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
697 state.scroll = maxScroll;
698 state.scrollPending = maxScroll;
699 diff = 0;
700@@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, {
701 }
702
703 // src/core/initialScroll.ts
704+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
705+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
706+var INSET_END_REVEAL_STABLE_FRAMES = 3;
707+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
708+function startInsetEndSettleWatchdog(ctx) {
709+ const state = ctx.state;
710+ if (state.insetEndSettleWatchdogActive) {
711+ return;
712+ }
713+ state.insetEndSettleWatchdogActive = true;
714+ state.didUserDrag = false;
715+ // Hold the readyToRender opacity gate until the end landing is stable, so
716+ // the estimated-to-measured settle chase happens before first VISIBLE
717+ // paint instead of in front of the user. Capped so slow measurement can
718+ // never hide content for long.
719+ state.insetEndRevealHold = true;
720+ let frames = 0;
721+ let settledFrames = 0;
722+ let revealStableFrames = 0;
723+ const releaseRevealHold = () => {
724+ if (state.insetEndRevealHold) {
725+ state.insetEndRevealHold = false;
726+ setInitialRenderState(ctx, {});
727+ }
728+ };
729+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
730+ // can move the end target one more time — do that upgrade while still
731+ // hidden (phase 1), then require stability again before revealing (phase 2)
732+ // so the first visible frame is the settled one.
733+ let revealPhase = 1;
734+ const onRevealStability = () => {
735+ var _a3;
736+ if (!state.insetEndRevealHold) {
737+ return;
738+ }
739+ if (revealPhase === 1) {
740+ revealPhase = 2;
741+ revealStableFrames = 0;
742+ setAdaptiveRender(ctx, "normal");
743+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
744+ return;
745+ }
746+ releaseRevealHold();
747+ };
748+ const tick = () => {
749+ // The user owns the scroll from their first drag — a re-pin here would
750+ // pin the list under their finger.
751+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
752+ state.insetEndSettleWatchdogActive = false;
753+ releaseRevealHold();
754+ return;
755+ }
756+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
757+ releaseRevealHold();
758+ }
759+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
760+ const contentSize = getContentSize(ctx);
761+ const scrollLength = state.scrollLength;
762+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
763+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
764+ const distance = endOffset - state.scroll;
765+ // Estimated row sizes converging to measured ones can strand the initial
766+ // end landing when the library's own end-anchor bookkeeping gives up.
767+ // While still near the end (never fighting a user who scrolled away),
768+ // re-pin to the current true end until sizes stop changing.
769+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
770+ settledFrames = 0;
771+ revealStableFrames = 0;
772+ const scroller = state.refScroller.current;
773+ if (scroller) {
774+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
775+ }
776+ } else {
777+ settledFrames++;
778+ revealStableFrames++;
779+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
780+ onRevealStability();
781+ }
782+ }
783+ } else {
784+ // Conditions that make re-pinning unnecessary (underflow, in-flight
785+ // programmatic scroll) count toward stability for the reveal.
786+ revealStableFrames++;
787+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
788+ onRevealStability();
789+ }
790+ }
791+ requestAnimationFrame(tick);
792+ };
793+ requestAnimationFrame(tick);
794+}
795 function dispatchInitialScroll(ctx, params) {
796 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
797+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
798+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
799+ if (ctx.state.scrollLength <= 0) {
800+ // The scroll length is unknown, so the resolved target is meaningless;
801+ // the initial-scroll machinery re-advances after layout.
802+ return;
803+ }
804+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
805+ // Content underflows the viewport and nothing has scrolled yet: the
806+ // untouched native rest position (UIKit's adjustedContentInset) IS the
807+ // end position. Dispatching a negative scroll here would race UIKit's
808+ // inset application during screen attach, so just finish the session.
809+ finishInitialScroll(ctx, {
810+ resolvedOffset
811+ });
812+ return;
813+ }
814+ ctx.state.didDispatchInsetInitialScroll = true;
815+ if (target.viewPosition === 1) {
816+ startInsetEndSettleWatchdog(ctx);
817+ }
818+ }
819 const requestedIndex = target.index;
820 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
821 const itemSize = getItemSizeAtIndex(ctx, index);
822@@ -2783,7 +2924,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
823 return;
824 }
825 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
826- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
827+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
828+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
829+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
830 if (!shouldKeepEndTargetAlive) {
831 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
832 clearPendingInitialScrollFooterLayout(ctx, {
833@@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
834 }
835 contentBelowAnchor += footerSize + stylePaddingBottom;
836 isReady = !hasUnknownTailSize;
837- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
838+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
839+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
840 } else if (anchorIndex >= 0) {
841 isReady = false;
842 }
843@@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
844 updateScroll(ctx, state.scroll, true);
845 }
846 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
847+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
848+ set$(ctx, "anchoredEndSpaceSize", nextSize);
849+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
850+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
851+ updateScroll(ctx, state.scroll, true);
852+ }
853 }
854 return nextSize;
855 }
856@@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
857 dataVersion,
858 drawDistance = 250,
859 contentInsetEndAdjustment,
860+ contentInsetStartAdjustment,
861 estimatedItemSize = 100,
862 estimatedListSize,
863 extraData,
864@@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
865 onLayout: onLayoutProp,
866 onLoad,
867 onMomentumScrollEnd,
868+ onScrollBeginDrag,
869 onRefresh,
870 onScroll: onScrollProp,
871 onStartReached,
872@@ -6556,7 +6708,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
873 const combinedRef = useCombinedRef(refScroller, refScrollView);
874 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
875 const stickyHeaderIndices = stickyHeaderIndicesProp;
876- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
877+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
878 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
879 const alwaysRenderIndices = useMemo(() => {
880 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
881@@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
882 contentContainerAlignItems: contentContainerStyle.alignItems,
883 contentInset,
884 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
885+ contentInsetStartAdjustment,
886 data: dataProp,
887 dataVersion,
888 drawDistance,
889@@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
890 return void 0;
891 }
892 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
893+ if (getContentInsetStartAdjustment(ctx) > 0) {
894+ // With a native leading inset (transparent header + automatic insets),
895+ // any mount-time contentOffset races UIKit's adjustedContentInset
896+ // application. The gated initial-scroll dispatch positions the list once
897+ // real sizes are known instead.
898+ return void 0;
899+ }
900 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
901 }, [usesBootstrapInitialScroll]);
902 useLayoutEffect(() => {
903@@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
904 onMomentumScrollEnd(event);
905 }
906 },
907+ onScrollBeginDrag: (event) => {
908+ ctx.state.didUserDrag = true;
909+ if (onScrollBeginDrag) {
910+ onScrollBeginDrag(event);
911+ }
912+ },
913 onScroll: (event) => onScroll(ctx, event)
914 }),
915 []
916@@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
917 onLayout,
918 onLayoutFooter,
919 onMomentumScrollEnd: fns.onMomentumScrollEnd,
920+ onScrollBeginDrag: fns.onScrollBeginDrag,
921 onScroll: onScrollHandler,
922 recycleItems,
923 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
924diff --git a/reanimated.d.ts b/reanimated.d.ts
925index 7e2d11f..d5b0d66 100644
926--- a/reanimated.d.ts
927+++ b/reanimated.d.ts
928@@ -285,6 +285,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
929 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
930 */
931 contentInsetEndAdjustment?: number;
932+ /**
933+ * Width/height of a leading content inset applied natively outside the list's knowledge
934+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
935+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
936+ */
937+ contentInsetStartAdjustment?: number;
938 /**
939 * Number of columns to render items in.
940 * @default 1
patches/@legendapp__list@3.3.3.patch created+1014
...@@ -0,0 +1,1014 @@
1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 7bc3bb8..75ec120 100644
3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts
5@@ -277,7 +277,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): {
11 contentInsetEndAdjustment: SharedValue<number>;
12 onComposerLayout: (event: LayoutChangeEvent) => void;
13 };
14@@ -286,8 +286,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 736286a..8218172 100644
27--- a/keyboard.js
28+++ b/keyboard.js
29@@ -33,19 +33,19 @@ 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) {
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 lastHeightRef.current = height;
44 contentInsetEndAdjustment.value = height;
45- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
46 }
47 },
48- [contentInsetEndAdjustment, listRef]
49+ [contentInsetEndAdjustment, heightAdjustment, listRef]
50 );
51 React.useLayoutEffect(() => {
52 var _a;
53@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
54 }
55 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
56 const {
57+ adjustedInsetCompensation,
58 anchoredEndSpace,
59 applyWorkaroundForContentInsetHitTestBug,
60 contentInsetEndAdjustment,
61+ contentInsetEndStaticAdjustment,
62 freeze,
63 keyboardLiftBehavior,
64 keyboardOffset,
65@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
66 includeInEndInset: true,
67 onSizeChanged: (size) => {
68 var _a;
69- blankSpace.value = size;
70+ // The anchored blank is sized against the ADJUSTED viewport, but the
71+ // scroll component writes it as a raw contentInset that UIKit tops up
72+ // with the safe-area bottom — write it net of that or the end rest
73+ // (and the anchored message) sinks one safe-area under the header.
74+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
75 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
76 }
77 };
78- }, [anchoredEndSpace, blankSpace]);
79+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
80 const onContentInsetChange = React.useCallback((insets) => {
81 var _a;
82 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
83@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
84 reactNativeKeyboardController.KeyboardChatScrollView,
85 {
86 ...scrollProps,
87+ adjustedInsetCompensation,
88 applyWorkaroundForContentInsetHitTestBug,
89 blankSpace,
90 extraContentPadding: contentInsetEndAdjustment,
91@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
92 );
93 },
94 [
95+ adjustedInsetCompensation,
96 applyWorkaroundForContentInsetHitTestBug,
97 blankSpace,
98 contentInsetEndAdjustment,
99@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
100 AnimatedLegendListInternal,
101 {
102 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
103+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
104 ref: combinedRef,
105 renderScrollComponent: memoList,
106 ...rest
107diff --git a/keyboard.mjs b/keyboard.mjs
108index c1dd270..cb0d142 100644
109--- a/keyboard.mjs
110+++ b/keyboard.mjs
111@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
112 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
113 );
114 }
115-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
116+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
117 const contentInsetEndAdjustment = useSharedValue(initialHeight);
118 const lastHeightRef = useRef(void 0);
119 const reportHeight = useCallback(
120- (height) => {
121+ (rawHeight) => {
122+ const height = Math.max(0, rawHeight + heightAdjustment);
123 var _a;
124 if (Number.isFinite(height) && height !== lastHeightRef.current) {
125 lastHeightRef.current = height;
126 contentInsetEndAdjustment.value = height;
127- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
128 }
129 },
130- [contentInsetEndAdjustment, listRef]
131+ [contentInsetEndAdjustment, heightAdjustment, listRef]
132 );
133 useLayoutEffect(() => {
134 var _a;
135@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
136 }
137 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
138 const {
139+ adjustedInsetCompensation,
140 anchoredEndSpace,
141 applyWorkaroundForContentInsetHitTestBug,
142 contentInsetEndAdjustment,
143+ contentInsetEndStaticAdjustment,
144 freeze,
145 keyboardLiftBehavior,
146 keyboardOffset,
147@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
148 includeInEndInset: true,
149 onSizeChanged: (size) => {
150 var _a;
151- blankSpace.value = size;
152+ // The anchored blank is sized against the ADJUSTED viewport, but the
153+ // scroll component writes it as a raw contentInset that UIKit tops up
154+ // with the safe-area bottom — write it net of that or the end rest
155+ // (and the anchored message) sinks one safe-area under the header.
156+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
157 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
158 }
159 };
160- }, [anchoredEndSpace, blankSpace]);
161+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
162 const onContentInsetChange = useCallback((insets) => {
163 var _a;
164 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
165@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
166 KeyboardChatScrollView,
167 {
168 ...scrollProps,
169+ adjustedInsetCompensation,
170 applyWorkaroundForContentInsetHitTestBug,
171 blankSpace,
172 extraContentPadding: contentInsetEndAdjustment,
173@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
174 );
175 },
176 [
177+ adjustedInsetCompensation,
178 applyWorkaroundForContentInsetHitTestBug,
179 blankSpace,
180 contentInsetEndAdjustment,
181@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
182 AnimatedLegendListInternal,
183 {
184 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
185+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
186 ref: combinedRef,
187 renderScrollComponent: memoList,
188 ...rest
189diff --git a/react-native.d.ts b/react-native.d.ts
190index 8204015..cdeaab7 100644
191--- a/react-native.d.ts
192+++ b/react-native.d.ts
193@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
194 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
195 */
196 contentInsetEndAdjustment?: number;
197+ /**
198+ * Width/height of a leading content inset applied natively outside the list's knowledge
199+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
200+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
201+ */
202+ contentInsetStartAdjustment?: number;
203 /**
204 * Number of columns to render items in.
205 * @default 1
206diff --git a/react-native.js b/react-native.js
207index 229f09a..2a1ceb6 100644
208--- a/react-native.js
209+++ b/react-native.js
210@@ -930,7 +930,7 @@ function setInitialRenderState(ctx, {
211 if (didInitialScroll) {
212 state.didFinishInitialScroll = true;
213 }
214- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
215+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
216 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
217 set$(ctx, "readyToRender", true);
218 setAdaptiveRender(ctx, "normal", "ready");
219@@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
220 }
221
222 // src/core/clampScrollOffset.ts
223+function getContentInsetStartAdjustment(ctx) {
224+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
225+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
226+}
227 function clampScrollOffset(ctx, offset, scrollTarget) {
228 const state = ctx.state;
229 const contentSize = getContentSize(ctx);
230+ const minOffset = -getContentInsetStartAdjustment(ctx);
231 let clampedOffset = offset;
232 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
233- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
234+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
235 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
236 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
237 const maxOffset = baseMaxOffset + extraEndOffset;
238 clampedOffset = Math.min(offset, maxOffset);
239 }
240- clampedOffset = Math.max(0, clampedOffset);
241+ clampedOffset = Math.max(minOffset, clampedOffset);
242 return clampedOffset;
243 }
244
245@@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) {
246 finishScrollTo(ctx);
247 }
248 }
249-function scrollToFallbackOffset(ctx, offset) {
250+function scrollToFallbackOffset(ctx, offset, animated) {
251 var _a3;
252 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
253- animated: false,
254+ animated: !!animated,
255 x: ctx.state.props.horizontal ? offset : 0,
256 y: ctx.state.props.horizontal ? 0 : offset
257 });
258@@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) {
259 });
260 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
261 } else if (shouldRetryUnalignedEndScroll) {
262- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
263+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
264+ if (!isActivelyAnimatingToEnd) {
265+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
266+ }
267 scheduleFallbackCheck(100);
268 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
269 finishScrollTo(ctx);
270@@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) {
271 }
272 if (shouldMaintainScrollAtEnd) {
273 state.pendingMaintainScrollAtEnd = false;
274+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
275+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
276+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
277+ return false;
278+ }
279 const contentSize = getContentSize(ctx);
280+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
281 if (contentSize < state.scrollLength) {
282- state.scroll = 0;
283+ state.scroll = -maintainInsetStartAdjustment;
284+ if (maintainInsetStartAdjustment > 0) {
285+ return true;
286+ }
287 }
288 if (!state.maintainingScrollAtEnd) {
289 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
290@@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) {
291 y: 0
292 });
293 } else {
294- scroller == null ? void 0 : scroller.scrollToEnd({
295- animated: maintainScrollAtEnd.animated
296- });
297+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
298+ if (insetStartAdjustment > 0) {
299+ scroller == null ? void 0 : scroller.scrollTo({
300+ animated: maintainScrollAtEnd.animated,
301+ x: 0,
302+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
303+ });
304+ } else {
305+ scroller == null ? void 0 : scroller.scrollToEnd({
306+ animated: maintainScrollAtEnd.animated
307+ });
308+ }
309 }
310 setTimeout(
311 () => {
312@@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) {
313 function requestAdjust(ctx, positionDiff, dataChanged) {
314 const state = ctx.state;
315 if (Math.abs(positionDiff) > 0.1) {
316+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
317+ // size adjustment are already compensated by a contentOffset shift, so
318+ // animating them would make rows visibly lurch and slide back.
319+ state.lastMVCPAdjustTime = Date.now();
320 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
321 const doit = () => {
322 if (needsScrollWorkaround) {
323@@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
324 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
325 return 0;
326 }
327- const maxScroll = Math.max(0, totalSize - state.scrollLength);
328+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
329+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
330+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
331 const clampDelta = maxScroll - state.scroll;
332 if (unresolvedAmount < 0) {
333 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
334@@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
335 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
336 return true;
337 }
338- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
339+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
340 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
341 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
342 if (isAtExpectedNativeClamp) {
343@@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) {
344 if (diff > 0) {
345 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
346 } else {
347- const maxScroll = Math.max(0, totalSize - state.scrollLength);
348+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
349 state.scroll = maxScroll;
350 state.scrollPending = maxScroll;
351 diff = 0;
352@@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, {
353 }
354
355 // src/core/initialScroll.ts
356+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
357+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
358+var INSET_END_REVEAL_STABLE_FRAMES = 3;
359+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
360+function startInsetEndSettleWatchdog(ctx) {
361+ const state = ctx.state;
362+ if (state.insetEndSettleWatchdogActive) {
363+ return;
364+ }
365+ state.insetEndSettleWatchdogActive = true;
366+ state.didUserDrag = false;
367+ // Hold the readyToRender opacity gate until the end landing is stable, so
368+ // the estimated-to-measured settle chase happens before first VISIBLE
369+ // paint instead of in front of the user. Capped so slow measurement can
370+ // never hide content for long.
371+ state.insetEndRevealHold = true;
372+ let frames = 0;
373+ let settledFrames = 0;
374+ let revealStableFrames = 0;
375+ const releaseRevealHold = () => {
376+ if (state.insetEndRevealHold) {
377+ state.insetEndRevealHold = false;
378+ setInitialRenderState(ctx, {});
379+ }
380+ };
381+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
382+ // can move the end target one more time — do that upgrade while still
383+ // hidden (phase 1), then require stability again before revealing (phase 2)
384+ // so the first visible frame is the settled one.
385+ let revealPhase = 1;
386+ const onRevealStability = () => {
387+ var _a3;
388+ if (!state.insetEndRevealHold) {
389+ return;
390+ }
391+ if (revealPhase === 1) {
392+ revealPhase = 2;
393+ revealStableFrames = 0;
394+ setAdaptiveRender(ctx, "normal");
395+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
396+ return;
397+ }
398+ releaseRevealHold();
399+ };
400+ const tick = () => {
401+ // The user owns the scroll from their first drag — a re-pin here would
402+ // pin the list under their finger.
403+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
404+ state.insetEndSettleWatchdogActive = false;
405+ releaseRevealHold();
406+ return;
407+ }
408+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
409+ releaseRevealHold();
410+ }
411+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
412+ const contentSize = getContentSize(ctx);
413+ const scrollLength = state.scrollLength;
414+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
415+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
416+ const distance = endOffset - state.scroll;
417+ // Estimated row sizes converging to measured ones can strand the initial
418+ // end landing when the library's own end-anchor bookkeeping gives up.
419+ // While still near the end (never fighting a user who scrolled away),
420+ // re-pin to the current true end until sizes stop changing.
421+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
422+ settledFrames = 0;
423+ revealStableFrames = 0;
424+ const scroller = state.refScroller.current;
425+ if (scroller) {
426+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
427+ }
428+ } else {
429+ settledFrames++;
430+ revealStableFrames++;
431+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
432+ onRevealStability();
433+ }
434+ }
435+ } else {
436+ // Conditions that make re-pinning unnecessary (underflow, in-flight
437+ // programmatic scroll) count toward stability for the reveal.
438+ revealStableFrames++;
439+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
440+ onRevealStability();
441+ }
442+ }
443+ requestAnimationFrame(tick);
444+ };
445+ requestAnimationFrame(tick);
446+}
447 function dispatchInitialScroll(ctx, params) {
448 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
449+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
450+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
451+ if (ctx.state.scrollLength <= 0) {
452+ // The scroll length is unknown, so the resolved target is meaningless;
453+ // the initial-scroll machinery re-advances after layout.
454+ return;
455+ }
456+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
457+ // Content underflows the viewport and nothing has scrolled yet: the
458+ // untouched native rest position (UIKit's adjustedContentInset) IS the
459+ // end position. Dispatching a negative scroll here would race UIKit's
460+ // inset application during screen attach, so just finish the session.
461+ finishInitialScroll(ctx, {
462+ resolvedOffset
463+ });
464+ return;
465+ }
466+ ctx.state.didDispatchInsetInitialScroll = true;
467+ if (target.viewPosition === 1) {
468+ startInsetEndSettleWatchdog(ctx);
469+ }
470+ }
471 const requestedIndex = target.index;
472 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
473 const itemSize = getItemSizeAtIndex(ctx, index);
474@@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
475 return;
476 }
477 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
478- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
479+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
480+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
481+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
482 if (!shouldKeepEndTargetAlive) {
483 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
484 clearPendingInitialScrollFooterLayout(ctx, {
485@@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
486 }
487 contentBelowAnchor += footerSize + stylePaddingBottom;
488 isReady = !hasUnknownTailSize;
489- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
490+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
491+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
492 } else if (anchorIndex >= 0) {
493 isReady = false;
494 }
495@@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
496 updateScroll(ctx, state.scroll, true);
497 }
498 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
499+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
500+ set$(ctx, "anchoredEndSpaceSize", nextSize);
501+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
502+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
503+ updateScroll(ctx, state.scroll, true);
504+ }
505 }
506 return nextSize;
507 }
508@@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
509 dataVersion,
510 drawDistance = 250,
511 contentInsetEndAdjustment,
512+ contentInsetStartAdjustment,
513 estimatedItemSize = 100,
514 estimatedListSize,
515 extraData,
516@@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
517 onLayout: onLayoutProp,
518 onLoad,
519 onMomentumScrollEnd,
520+ onScrollBeginDrag,
521 onRefresh,
522 onScroll: onScrollProp,
523 onScrollBeginDrag,
524@@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
525 const combinedRef = useCombinedRef(refScroller, refScrollView);
526 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
527 const stickyHeaderIndices = stickyHeaderIndicesProp;
528- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
529+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
530 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
531 const alwaysRenderIndices = React2.useMemo(() => {
532 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
533@@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
534 contentContainerAlignItems: contentContainerStyle.alignItems,
535 contentInset,
536 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
537+ contentInsetStartAdjustment,
538 data: dataProp,
539 dataKey,
540 dataVersion,
541@@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
542 return void 0;
543 }
544 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
545+ if (getContentInsetStartAdjustment(ctx) > 0) {
546+ // With a native leading inset (transparent header + automatic insets),
547+ // any mount-time contentOffset races UIKit's adjustedContentInset
548+ // application. The gated initial-scroll dispatch positions the list once
549+ // real sizes are known instead.
550+ return void 0;
551+ }
552 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
553 }, [usesBootstrapInitialScroll]);
554 React2.useLayoutEffect(() => {
555@@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
556 onScroll: (event) => onScroll(ctx, event),
557 onScrollBeginDrag: (event) => {
558 var _a4, _b2;
559+ ctx.state.didUserDrag = true;
560 prepareReachedEdgeForNextUserScroll(ctx);
561 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
562 },
563@@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
564 onLayout,
565 onLayoutFooter,
566 onMomentumScrollEnd: fns.onMomentumScrollEnd,
567+ onScrollBeginDrag: fns.onScrollBeginDrag,
568 onScroll: onScrollHandler,
569 recycleItems,
570 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
571diff --git a/react-native.mjs b/react-native.mjs
572index c2e0f38..5313086 100644
573--- a/react-native.mjs
574+++ b/react-native.mjs
575@@ -909,7 +909,7 @@ function setInitialRenderState(ctx, {
576 if (didInitialScroll) {
577 state.didFinishInitialScroll = true;
578 }
579- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
580+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
581 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
582 set$(ctx, "readyToRender", true);
583 setAdaptiveRender(ctx, "normal", "ready");
584@@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
585 }
586
587 // src/core/clampScrollOffset.ts
588+function getContentInsetStartAdjustment(ctx) {
589+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
590+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
591+}
592 function clampScrollOffset(ctx, offset, scrollTarget) {
593 const state = ctx.state;
594 const contentSize = getContentSize(ctx);
595+ const minOffset = -getContentInsetStartAdjustment(ctx);
596 let clampedOffset = offset;
597 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
598- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
599+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
600 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
601 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
602 const maxOffset = baseMaxOffset + extraEndOffset;
603 clampedOffset = Math.min(offset, maxOffset);
604 }
605- clampedOffset = Math.max(0, clampedOffset);
606+ clampedOffset = Math.max(minOffset, clampedOffset);
607 return clampedOffset;
608 }
609
610@@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) {
611 finishScrollTo(ctx);
612 }
613 }
614-function scrollToFallbackOffset(ctx, offset) {
615+function scrollToFallbackOffset(ctx, offset, animated) {
616 var _a3;
617 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
618- animated: false,
619+ animated: !!animated,
620 x: ctx.state.props.horizontal ? offset : 0,
621 y: ctx.state.props.horizontal ? 0 : offset
622 });
623@@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) {
624 });
625 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
626 } else if (shouldRetryUnalignedEndScroll) {
627- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
628+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
629+ if (!isActivelyAnimatingToEnd) {
630+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
631+ }
632 scheduleFallbackCheck(100);
633 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
634 finishScrollTo(ctx);
635@@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) {
636 }
637 if (shouldMaintainScrollAtEnd) {
638 state.pendingMaintainScrollAtEnd = false;
639+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
640+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
641+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
642+ return false;
643+ }
644 const contentSize = getContentSize(ctx);
645+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
646 if (contentSize < state.scrollLength) {
647- state.scroll = 0;
648+ state.scroll = -maintainInsetStartAdjustment;
649+ if (maintainInsetStartAdjustment > 0) {
650+ return true;
651+ }
652 }
653 if (!state.maintainingScrollAtEnd) {
654 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
655@@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) {
656 y: 0
657 });
658 } else {
659- scroller == null ? void 0 : scroller.scrollToEnd({
660- animated: maintainScrollAtEnd.animated
661- });
662+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
663+ if (insetStartAdjustment > 0) {
664+ scroller == null ? void 0 : scroller.scrollTo({
665+ animated: maintainScrollAtEnd.animated,
666+ x: 0,
667+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
668+ });
669+ } else {
670+ scroller == null ? void 0 : scroller.scrollToEnd({
671+ animated: maintainScrollAtEnd.animated
672+ });
673+ }
674 }
675 setTimeout(
676 () => {
677@@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) {
678 function requestAdjust(ctx, positionDiff, dataChanged) {
679 const state = ctx.state;
680 if (Math.abs(positionDiff) > 0.1) {
681+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
682+ // size adjustment are already compensated by a contentOffset shift, so
683+ // animating them would make rows visibly lurch and slide back.
684+ state.lastMVCPAdjustTime = Date.now();
685 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
686 const doit = () => {
687 if (needsScrollWorkaround) {
688@@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
689 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
690 return 0;
691 }
692- const maxScroll = Math.max(0, totalSize - state.scrollLength);
693+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
694+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
695+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
696 const clampDelta = maxScroll - state.scroll;
697 if (unresolvedAmount < 0) {
698 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
699@@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
700 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
701 return true;
702 }
703- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
704+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
705 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
706 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
707 if (isAtExpectedNativeClamp) {
708@@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) {
709 if (diff > 0) {
710 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
711 } else {
712- const maxScroll = Math.max(0, totalSize - state.scrollLength);
713+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
714 state.scroll = maxScroll;
715 state.scrollPending = maxScroll;
716 diff = 0;
717@@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, {
718 }
719
720 // src/core/initialScroll.ts
721+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
722+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
723+var INSET_END_REVEAL_STABLE_FRAMES = 3;
724+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
725+function startInsetEndSettleWatchdog(ctx) {
726+ const state = ctx.state;
727+ if (state.insetEndSettleWatchdogActive) {
728+ return;
729+ }
730+ state.insetEndSettleWatchdogActive = true;
731+ state.didUserDrag = false;
732+ // Hold the readyToRender opacity gate until the end landing is stable, so
733+ // the estimated-to-measured settle chase happens before first VISIBLE
734+ // paint instead of in front of the user. Capped so slow measurement can
735+ // never hide content for long.
736+ state.insetEndRevealHold = true;
737+ let frames = 0;
738+ let settledFrames = 0;
739+ let revealStableFrames = 0;
740+ const releaseRevealHold = () => {
741+ if (state.insetEndRevealHold) {
742+ state.insetEndRevealHold = false;
743+ setInitialRenderState(ctx, {});
744+ }
745+ };
746+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
747+ // can move the end target one more time — do that upgrade while still
748+ // hidden (phase 1), then require stability again before revealing (phase 2)
749+ // so the first visible frame is the settled one.
750+ let revealPhase = 1;
751+ const onRevealStability = () => {
752+ var _a3;
753+ if (!state.insetEndRevealHold) {
754+ return;
755+ }
756+ if (revealPhase === 1) {
757+ revealPhase = 2;
758+ revealStableFrames = 0;
759+ setAdaptiveRender(ctx, "normal");
760+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
761+ return;
762+ }
763+ releaseRevealHold();
764+ };
765+ const tick = () => {
766+ // The user owns the scroll from their first drag — a re-pin here would
767+ // pin the list under their finger.
768+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
769+ state.insetEndSettleWatchdogActive = false;
770+ releaseRevealHold();
771+ return;
772+ }
773+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
774+ releaseRevealHold();
775+ }
776+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
777+ const contentSize = getContentSize(ctx);
778+ const scrollLength = state.scrollLength;
779+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
780+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
781+ const distance = endOffset - state.scroll;
782+ // Estimated row sizes converging to measured ones can strand the initial
783+ // end landing when the library's own end-anchor bookkeeping gives up.
784+ // While still near the end (never fighting a user who scrolled away),
785+ // re-pin to the current true end until sizes stop changing.
786+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
787+ settledFrames = 0;
788+ revealStableFrames = 0;
789+ const scroller = state.refScroller.current;
790+ if (scroller) {
791+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
792+ }
793+ } else {
794+ settledFrames++;
795+ revealStableFrames++;
796+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
797+ onRevealStability();
798+ }
799+ }
800+ } else {
801+ // Conditions that make re-pinning unnecessary (underflow, in-flight
802+ // programmatic scroll) count toward stability for the reveal.
803+ revealStableFrames++;
804+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
805+ onRevealStability();
806+ }
807+ }
808+ requestAnimationFrame(tick);
809+ };
810+ requestAnimationFrame(tick);
811+}
812 function dispatchInitialScroll(ctx, params) {
813 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
814+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
815+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
816+ if (ctx.state.scrollLength <= 0) {
817+ // The scroll length is unknown, so the resolved target is meaningless;
818+ // the initial-scroll machinery re-advances after layout.
819+ return;
820+ }
821+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
822+ // Content underflows the viewport and nothing has scrolled yet: the
823+ // untouched native rest position (UIKit's adjustedContentInset) IS the
824+ // end position. Dispatching a negative scroll here would race UIKit's
825+ // inset application during screen attach, so just finish the session.
826+ finishInitialScroll(ctx, {
827+ resolvedOffset
828+ });
829+ return;
830+ }
831+ ctx.state.didDispatchInsetInitialScroll = true;
832+ if (target.viewPosition === 1) {
833+ startInsetEndSettleWatchdog(ctx);
834+ }
835+ }
836 const requestedIndex = target.index;
837 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
838 const itemSize = getItemSizeAtIndex(ctx, index);
839@@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
840 return;
841 }
842 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
843- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
844+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
845+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
846+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
847 if (!shouldKeepEndTargetAlive) {
848 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
849 clearPendingInitialScrollFooterLayout(ctx, {
850@@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
851 }
852 contentBelowAnchor += footerSize + stylePaddingBottom;
853 isReady = !hasUnknownTailSize;
854- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
855+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
856+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
857 } else if (anchorIndex >= 0) {
858 isReady = false;
859 }
860@@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
861 updateScroll(ctx, state.scroll, true);
862 }
863 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
864+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
865+ set$(ctx, "anchoredEndSpaceSize", nextSize);
866+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
867+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
868+ updateScroll(ctx, state.scroll, true);
869+ }
870 }
871 return nextSize;
872 }
873@@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
874 dataVersion,
875 drawDistance = 250,
876 contentInsetEndAdjustment,
877+ contentInsetStartAdjustment,
878 estimatedItemSize = 100,
879 estimatedListSize,
880 extraData,
881@@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
882 const combinedRef = useCombinedRef(refScroller, refScrollView);
883 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
884 const stickyHeaderIndices = stickyHeaderIndicesProp;
885- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
886+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
887 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
888 const alwaysRenderIndices = useMemo(() => {
889 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
890@@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
891 contentContainerAlignItems: contentContainerStyle.alignItems,
892 contentInset,
893 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
894+ contentInsetStartAdjustment,
895 data: dataProp,
896 dataKey,
897 dataVersion,
898@@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
899 return void 0;
900 }
901 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
902+ if (getContentInsetStartAdjustment(ctx) > 0) {
903+ // With a native leading inset (transparent header + automatic insets),
904+ // any mount-time contentOffset races UIKit's adjustedContentInset
905+ // application. The gated initial-scroll dispatch positions the list once
906+ // real sizes are known instead.
907+ return void 0;
908+ }
909 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
910 }, [usesBootstrapInitialScroll]);
911 useLayoutEffect(() => {
912@@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
913 onScroll: (event) => onScroll(ctx, event),
914 onScrollBeginDrag: (event) => {
915 var _a4, _b2;
916+ ctx.state.didUserDrag = true;
917 prepareReachedEdgeForNextUserScroll(ctx);
918 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
919 },
920@@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
921 onLayout,
922 onLayoutFooter,
923 onMomentumScrollEnd: fns.onMomentumScrollEnd,
924+ onScrollBeginDrag: fns.onScrollBeginDrag,
925 onScroll: onScrollHandler,
926 recycleItems,
927 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
928diff --git a/reanimated.d.ts b/reanimated.d.ts
929index 940da28..28dccbe 100644
930--- a/reanimated.d.ts
931+++ b/reanimated.d.ts
932@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
933 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
934 */
935 contentInsetEndAdjustment?: number;
936+ /**
937+ * Width/height of a leading content inset applied natively outside the list's knowledge
938+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
939+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
940+ */
941+ contentInsetStartAdjustment?: number;
942 /**
943 * Number of columns to render items in.
944 * @default 1
945diff --git a/reanimated.js b/reanimated.js
946index f1265fa..16dcef0 100644
947--- a/reanimated.js
948+++ b/reanimated.js
949@@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
950 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
951 const prevItemKeyRef = React__namespace.useRef(void 0);
952 let shouldSkipTransitionForRecycleReuse = false;
953- if (recycleItems && layoutTransition) {
954+ if (layoutTransition) {
955 const itemKeySignal = `containerItemKey${id}`;
956 const itemKey = peek$(ctx, itemKeySignal);
957 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
958@@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
959 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
960 [horizontal, positionValue, style]
961 );
962+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
963+ // which are compensated by an equal contentOffset shift so the row should
964+ // not visibly move — animating turns the invisible correction into a
965+ // lurch-and-settle; (2) any reposition while the user is actively
966+ // scrolling, where rows shifting under the finger reads as jank. The
967+ // transition exists to smooth at-rest shifts (streaming text, work-log
968+ // folds), so gate it to at-rest moments.
969+ const now = Date.now();
970+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||
971+ now - (ctx.state.lastNativeScrollTime || 0) < 300;
972 return /* @__PURE__ */ React__namespace.createElement(
973 Reanimated__default.default.View,
974 {
975- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
976+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
977 ref: refView,
978 style: viewStyle,
979 ...rest
980diff --git a/reanimated.mjs b/reanimated.mjs
981index 29a00d5..9c25ec5 100644
982--- a/reanimated.mjs
983+++ b/reanimated.mjs
984@@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
985 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
986 const prevItemKeyRef = React.useRef(void 0);
987 let shouldSkipTransitionForRecycleReuse = false;
988- if (recycleItems && layoutTransition) {
989+ if (layoutTransition) {
990 const itemKeySignal = `containerItemKey${id}`;
991 const itemKey = peek$(ctx, itemKeySignal);
992 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
993@@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
994 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
995 [horizontal, positionValue, style]
996 );
997+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
998+ // which are compensated by an equal contentOffset shift so the row should
999+ // not visibly move — animating turns the invisible correction into a
1000+ // lurch-and-settle; (2) any reposition while the user is actively
1001+ // scrolling, where rows shifting under the finger reads as jank. The
1002+ // transition exists to smooth at-rest shifts (streaming text, work-log
1003+ // folds), so gate it to at-rest moments.
1004+ const now = Date.now();
1005+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||
1006+ now - (ctx.state.lastNativeScrollTime || 0) < 300;
1007 return /* @__PURE__ */ React.createElement(
1008 Reanimated.View,
1009 {
1010- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1011+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
1012 ref: refView,
1013 style: viewStyle,
1014 ...rest
pnpm-lock.yaml+24-12
...@@ -74,7 +74,7 @@ patchedDependencies:...@@ -74,7 +74,7 @@ patchedDependencies:
74 '@effect/vitest@4.0.0-beta.102': a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c1342574 '@effect/vitest@4.0.0-beta.102': a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425
75 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff4675 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
76 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c876 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
77 '@legendapp/list@3.2.0': 9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d77 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09
78 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa78 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
79 '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae79 '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae
80 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c71727380 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273
...@@ -212,8 +212,8 @@ importers:...@@ -212,8 +212,8 @@ importers:
212 specifier: ~56.0.18212 specifier: ~56.0.18
213 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)213 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
214 '@legendapp/list':214 '@legendapp/list':
215 specifier: 3.2.0215 specifier: 3.3.3
216 version: 3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)216 version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
217 '@noble/curves':217 '@noble/curves':
218 specifier: 'catalog:'218 specifier: 'catalog:'
219 version: 1.9.1219 version: 1.9.1
...@@ -545,7 +545,7 @@ importers:...@@ -545,7 +545,7 @@ importers:
545 version: 0.9.0545 version: 0.9.0
546 '@legendapp/list':546 '@legendapp/list':
547 specifier: 3.2.0547 specifier: 3.2.0
548 version: 3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)548 version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
549 '@lexical/react':549 '@lexical/react':
550 specifier: ^0.41.0550 specifier: ^0.41.0
551 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)551 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
...@@ -2975,6 +2975,18 @@ packages:...@@ -2975,6 +2975,18 @@ packages:
2975 react-native:2975 react-native:
2976 optional: true2976 optional: true
29772977
2978 '@legendapp/list@3.3.3':
2979 resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==}
2980 peerDependencies:
2981 react: '*'
2982 react-dom: '*'
2983 react-native: '*'
2984 peerDependenciesMeta:
2985 react-dom:
2986 optional: true
2987 react-native:
2988 optional: true
2989
2978 '@lexical/clipboard@0.41.0':2990 '@lexical/clipboard@0.41.0':
2979 resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==}2991 resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==}
29802992
...@@ -12982,7 +12994,14 @@ snapshots:...@@ -12982,7 +12994,14 @@ snapshots:
12982 dependencies:12994 dependencies:
12983 jsbi: 4.3.212995 jsbi: 4.3.2
1298412996
12985 '@legendapp/list@3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':12997 '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12998 dependencies:
12999 react: 19.2.6
13000 use-sync-external-store: 1.6.0(react@19.2.6)
13001 optionalDependencies:
13002 react-dom: 19.2.6(react@19.2.6)
13003
13004 '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
12986 dependencies:13005 dependencies:
12987 react: 19.2.313006 react: 19.2.3
12988 use-sync-external-store: 1.6.0(react@19.2.3)13007 use-sync-external-store: 1.6.0(react@19.2.3)
...@@ -12990,13 +13009,6 @@ snapshots:...@@ -12990,13 +13009,6 @@ snapshots:
12990 react-dom: 19.2.3(react@19.2.3)13009 react-dom: 19.2.3(react@19.2.3)
12991 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)13010 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1299213011
12993 '@legendapp/list@3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12994 dependencies:
12995 react: 19.2.6
12996 use-sync-external-store: 1.6.0(react@19.2.6)
12997 optionalDependencies:
12998 react-dom: 19.2.6(react@19.2.6)
12999
13000 '@lexical/clipboard@0.41.0':13012 '@lexical/clipboard@0.41.0':
13001 dependencies:13013 dependencies:
13002 '@lexical/html': 0.41.013014 '@lexical/html': 0.41.0
pnpm-workspace.yaml+1-1
...@@ -127,7 +127,7 @@ patchedDependencies:...@@ -127,7 +127,7 @@ patchedDependencies:
127 "@effect/vitest@4.0.0-beta.102": patches/@effect__vitest@4.0.0-beta.102.patch127 "@effect/vitest@4.0.0-beta.102": patches/@effect__vitest@4.0.0-beta.102.patch
128 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch128 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch
129 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch129 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch
130 "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch130 "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch
131 "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch131 "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch
132 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch132 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
133 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch133 "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch