diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c99351547be2f861f9b851999e6beb85d3b2fa68..9a5e64aa46f7b4459fb34bf1ea4332d566e8f1b1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,7 @@ "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/metro-runtime": "~56.0.15", "@expo/ui": "~56.0.18", - "@legendapp/list": "3.2.0", + "@legendapp/list": "3.3.3", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch deleted file mode 100644 index 8059fbb5f4e54ee3ddf85a9b4bf6eecbb40770f5..0000000000000000000000000000000000000000 --- a/patches/@legendapp__list@3.2.0.patch +++ /dev/null @@ -1,940 +0,0 @@ -diff --git a/keyboard.d.ts b/keyboard.d.ts -index 5a115ea..2c65d31 100644 ---- a/keyboard.d.ts -+++ b/keyboard.d.ts -@@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { - type KeyboardChatComposerRef = { - current: Pick | null; - }; --declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { -+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { - contentInsetEndAdjustment: SharedValue; - onComposerLayout: (event: LayoutChangeEvent) => void; - }; -@@ -278,8 +278,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb - scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise; - }; - declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { -+ adjustedInsetCompensation?: number; - anchoredEndSpace?: AnchoredEndSpaceConfig; - contentInsetEndAdjustment?: SharedValue; -+ contentInsetEndStaticAdjustment?: number; - keyboardOffset?: number; - } & React.RefAttributes) => React.ReactElement | null; - -diff --git a/keyboard.js b/keyboard.js -index 736286a..8218172 100644 ---- a/keyboard.js -+++ b/keyboard.js -@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. - "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." - ); - } --function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { -+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { - const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); - const lastHeightRef = React.useRef(void 0); - const reportHeight = React.useCallback( -- (height) => { -+ (rawHeight) => { -+ const height = Math.max(0, rawHeight + heightAdjustment); - var _a; - if (Number.isFinite(height) && height !== lastHeightRef.current) { - lastHeightRef.current = height; - contentInsetEndAdjustment.value = height; -- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); - } - }, -- [contentInsetEndAdjustment, listRef] -+ [contentInsetEndAdjustment, heightAdjustment, listRef] - ); - React.useLayoutEffect(() => { - var _a; -@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { - } - var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { - const { -+ adjustedInsetCompensation, - anchoredEndSpace, - applyWorkaroundForContentInsetHitTestBug, - contentInsetEndAdjustment, -+ contentInsetEndStaticAdjustment, - freeze, - keyboardLiftBehavior, - keyboardOffset, -@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, - onSizeChanged: (size) => { - var _a; -- blankSpace.value = size; -+ // The anchored blank is sized against the ADJUSTED viewport, but the -+ // scroll component writes it as a raw contentInset that UIKit tops up -+ // with the safe-area bottom — write it net of that or the end rest -+ // (and the anchored message) sinks one safe-area under the header. -+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; - (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); - } - }; -- }, [anchoredEndSpace, blankSpace]); -+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); - const onContentInsetChange = React.useCallback((insets) => { - var _a; - (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - reactNativeKeyboardController.KeyboardChatScrollView, - { - ...scrollProps, -+ adjustedInsetCompensation, - applyWorkaroundForContentInsetHitTestBug, - blankSpace, - extraContentPadding: contentInsetEndAdjustment, -@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - ); - }, - [ -+ adjustedInsetCompensation, - applyWorkaroundForContentInsetHitTestBug, - blankSpace, - contentInsetEndAdjustment, -@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, - { - anchoredEndSpace: anchoredEndSpaceWithBlankSpace, -+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, - ref: combinedRef, - renderScrollComponent: memoList, - ...rest -diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..cb0d142 100644 ---- a/keyboard.mjs -+++ b/keyboard.mjs -@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { - "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." - ); - } --function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { -+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { - const contentInsetEndAdjustment = useSharedValue(initialHeight); - const lastHeightRef = useRef(void 0); - const reportHeight = useCallback( -- (height) => { -+ (rawHeight) => { -+ const height = Math.max(0, rawHeight + heightAdjustment); - var _a; - if (Number.isFinite(height) && height !== lastHeightRef.current) { - lastHeightRef.current = height; - contentInsetEndAdjustment.value = height; -- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); - } - }, -- [contentInsetEndAdjustment, listRef] -+ [contentInsetEndAdjustment, heightAdjustment, listRef] - ); - useLayoutEffect(() => { - var _a; -@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { - } - var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { - const { -+ adjustedInsetCompensation, - anchoredEndSpace, - applyWorkaroundForContentInsetHitTestBug, - contentInsetEndAdjustment, -+ contentInsetEndStaticAdjustment, - freeze, - keyboardLiftBehavior, - keyboardOffset, -@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, - onSizeChanged: (size) => { - var _a; -- blankSpace.value = size; -+ // The anchored blank is sized against the ADJUSTED viewport, but the -+ // scroll component writes it as a raw contentInset that UIKit tops up -+ // with the safe-area bottom — write it net of that or the end rest -+ // (and the anchored message) sinks one safe-area under the header. -+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; - (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); - } - }; -- }, [anchoredEndSpace, blankSpace]); -+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); - const onContentInsetChange = useCallback((insets) => { - var _a; - (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - KeyboardChatScrollView, - { - ...scrollProps, -+ adjustedInsetCompensation, - applyWorkaroundForContentInsetHitTestBug, - blankSpace, - extraContentPadding: contentInsetEndAdjustment, -@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - ); - }, - [ -+ adjustedInsetCompensation, - applyWorkaroundForContentInsetHitTestBug, - blankSpace, - contentInsetEndAdjustment, -@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, - { - anchoredEndSpace: anchoredEndSpaceWithBlankSpace, -+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, - ref: combinedRef, - renderScrollComponent: memoList, - ...rest -diff --git a/react-native.d.ts b/react-native.d.ts -index 72d3f59..435a5fc 100644 ---- a/react-native.d.ts -+++ b/react-native.d.ts -@@ -284,6 +284,12 @@ interface LegendListSpecificProps { - * The adjustment is also rendered as real content padding so the browser scroll range includes it. - */ - contentInsetEndAdjustment?: number; -+ /** -+ * Width/height of a leading content inset applied natively outside the list's knowledge -+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). -+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. -+ */ -+ contentInsetStartAdjustment?: number; - /** - * Number of columns to render items in. - * @default 1 -diff --git a/react-native.js b/react-native.js -index 8d4ff89..003b2f7 100644 ---- a/react-native.js -+++ b/react-native.js -@@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal"); -@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { - } - - // src/core/clampScrollOffset.ts -+function getContentInsetStartAdjustment(ctx) { -+ const adjustment = ctx.state.props.contentInsetStartAdjustment; -+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; -+} - function clampScrollOffset(ctx, offset, scrollTarget) { - const state = ctx.state; - const contentSize = getContentSize(ctx); -+ const minOffset = -getContentInsetStartAdjustment(ctx); - let clampedOffset = offset; - if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { -- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); -+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); - const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; - const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; - const maxOffset = baseMaxOffset + extraEndOffset; - clampedOffset = Math.min(offset, maxOffset); - } -- clampedOffset = Math.max(0, clampedOffset); -+ clampedOffset = Math.max(minOffset, clampedOffset); - return clampedOffset; - } - -@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { - finishScrollTo(ctx); - } - } --function scrollToFallbackOffset(ctx, offset) { -+function scrollToFallbackOffset(ctx, offset, animated) { - var _a3; - (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ -- animated: false, -+ animated: !!animated, - x: ctx.state.props.horizontal ? offset : 0, - y: ctx.state.props.horizontal ? 0 : offset - }); -@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { - } - state.pendingMaintainScrollAtEnd = false; - if (shouldMaintainScrollAtEnd) { -+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; -+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; -+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { -+ return false; -+ } - const contentSize = getContentSize(ctx); -+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); - if (contentSize < state.scrollLength) { -- state.scroll = 0; -+ state.scroll = -maintainInsetStartAdjustment; -+ if (maintainInsetStartAdjustment > 0) { -+ return true; -+ } - } - if (!state.maintainingScrollAtEnd) { - const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { - y: 0 - }); - } else { -- scroller == null ? void 0 : scroller.scrollToEnd({ -- animated: maintainScrollAtEnd.animated -- }); -+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); -+ if (insetStartAdjustment > 0) { -+ scroller == null ? void 0 : scroller.scrollTo({ -+ animated: maintainScrollAtEnd.animated, -+ x: 0, -+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) -+ }); -+ } else { -+ scroller == null ? void 0 : scroller.scrollToEnd({ -+ animated: maintainScrollAtEnd.animated -+ }); -+ } - } - setTimeout( - () => { -@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { - if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { - return 0; - } -- const maxScroll = Math.max(0, totalSize - state.scrollLength); -+ const insetStartAdjustment = state.props.contentInsetStartAdjustment; -+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; -+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); - const clampDelta = maxScroll - state.scroll; - if (unresolvedAmount < 0) { - return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { - settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); - return true; - } -- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); -+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); - const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); - const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; - if (isAtExpectedNativeClamp) { -@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { - if (diff > 0) { - diff = Math.max(0, totalSize - state.scroll - state.scrollLength); - } else { -- const maxScroll = Math.max(0, totalSize - state.scrollLength); -+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); - state.scroll = maxScroll; - state.scrollPending = maxScroll; - diff = 0; -@@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, { - } - - // src/core/initialScroll.ts -+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; -+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; -+var INSET_END_REVEAL_STABLE_FRAMES = 3; -+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; -+function startInsetEndSettleWatchdog(ctx) { -+ const state = ctx.state; -+ if (state.insetEndSettleWatchdogActive) { -+ return; -+ } -+ state.insetEndSettleWatchdogActive = true; -+ state.didUserDrag = false; -+ // Hold the readyToRender opacity gate until the end landing is stable, so -+ // the estimated-to-measured settle chase happens before first VISIBLE -+ // paint instead of in front of the user. Capped so slow measurement can -+ // never hide content for long. -+ state.insetEndRevealHold = true; -+ let frames = 0; -+ let settledFrames = 0; -+ let revealStableFrames = 0; -+ const releaseRevealHold = () => { -+ if (state.insetEndRevealHold) { -+ state.insetEndRevealHold = false; -+ setInitialRenderState(ctx, {}); -+ } -+ }; -+ // Revealing flips adaptive rendering to "normal", which re-renders rows and -+ // can move the end target one more time — do that upgrade while still -+ // hidden (phase 1), then require stability again before revealing (phase 2) -+ // so the first visible frame is the settled one. -+ let revealPhase = 1; -+ const onRevealStability = () => { -+ var _a3; -+ if (!state.insetEndRevealHold) { -+ return; -+ } -+ if (revealPhase === 1) { -+ revealPhase = 2; -+ revealStableFrames = 0; -+ setAdaptiveRender(ctx, "normal"); -+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); -+ return; -+ } -+ releaseRevealHold(); -+ }; -+ const tick = () => { -+ // The user owns the scroll from their first drag — a re-pin here would -+ // pin the list under their finger. -+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { -+ state.insetEndSettleWatchdogActive = false; -+ releaseRevealHold(); -+ return; -+ } -+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { -+ releaseRevealHold(); -+ } -+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); -+ const contentSize = getContentSize(ctx); -+ const scrollLength = state.scrollLength; -+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { -+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); -+ const distance = endOffset - state.scroll; -+ // Estimated row sizes converging to measured ones can strand the initial -+ // end landing when the library's own end-anchor bookkeeping gives up. -+ // While still near the end (never fighting a user who scrolled away), -+ // re-pin to the current true end until sizes stop changing. -+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { -+ settledFrames = 0; -+ revealStableFrames = 0; -+ const scroller = state.refScroller.current; -+ if (scroller) { -+ scroller.scrollTo({ animated: false, x: 0, y: endOffset }); -+ } -+ } else { -+ settledFrames++; -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ } else { -+ // Conditions that make re-pinning unnecessary (underflow, in-flight -+ // programmatic scroll) count toward stability for the reveal. -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ requestAnimationFrame(tick); -+ }; -+ requestAnimationFrame(tick); -+} - function dispatchInitialScroll(ctx, params) { - const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; -+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); -+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { -+ if (ctx.state.scrollLength <= 0) { -+ // The scroll length is unknown, so the resolved target is meaningless; -+ // the initial-scroll machinery re-advances after layout. -+ return; -+ } -+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { -+ // Content underflows the viewport and nothing has scrolled yet: the -+ // untouched native rest position (UIKit's adjustedContentInset) IS the -+ // end position. Dispatching a negative scroll here would race UIKit's -+ // inset application during screen attach, so just finish the session. -+ finishInitialScroll(ctx, { -+ resolvedOffset -+ }); -+ return; -+ } -+ ctx.state.didDispatchInsetInitialScroll = true; -+ if (target.viewPosition === 1) { -+ startInsetEndSettleWatchdog(ctx); -+ } -+ } - const requestedIndex = target.index; - const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; - const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2804,7 +2945,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { - return; - } - if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { -- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); -+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); -+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; -+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); - if (!shouldKeepEndTargetAlive) { - if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { - clearPendingInitialScrollFooterLayout(ctx, { -@@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; - isReady = !hasUnknownTailSize; -- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); -+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); -+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; - } else if (anchorIndex >= 0) { - isReady = false; - } -@@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); - } - (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); -+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { -+ set$(ctx, "anchoredEndSpaceSize", nextSize); -+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); -+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); -+ } - } - return nextSize; - } -@@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - dataVersion, - drawDistance = 250, - contentInsetEndAdjustment, -+ contentInsetStartAdjustment, - estimatedItemSize = 100, - estimatedListSize, - extraData, -@@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - onLayout: onLayoutProp, - onLoad, - onMomentumScrollEnd, -+ onScrollBeginDrag, - onRefresh, - onScroll: onScrollProp, - onStartReached, -@@ -6577,7 +6729,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - const combinedRef = useCombinedRef(refScroller, refScrollView); - const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); - const stickyHeaderIndices = stickyHeaderIndicesProp; -- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; -+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; - const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); - const alwaysRenderIndices = React2.useMemo(() => { - const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - contentContainerAlignItems: contentContainerStyle.alignItems, - contentInset, - contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, -+ contentInsetStartAdjustment, - data: dataProp, - dataVersion, - drawDistance, -@@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - return void 0; - } - const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); -+ if (getContentInsetStartAdjustment(ctx) > 0) { -+ // With a native leading inset (transparent header + automatic insets), -+ // any mount-time contentOffset races UIKit's adjustedContentInset -+ // application. The gated initial-scroll dispatch positions the list once -+ // real sizes are known instead. -+ return void 0; -+ } - return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; - }, [usesBootstrapInitialScroll]); - React2.useLayoutEffect(() => { -@@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - onMomentumScrollEnd(event); - } - }, -+ onScrollBeginDrag: (event) => { -+ ctx.state.didUserDrag = true; -+ if (onScrollBeginDrag) { -+ onScrollBeginDrag(event); -+ } -+ }, - onScroll: (event) => onScroll(ctx, event) - }), - [] -@@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - onLayout, - onLayoutFooter, - onMomentumScrollEnd: fns.onMomentumScrollEnd, -+ onScrollBeginDrag: fns.onScrollBeginDrag, - onScroll: onScrollHandler, - recycleItems, - refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { -diff --git a/react-native.mjs b/react-native.mjs -index 2e96ca7..fc88d4b 100644 ---- a/react-native.mjs -+++ b/react-native.mjs -@@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal"); -@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { - } - - // src/core/clampScrollOffset.ts -+function getContentInsetStartAdjustment(ctx) { -+ const adjustment = ctx.state.props.contentInsetStartAdjustment; -+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; -+} - function clampScrollOffset(ctx, offset, scrollTarget) { - const state = ctx.state; - const contentSize = getContentSize(ctx); -+ const minOffset = -getContentInsetStartAdjustment(ctx); - let clampedOffset = offset; - if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { -- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); -+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); - const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; - const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; - const maxOffset = baseMaxOffset + extraEndOffset; - clampedOffset = Math.min(offset, maxOffset); - } -- clampedOffset = Math.max(0, clampedOffset); -+ clampedOffset = Math.max(minOffset, clampedOffset); - return clampedOffset; - } - -@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { - finishScrollTo(ctx); - } - } --function scrollToFallbackOffset(ctx, offset) { -+function scrollToFallbackOffset(ctx, offset, animated) { - var _a3; - (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ -- animated: false, -+ animated: !!animated, - x: ctx.state.props.horizontal ? offset : 0, - y: ctx.state.props.horizontal ? 0 : offset - }); -@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { - } - state.pendingMaintainScrollAtEnd = false; - if (shouldMaintainScrollAtEnd) { -+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; -+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; -+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { -+ return false; -+ } - const contentSize = getContentSize(ctx); -+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); - if (contentSize < state.scrollLength) { -- state.scroll = 0; -+ state.scroll = -maintainInsetStartAdjustment; -+ if (maintainInsetStartAdjustment > 0) { -+ return true; -+ } - } - if (!state.maintainingScrollAtEnd) { - const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { - y: 0 - }); - } else { -- scroller == null ? void 0 : scroller.scrollToEnd({ -- animated: maintainScrollAtEnd.animated -- }); -+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); -+ if (insetStartAdjustment > 0) { -+ scroller == null ? void 0 : scroller.scrollTo({ -+ animated: maintainScrollAtEnd.animated, -+ x: 0, -+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) -+ }); -+ } else { -+ scroller == null ? void 0 : scroller.scrollToEnd({ -+ animated: maintainScrollAtEnd.animated -+ }); -+ } - } - setTimeout( - () => { -@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { - if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { - return 0; - } -- const maxScroll = Math.max(0, totalSize - state.scrollLength); -+ const insetStartAdjustment = state.props.contentInsetStartAdjustment; -+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; -+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); - const clampDelta = maxScroll - state.scroll; - if (unresolvedAmount < 0) { - return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { - settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); - return true; - } -- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); -+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); - const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); - const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; - if (isAtExpectedNativeClamp) { -@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { - if (diff > 0) { - diff = Math.max(0, totalSize - state.scroll - state.scrollLength); - } else { -- const maxScroll = Math.max(0, totalSize - state.scrollLength); -+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); - state.scroll = maxScroll; - state.scrollPending = maxScroll; - diff = 0; -@@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, { - } - - // src/core/initialScroll.ts -+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; -+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; -+var INSET_END_REVEAL_STABLE_FRAMES = 3; -+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; -+function startInsetEndSettleWatchdog(ctx) { -+ const state = ctx.state; -+ if (state.insetEndSettleWatchdogActive) { -+ return; -+ } -+ state.insetEndSettleWatchdogActive = true; -+ state.didUserDrag = false; -+ // Hold the readyToRender opacity gate until the end landing is stable, so -+ // the estimated-to-measured settle chase happens before first VISIBLE -+ // paint instead of in front of the user. Capped so slow measurement can -+ // never hide content for long. -+ state.insetEndRevealHold = true; -+ let frames = 0; -+ let settledFrames = 0; -+ let revealStableFrames = 0; -+ const releaseRevealHold = () => { -+ if (state.insetEndRevealHold) { -+ state.insetEndRevealHold = false; -+ setInitialRenderState(ctx, {}); -+ } -+ }; -+ // Revealing flips adaptive rendering to "normal", which re-renders rows and -+ // can move the end target one more time — do that upgrade while still -+ // hidden (phase 1), then require stability again before revealing (phase 2) -+ // so the first visible frame is the settled one. -+ let revealPhase = 1; -+ const onRevealStability = () => { -+ var _a3; -+ if (!state.insetEndRevealHold) { -+ return; -+ } -+ if (revealPhase === 1) { -+ revealPhase = 2; -+ revealStableFrames = 0; -+ setAdaptiveRender(ctx, "normal"); -+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); -+ return; -+ } -+ releaseRevealHold(); -+ }; -+ const tick = () => { -+ // The user owns the scroll from their first drag — a re-pin here would -+ // pin the list under their finger. -+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { -+ state.insetEndSettleWatchdogActive = false; -+ releaseRevealHold(); -+ return; -+ } -+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { -+ releaseRevealHold(); -+ } -+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); -+ const contentSize = getContentSize(ctx); -+ const scrollLength = state.scrollLength; -+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { -+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); -+ const distance = endOffset - state.scroll; -+ // Estimated row sizes converging to measured ones can strand the initial -+ // end landing when the library's own end-anchor bookkeeping gives up. -+ // While still near the end (never fighting a user who scrolled away), -+ // re-pin to the current true end until sizes stop changing. -+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { -+ settledFrames = 0; -+ revealStableFrames = 0; -+ const scroller = state.refScroller.current; -+ if (scroller) { -+ scroller.scrollTo({ animated: false, x: 0, y: endOffset }); -+ } -+ } else { -+ settledFrames++; -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ } else { -+ // Conditions that make re-pinning unnecessary (underflow, in-flight -+ // programmatic scroll) count toward stability for the reveal. -+ revealStableFrames++; -+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { -+ onRevealStability(); -+ } -+ } -+ requestAnimationFrame(tick); -+ }; -+ requestAnimationFrame(tick); -+} - function dispatchInitialScroll(ctx, params) { - const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; -+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); -+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { -+ if (ctx.state.scrollLength <= 0) { -+ // The scroll length is unknown, so the resolved target is meaningless; -+ // the initial-scroll machinery re-advances after layout. -+ return; -+ } -+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { -+ // Content underflows the viewport and nothing has scrolled yet: the -+ // untouched native rest position (UIKit's adjustedContentInset) IS the -+ // end position. Dispatching a negative scroll here would race UIKit's -+ // inset application during screen attach, so just finish the session. -+ finishInitialScroll(ctx, { -+ resolvedOffset -+ }); -+ return; -+ } -+ ctx.state.didDispatchInsetInitialScroll = true; -+ if (target.viewPosition === 1) { -+ startInsetEndSettleWatchdog(ctx); -+ } -+ } - const requestedIndex = target.index; - const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; - const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2783,7 +2924,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { - return; - } - if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { -- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); -+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); -+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; -+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); - if (!shouldKeepEndTargetAlive) { - if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { - clearPendingInitialScrollFooterLayout(ctx, { -@@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; - isReady = !hasUnknownTailSize; -- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); -+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); -+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; - } else if (anchorIndex >= 0) { - isReady = false; - } -@@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); - } - (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); -+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { -+ set$(ctx, "anchoredEndSpaceSize", nextSize); -+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); -+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); -+ } - } - return nextSize; - } -@@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - dataVersion, - drawDistance = 250, - contentInsetEndAdjustment, -+ contentInsetStartAdjustment, - estimatedItemSize = 100, - estimatedListSize, - extraData, -@@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - onLayout: onLayoutProp, - onLoad, - onMomentumScrollEnd, -+ onScrollBeginDrag, - onRefresh, - onScroll: onScrollProp, - onStartReached, -@@ -6556,7 +6708,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - const combinedRef = useCombinedRef(refScroller, refScrollView); - const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); - const stickyHeaderIndices = stickyHeaderIndicesProp; -- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; -+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; - const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); - const alwaysRenderIndices = useMemo(() => { - const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - contentContainerAlignItems: contentContainerStyle.alignItems, - contentInset, - contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, -+ contentInsetStartAdjustment, - data: dataProp, - dataVersion, - drawDistance, -@@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - return void 0; - } - const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); -+ if (getContentInsetStartAdjustment(ctx) > 0) { -+ // With a native leading inset (transparent header + automatic insets), -+ // any mount-time contentOffset races UIKit's adjustedContentInset -+ // application. The gated initial-scroll dispatch positions the list once -+ // real sizes are known instead. -+ return void 0; -+ } - return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; - }, [usesBootstrapInitialScroll]); - useLayoutEffect(() => { -@@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - onMomentumScrollEnd(event); - } - }, -+ onScrollBeginDrag: (event) => { -+ ctx.state.didUserDrag = true; -+ if (onScrollBeginDrag) { -+ onScrollBeginDrag(event); -+ } -+ }, - onScroll: (event) => onScroll(ctx, event) - }), - [] -@@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded - onLayout, - onLayoutFooter, - onMomentumScrollEnd: fns.onMomentumScrollEnd, -+ onScrollBeginDrag: fns.onScrollBeginDrag, - onScroll: onScrollHandler, - recycleItems, - refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { -diff --git a/reanimated.d.ts b/reanimated.d.ts -index 7e2d11f..d5b0d66 100644 ---- a/reanimated.d.ts -+++ b/reanimated.d.ts -@@ -285,6 +285,12 @@ interface LegendListSpecificProps { - * The adjustment is also rendered as real content padding so the browser scroll range includes it. - */ - contentInsetEndAdjustment?: number; -+ /** -+ * Width/height of a leading content inset applied natively outside the list's knowledge -+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). -+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. -+ */ -+ contentInsetStartAdjustment?: number; - /** - * Number of columns to render items in. - * @default 1 diff --git a/patches/@legendapp__list@3.3.3.patch b/patches/@legendapp__list@3.3.3.patch new file mode 100644 index 0000000000000000000000000000000000000000..4fa135d5aa082d5146787c3679a9fa506b1c4715 --- /dev/null +++ b/patches/@legendapp__list@3.3.3.patch @@ -0,0 +1,1014 @@ +diff --git a/keyboard.d.ts b/keyboard.d.ts +index 7bc3bb8..75ec120 100644 +--- a/keyboard.d.ts ++++ b/keyboard.d.ts +@@ -277,7 +277,7 @@ type KeyboardChatComposerInsetListRef = { + type KeyboardChatComposerRef = { + current: Pick | null; + }; +-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { ++declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { + contentInsetEndAdjustment: SharedValue; + onComposerLayout: (event: LayoutChangeEvent) => void; + }; +@@ -286,8 +286,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb + scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise; + }; + declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { ++ adjustedInsetCompensation?: number; + anchoredEndSpace?: AnchoredEndSpaceConfig; + contentInsetEndAdjustment?: SharedValue; ++ contentInsetEndStaticAdjustment?: number; + keyboardOffset?: number; + } & React.RefAttributes) => React.ReactElement | null; + +diff --git a/keyboard.js b/keyboard.js +index 736286a..8218172 100644 +--- a/keyboard.js ++++ b/keyboard.js +@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. + "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." + ); + } +-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { + const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); + const lastHeightRef = React.useRef(void 0); + const reportHeight = React.useCallback( +- (height) => { ++ (rawHeight) => { ++ const height = Math.max(0, rawHeight + heightAdjustment); + var _a; + if (Number.isFinite(height) && height !== lastHeightRef.current) { + lastHeightRef.current = height; + contentInsetEndAdjustment.value = height; +- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + } + }, +- [contentInsetEndAdjustment, listRef] ++ [contentInsetEndAdjustment, heightAdjustment, listRef] + ); + React.useLayoutEffect(() => { + var _a; +@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { + } + var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { + const { ++ adjustedInsetCompensation, + anchoredEndSpace, + applyWorkaroundForContentInsetHitTestBug, + contentInsetEndAdjustment, ++ contentInsetEndStaticAdjustment, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + includeInEndInset: true, + onSizeChanged: (size) => { + var _a; +- blankSpace.value = size; ++ // The anchored blank is sized against the ADJUSTED viewport, but the ++ // scroll component writes it as a raw contentInset that UIKit tops up ++ // with the safe-area bottom — write it net of that or the end rest ++ // (and the anchored message) sinks one safe-area under the header. ++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; + (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); + } + }; +- }, [anchoredEndSpace, blankSpace]); ++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); + const onContentInsetChange = React.useCallback((insets) => { + var _a; + (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); +@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + reactNativeKeyboardController.KeyboardChatScrollView, + { + ...scrollProps, ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + extraContentPadding: contentInsetEndAdjustment, +@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ); + }, + [ ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + contentInsetEndAdjustment, +@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + AnimatedLegendListInternal, + { + anchoredEndSpace: anchoredEndSpaceWithBlankSpace, ++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, + ref: combinedRef, + renderScrollComponent: memoList, + ...rest +diff --git a/keyboard.mjs b/keyboard.mjs +index c1dd270..cb0d142 100644 +--- a/keyboard.mjs ++++ b/keyboard.mjs +@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { + "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." + ); + } +-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { + const contentInsetEndAdjustment = useSharedValue(initialHeight); + const lastHeightRef = useRef(void 0); + const reportHeight = useCallback( +- (height) => { ++ (rawHeight) => { ++ const height = Math.max(0, rawHeight + heightAdjustment); + var _a; + if (Number.isFinite(height) && height !== lastHeightRef.current) { + lastHeightRef.current = height; + contentInsetEndAdjustment.value = height; +- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); + } + }, +- [contentInsetEndAdjustment, listRef] ++ [contentInsetEndAdjustment, heightAdjustment, listRef] + ); + useLayoutEffect(() => { + var _a; +@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { + } + var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { + const { ++ adjustedInsetCompensation, + anchoredEndSpace, + applyWorkaroundForContentInsetHitTestBug, + contentInsetEndAdjustment, ++ contentInsetEndStaticAdjustment, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + includeInEndInset: true, + onSizeChanged: (size) => { + var _a; +- blankSpace.value = size; ++ // The anchored blank is sized against the ADJUSTED viewport, but the ++ // scroll component writes it as a raw contentInset that UIKit tops up ++ // with the safe-area bottom — write it net of that or the end rest ++ // (and the anchored message) sinks one safe-area under the header. ++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; + (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); + } + }; +- }, [anchoredEndSpace, blankSpace]); ++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); + const onContentInsetChange = useCallback((insets) => { + var _a; + (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); +@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + KeyboardChatScrollView, + { + ...scrollProps, ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + extraContentPadding: contentInsetEndAdjustment, +@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ); + }, + [ ++ adjustedInsetCompensation, + applyWorkaroundForContentInsetHitTestBug, + blankSpace, + contentInsetEndAdjustment, +@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + AnimatedLegendListInternal, + { + anchoredEndSpace: anchoredEndSpaceWithBlankSpace, ++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment, + ref: combinedRef, + renderScrollComponent: memoList, + ...rest +diff --git a/react-native.d.ts b/react-native.d.ts +index 8204015..cdeaab7 100644 +--- a/react-native.d.ts ++++ b/react-native.d.ts +@@ -293,6 +293,12 @@ interface LegendListSpecificProps { + * The adjustment is also rendered as real content padding so the browser scroll range includes it. + */ + contentInsetEndAdjustment?: number; ++ /** ++ * Width/height of a leading content inset applied natively outside the list's knowledge ++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). ++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. ++ */ ++ contentInsetStartAdjustment?: number; + /** + * Number of columns to render items in. + * @default 1 +diff --git a/react-native.js b/react-native.js +index 229f09a..2a1ceb6 100644 +--- a/react-native.js ++++ b/react-native.js +@@ -930,7 +930,7 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { + } + + // src/core/clampScrollOffset.ts ++function getContentInsetStartAdjustment(ctx) { ++ const adjustment = ctx.state.props.contentInsetStartAdjustment; ++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + const state = ctx.state; + const contentSize = getContentSize(ctx); ++ const minOffset = -getContentInsetStartAdjustment(ctx); + let clampedOffset = offset; + if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { +- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); ++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); + const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; + const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; + const maxOffset = baseMaxOffset + extraEndOffset; + clampedOffset = Math.min(offset, maxOffset); + } +- clampedOffset = Math.max(0, clampedOffset); ++ clampedOffset = Math.max(minOffset, clampedOffset); + return clampedOffset; + } + +@@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) { + finishScrollTo(ctx); + } + } +-function scrollToFallbackOffset(ctx, offset) { ++function scrollToFallbackOffset(ctx, offset, animated) { + var _a3; + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, ++ animated: !!animated, + x: ctx.state.props.horizontal ? offset : 0, + y: ctx.state.props.horizontal ? 0 : offset + }); +@@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) { + }); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) { + } + if (shouldMaintainScrollAtEnd) { + state.pendingMaintainScrollAtEnd = false; ++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; ++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; ++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { ++ return false; ++ } + const contentSize = getContentSize(ctx); ++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); + if (contentSize < state.scrollLength) { +- state.scroll = 0; ++ state.scroll = -maintainInsetStartAdjustment; ++ if (maintainInsetStartAdjustment > 0) { ++ return true; ++ } + } + if (!state.maintainingScrollAtEnd) { + const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; +@@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) { + y: 0 + }); + } else { +- scroller == null ? void 0 : scroller.scrollToEnd({ +- animated: maintainScrollAtEnd.animated +- }); ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0) { ++ scroller == null ? void 0 : scroller.scrollTo({ ++ animated: maintainScrollAtEnd.animated, ++ x: 0, ++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) ++ }); ++ } else { ++ scroller == null ? void 0 : scroller.scrollToEnd({ ++ animated: maintainScrollAtEnd.animated ++ }); ++ } + } + setTimeout( + () => { +@@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) { + function requestAdjust(ctx, positionDiff, dataChanged) { + const state = ctx.state; + if (Math.abs(positionDiff) > 0.1) { ++ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP ++ // size adjustment are already compensated by a contentOffset shift, so ++ // animating them would make rows visibly lurch and slide back. ++ state.lastMVCPAdjustTime = Date.now(); + const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; + const doit = () => { + if (needsScrollWorkaround) { +@@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { + return 0; + } +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const insetStartAdjustment = state.props.contentInsetStartAdjustment; ++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; ++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); + const clampDelta = maxScroll - state.scroll; + if (unresolvedAmount < 0) { + return Math.max(unresolvedAmount, Math.min(0, clampDelta)); +@@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { + settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); + return true; + } +- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); + const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); + const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; + if (isAtExpectedNativeClamp) { +@@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) { + if (diff > 0) { + diff = Math.max(0, totalSize - state.scroll - state.scrollLength); + } else { +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); + state.scroll = maxScroll; + state.scrollPending = maxScroll; + diff = 0; +@@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, { + } + + // src/core/initialScroll.ts ++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; ++var INSET_END_REVEAL_STABLE_FRAMES = 3; ++var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; ++function startInsetEndSettleWatchdog(ctx) { ++ const state = ctx.state; ++ if (state.insetEndSettleWatchdogActive) { ++ return; ++ } ++ state.insetEndSettleWatchdogActive = true; ++ state.didUserDrag = false; ++ // Hold the readyToRender opacity gate until the end landing is stable, so ++ // the estimated-to-measured settle chase happens before first VISIBLE ++ // paint instead of in front of the user. Capped so slow measurement can ++ // never hide content for long. ++ state.insetEndRevealHold = true; ++ let frames = 0; ++ let settledFrames = 0; ++ let revealStableFrames = 0; ++ const releaseRevealHold = () => { ++ if (state.insetEndRevealHold) { ++ state.insetEndRevealHold = false; ++ setInitialRenderState(ctx, {}); ++ } ++ }; ++ // Revealing flips adaptive rendering to "normal", which re-renders rows and ++ // can move the end target one more time — do that upgrade while still ++ // hidden (phase 1), then require stability again before revealing (phase 2) ++ // so the first visible frame is the settled one. ++ let revealPhase = 1; ++ const onRevealStability = () => { ++ var _a3; ++ if (!state.insetEndRevealHold) { ++ return; ++ } ++ if (revealPhase === 1) { ++ revealPhase = 2; ++ revealStableFrames = 0; ++ setAdaptiveRender(ctx, "normal"); ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ return; ++ } ++ releaseRevealHold(); ++ }; ++ const tick = () => { ++ // The user owns the scroll from their first drag — a re-pin here would ++ // pin the list under their finger. ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { ++ state.insetEndSettleWatchdogActive = false; ++ releaseRevealHold(); ++ return; ++ } ++ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { ++ releaseRevealHold(); ++ } ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ const contentSize = getContentSize(ctx); ++ const scrollLength = state.scrollLength; ++ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ const distance = endOffset - state.scroll; ++ // Estimated row sizes converging to measured ones can strand the initial ++ // end landing when the library's own end-anchor bookkeeping gives up. ++ // While still near the end (never fighting a user who scrolled away), ++ // re-pin to the current true end until sizes stop changing. ++ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { ++ settledFrames = 0; ++ revealStableFrames = 0; ++ const scroller = state.refScroller.current; ++ if (scroller) { ++ scroller.scrollTo({ animated: false, x: 0, y: endOffset }); ++ } ++ } else { ++ settledFrames++; ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } ++ } else { ++ // Conditions that make re-pinning unnecessary (underflow, in-flight ++ // programmatic scroll) count toward stability for the reveal. ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } ++ requestAnimationFrame(tick); ++ }; ++ requestAnimationFrame(tick); ++} + function dispatchInitialScroll(ctx, params) { + const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { ++ if (ctx.state.scrollLength <= 0) { ++ // The scroll length is unknown, so the resolved target is meaningless; ++ // the initial-scroll machinery re-advances after layout. ++ return; ++ } ++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { ++ // Content underflows the viewport and nothing has scrolled yet: the ++ // untouched native rest position (UIKit's adjustedContentInset) IS the ++ // end position. Dispatching a negative scroll here would race UIKit's ++ // inset application during screen attach, so just finish the session. ++ finishInitialScroll(ctx, { ++ resolvedOffset ++ }); ++ return; ++ } ++ ctx.state.didDispatchInsetInitialScroll = true; ++ if (target.viewPosition === 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const requestedIndex = target.index; + const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; + const itemSize = getItemSizeAtIndex(ctx, index); +@@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { + return; + } + if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { +- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); ++ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); + if (!shouldKeepEndTargetAlive) { + if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { + clearPendingInitialScrollFooterLayout(ctx, { +@@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + } + contentBelowAnchor += footerSize + stylePaddingBottom; + isReady = !hasUnknownTailSize; +- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; + } else if (anchorIndex >= 0) { + isReady = false; + } +@@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true); + } + (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); ++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { ++ set$(ctx, "anchoredEndSpaceSize", nextSize); ++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); ++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { ++ updateScroll(ctx, state.scroll, true); ++ } + } + return nextSize; + } +@@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance = 250, + contentInsetEndAdjustment, ++ contentInsetStartAdjustment, + estimatedItemSize = 100, + estimatedListSize, + extraData, +@@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout: onLayoutProp, + onLoad, + onMomentumScrollEnd, ++ onScrollBeginDrag, + onRefresh, + onScroll: onScrollProp, + onScrollBeginDrag, +@@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + const combinedRef = useCombinedRef(refScroller, refScrollView); + const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); + const stickyHeaderIndices = stickyHeaderIndicesProp; +- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; ++ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; + const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); + const alwaysRenderIndices = React2.useMemo(() => { + const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); +@@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + contentContainerAlignItems: contentContainerStyle.alignItems, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, ++ contentInsetStartAdjustment, + data: dataProp, + dataKey, + dataVersion, +@@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + return void 0; + } + const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); ++ if (getContentInsetStartAdjustment(ctx) > 0) { ++ // With a native leading inset (transparent header + automatic insets), ++ // any mount-time contentOffset races UIKit's adjustedContentInset ++ // application. The gated initial-scroll dispatch positions the list once ++ // real sizes are known instead. ++ return void 0; ++ } + return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; + }, [usesBootstrapInitialScroll]); + React2.useLayoutEffect(() => { +@@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScroll: (event) => onScroll(ctx, event), + onScrollBeginDrag: (event) => { + var _a4, _b2; ++ ctx.state.didUserDrag = true; + prepareReachedEdgeForNextUserScroll(ctx); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, +@@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout, + onLayoutFooter, + onMomentumScrollEnd: fns.onMomentumScrollEnd, ++ onScrollBeginDrag: fns.onScrollBeginDrag, + onScroll: onScrollHandler, + recycleItems, + refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { +diff --git a/react-native.mjs b/react-native.mjs +index c2e0f38..5313086 100644 +--- a/react-native.mjs ++++ b/react-native.mjs +@@ -909,7 +909,7 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { + } + + // src/core/clampScrollOffset.ts ++function getContentInsetStartAdjustment(ctx) { ++ const adjustment = ctx.state.props.contentInsetStartAdjustment; ++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + const state = ctx.state; + const contentSize = getContentSize(ctx); ++ const minOffset = -getContentInsetStartAdjustment(ctx); + let clampedOffset = offset; + if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { +- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); ++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); + const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; + const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; + const maxOffset = baseMaxOffset + extraEndOffset; + clampedOffset = Math.min(offset, maxOffset); + } +- clampedOffset = Math.max(0, clampedOffset); ++ clampedOffset = Math.max(minOffset, clampedOffset); + return clampedOffset; + } + +@@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) { + finishScrollTo(ctx); + } + } +-function scrollToFallbackOffset(ctx, offset) { ++function scrollToFallbackOffset(ctx, offset, animated) { + var _a3; + (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, ++ animated: !!animated, + x: ctx.state.props.horizontal ? offset : 0, + y: ctx.state.props.horizontal ? 0 : offset + }); +@@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) { + }); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) { + } + if (shouldMaintainScrollAtEnd) { + state.pendingMaintainScrollAtEnd = false; ++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace; ++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; ++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { ++ return false; ++ } + const contentSize = getContentSize(ctx); ++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); + if (contentSize < state.scrollLength) { +- state.scroll = 0; ++ state.scroll = -maintainInsetStartAdjustment; ++ if (maintainInsetStartAdjustment > 0) { ++ return true; ++ } + } + if (!state.maintainingScrollAtEnd) { + const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; +@@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) { + y: 0 + }); + } else { +- scroller == null ? void 0 : scroller.scrollToEnd({ +- animated: maintainScrollAtEnd.animated +- }); ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0) { ++ scroller == null ? void 0 : scroller.scrollTo({ ++ animated: maintainScrollAtEnd.animated, ++ x: 0, ++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) ++ }); ++ } else { ++ scroller == null ? void 0 : scroller.scrollToEnd({ ++ animated: maintainScrollAtEnd.animated ++ }); ++ } + } + setTimeout( + () => { +@@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) { + function requestAdjust(ctx, positionDiff, dataChanged) { + const state = ctx.state; + if (Math.abs(positionDiff) > 0.1) { ++ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP ++ // size adjustment are already compensated by a contentOffset shift, so ++ // animating them would make rows visibly lurch and slide back. ++ state.lastMVCPAdjustTime = Date.now(); + const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; + const doit = () => { + if (needsScrollWorkaround) { +@@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { + if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { + return 0; + } +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const insetStartAdjustment = state.props.contentInsetStartAdjustment; ++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; ++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); + const clampDelta = maxScroll - state.scroll; + if (unresolvedAmount < 0) { + return Math.max(unresolvedAmount, Math.min(0, clampDelta)); +@@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { + settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); + return true; + } +- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); ++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); + const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); + const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; + if (isAtExpectedNativeClamp) { +@@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) { + if (diff > 0) { + diff = Math.max(0, totalSize - state.scroll - state.scrollLength); + } else { +- const maxScroll = Math.max(0, totalSize - state.scrollLength); ++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); + state.scroll = maxScroll; + state.scrollPending = maxScroll; + diff = 0; +@@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, { + } + + // src/core/initialScroll.ts ++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; ++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; ++var INSET_END_REVEAL_STABLE_FRAMES = 3; ++var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; ++function startInsetEndSettleWatchdog(ctx) { ++ const state = ctx.state; ++ if (state.insetEndSettleWatchdogActive) { ++ return; ++ } ++ state.insetEndSettleWatchdogActive = true; ++ state.didUserDrag = false; ++ // Hold the readyToRender opacity gate until the end landing is stable, so ++ // the estimated-to-measured settle chase happens before first VISIBLE ++ // paint instead of in front of the user. Capped so slow measurement can ++ // never hide content for long. ++ state.insetEndRevealHold = true; ++ let frames = 0; ++ let settledFrames = 0; ++ let revealStableFrames = 0; ++ const releaseRevealHold = () => { ++ if (state.insetEndRevealHold) { ++ state.insetEndRevealHold = false; ++ setInitialRenderState(ctx, {}); ++ } ++ }; ++ // Revealing flips adaptive rendering to "normal", which re-renders rows and ++ // can move the end target one more time — do that upgrade while still ++ // hidden (phase 1), then require stability again before revealing (phase 2) ++ // so the first visible frame is the settled one. ++ let revealPhase = 1; ++ const onRevealStability = () => { ++ var _a3; ++ if (!state.insetEndRevealHold) { ++ return; ++ } ++ if (revealPhase === 1) { ++ revealPhase = 2; ++ revealStableFrames = 0; ++ setAdaptiveRender(ctx, "normal"); ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ return; ++ } ++ releaseRevealHold(); ++ }; ++ const tick = () => { ++ // The user owns the scroll from their first drag — a re-pin here would ++ // pin the list under their finger. ++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { ++ state.insetEndSettleWatchdogActive = false; ++ releaseRevealHold(); ++ return; ++ } ++ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { ++ releaseRevealHold(); ++ } ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ const contentSize = getContentSize(ctx); ++ const scrollLength = state.scrollLength; ++ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { ++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); ++ const distance = endOffset - state.scroll; ++ // Estimated row sizes converging to measured ones can strand the initial ++ // end landing when the library's own end-anchor bookkeeping gives up. ++ // While still near the end (never fighting a user who scrolled away), ++ // re-pin to the current true end until sizes stop changing. ++ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { ++ settledFrames = 0; ++ revealStableFrames = 0; ++ const scroller = state.refScroller.current; ++ if (scroller) { ++ scroller.scrollTo({ animated: false, x: 0, y: endOffset }); ++ } ++ } else { ++ settledFrames++; ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } ++ } else { ++ // Conditions that make re-pinning unnecessary (underflow, in-flight ++ // programmatic scroll) count toward stability for the reveal. ++ revealStableFrames++; ++ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { ++ onRevealStability(); ++ } ++ } ++ requestAnimationFrame(tick); ++ }; ++ requestAnimationFrame(tick); ++} + function dispatchInitialScroll(ctx, params) { + const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; ++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx); ++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { ++ if (ctx.state.scrollLength <= 0) { ++ // The scroll length is unknown, so the resolved target is meaningless; ++ // the initial-scroll machinery re-advances after layout. ++ return; ++ } ++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { ++ // Content underflows the viewport and nothing has scrolled yet: the ++ // untouched native rest position (UIKit's adjustedContentInset) IS the ++ // end position. Dispatching a negative scroll here would race UIKit's ++ // inset application during screen attach, so just finish the session. ++ finishInitialScroll(ctx, { ++ resolvedOffset ++ }); ++ return; ++ } ++ ctx.state.didDispatchInsetInitialScroll = true; ++ if (target.viewPosition === 1) { ++ startInsetEndSettleWatchdog(ctx); ++ } ++ } + const requestedIndex = target.index; + const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; + const itemSize = getItemSizeAtIndex(ctx, index); +@@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { + return; + } + if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { +- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); ++ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; ++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); + if (!shouldKeepEndTargetAlive) { + if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { + clearPendingInitialScrollFooterLayout(ctx, { +@@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + } + contentBelowAnchor += footerSize + stylePaddingBottom; + isReady = !hasUnknownTailSize; +- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); ++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; + } else if (anchorIndex >= 0) { + isReady = false; + } +@@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true); + } + (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); ++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { ++ set$(ctx, "anchoredEndSpaceSize", nextSize); ++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); ++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { ++ updateScroll(ctx, state.scroll, true); ++ } + } + return nextSize; + } +@@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance = 250, + contentInsetEndAdjustment, ++ contentInsetStartAdjustment, + estimatedItemSize = 100, + estimatedListSize, + extraData, +@@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + const combinedRef = useCombinedRef(refScroller, refScrollView); + const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); + const stickyHeaderIndices = stickyHeaderIndicesProp; +- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; ++ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; + const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); + const alwaysRenderIndices = useMemo(() => { + const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); +@@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + contentContainerAlignItems: contentContainerStyle.alignItems, + contentInset, + contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, ++ contentInsetStartAdjustment, + data: dataProp, + dataKey, + dataVersion, +@@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + return void 0; + } + const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); ++ if (getContentInsetStartAdjustment(ctx) > 0) { ++ // With a native leading inset (transparent header + automatic insets), ++ // any mount-time contentOffset races UIKit's adjustedContentInset ++ // application. The gated initial-scroll dispatch positions the list once ++ // real sizes are known instead. ++ return void 0; ++ } + return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; + }, [usesBootstrapInitialScroll]); + useLayoutEffect(() => { +@@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScroll: (event) => onScroll(ctx, event), + onScrollBeginDrag: (event) => { + var _a4, _b2; ++ ctx.state.didUserDrag = true; + prepareReachedEdgeForNextUserScroll(ctx); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, +@@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onLayout, + onLayoutFooter, + onMomentumScrollEnd: fns.onMomentumScrollEnd, ++ onScrollBeginDrag: fns.onScrollBeginDrag, + onScroll: onScrollHandler, + recycleItems, + refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { +diff --git a/reanimated.d.ts b/reanimated.d.ts +index 940da28..28dccbe 100644 +--- a/reanimated.d.ts ++++ b/reanimated.d.ts +@@ -294,6 +294,12 @@ interface LegendListSpecificProps { + * The adjustment is also rendered as real content padding so the browser scroll range includes it. + */ + contentInsetEndAdjustment?: number; ++ /** ++ * Width/height of a leading content inset applied natively outside the list's knowledge ++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). ++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. ++ */ ++ contentInsetStartAdjustment?: number; + /** + * Number of columns to render items in. + * @default 1 +diff --git a/reanimated.js b/reanimated.js +index f1265fa..16dcef0 100644 +--- a/reanimated.js ++++ b/reanimated.js +@@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); + const prevItemKeyRef = React__namespace.useRef(void 0); + let shouldSkipTransitionForRecycleReuse = false; +- if (recycleItems && layoutTransition) { ++ if (layoutTransition) { + const itemKeySignal = `containerItemKey${id}`; + const itemKey = peek$(ctx, itemKeySignal); + shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; +@@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + () => [style, horizontal ? { left: positionValue } : { top: positionValue }], + [horizontal, positionValue, style] + ); ++ // Two kinds of repositions must not animate: (1) MVCP size adjustments, ++ // which are compensated by an equal contentOffset shift so the row should ++ // not visibly move — animating turns the invisible correction into a ++ // lurch-and-settle; (2) any reposition while the user is actively ++ // scrolling, where rows shifting under the finger reads as jank. The ++ // transition exists to smooth at-rest shifts (streaming text, work-log ++ // folds), so gate it to at-rest moments. ++ const now = Date.now(); ++ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ++ now - (ctx.state.lastNativeScrollTime || 0) < 300; + return /* @__PURE__ */ React__namespace.createElement( + Reanimated__default.default.View, + { +- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, ++ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, + ref: refView, + style: viewStyle, + ...rest +diff --git a/reanimated.mjs b/reanimated.mjs +index 29a00d5..9c25ec5 100644 +--- a/reanimated.mjs ++++ b/reanimated.mjs +@@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); + const prevItemKeyRef = React.useRef(void 0); + let shouldSkipTransitionForRecycleReuse = false; +- if (recycleItems && layoutTransition) { ++ if (layoutTransition) { + const itemKeySignal = `containerItemKey${id}`; + const itemKey = peek$(ctx, itemKeySignal); + shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; +@@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + () => [style, horizontal ? { left: positionValue } : { top: positionValue }], + [horizontal, positionValue, style] + ); ++ // Two kinds of repositions must not animate: (1) MVCP size adjustments, ++ // which are compensated by an equal contentOffset shift so the row should ++ // not visibly move — animating turns the invisible correction into a ++ // lurch-and-settle; (2) any reposition while the user is actively ++ // scrolling, where rows shifting under the finger reads as jank. The ++ // transition exists to smooth at-rest shifts (streaming text, work-log ++ // folds), so gate it to at-rest moments. ++ const now = Date.now(); ++ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ++ now - (ctx.state.lastNativeScrollTime || 0) < 300; + return /* @__PURE__ */ React.createElement( + Reanimated.View, + { +- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, ++ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, + ref: refView, + style: viewStyle, + ...rest diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c46d2c6a4dc804e64afcd3344cf9d4d4fdcc3cae..816846ab5298a8054a4b3287fc847bfbc603f7bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,7 +74,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.102': a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.2.0': 9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d + '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 @@ -212,8 +212,8 @@ importers: specifier: ~56.0.18 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': - specifier: 3.2.0 - 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) + specifier: 3.3.3 + 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) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -545,7 +545,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 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: react-native: optional: true + '@legendapp/list@3.3.3': + resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} + peerDependencies: + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + '@lexical/clipboard@0.41.0': resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==} @@ -12982,7 +12994,14 @@ snapshots: dependencies: jsbi: 4.3.2 - '@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)': + '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + '@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)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12990,13 +13009,6 @@ snapshots: 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) - '@legendapp/list@3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) - optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - '@lexical/clipboard@0.41.0': dependencies: '@lexical/html': 0.41.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 70f61242053d2e918d91ea5fcdaca1a6543106f2..d41e50e8784c59693a8dc08586893acc01a38827 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -127,7 +127,7 @@ patchedDependencies: "@effect/vitest@4.0.0-beta.102": patches/@effect__vitest@4.0.0-beta.102.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch - "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch + "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch