diff --git a/shared/chat/conversation/header-area/index.tsx b/shared/chat/conversation/header-area/index.tsx index bcb42c00574c..bf2c775acacd 100644 --- a/shared/chat/conversation/header-area/index.tsx +++ b/shared/chat/conversation/header-area/index.tsx @@ -51,7 +51,7 @@ const HeaderAreaRight = (props: HeaderConversationProps) => { noShrink={true} style={Kb.Styles.collapseStyles([styles.headerRight, {opacity: pendingWaiting ? 0 : 1}])} > - + ) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index dda21755fdf5..765cfa4f9678 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -1,7 +1,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as React from 'react' -import * as T from '@/constants/types' +import type * as T from '@/constants/types' import * as TestIDs from '@/tests/e2e/shared/test-ids' import Separator from '../messages/separator' import SpecialBottomMessage from '../messages/special-bottom-message' @@ -30,21 +30,23 @@ import {copyToClipboard} from '@/util/storeless-actions' import noop from 'lodash/noop' import {LegendList} from '@legendapp/list/react' import type {LegendListRef} from '@/common-adapters' -import {FlatList} from 'react-native' -import type {ScrollViewProps} from 'react-native' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import { - KeyboardChatScrollView, - useKeyboardState, - useReanimatedKeyboardAnimation, -} from 'react-native-keyboard-controller' -import Animated, {interpolate, useAnimatedStyle} from 'react-native-reanimated' + KeyboardAwareLegendList, + useKeyboardScrollToEnd, +} from '@legendapp/list/keyboard' +import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller' +import Animated, {interpolate, useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated' +import {scheduleOnRN} from 'react-native-worklets' import {ThreadSearchOverlayContext} from '../thread-search-overlay-context' import {useSafeAreaInsets} from 'react-native-safe-area-context' type ItemType = T.Chat.Ordinal const noOrdinals: ReadonlyArray = [] +// Stable config so it doesn't churn props each render. Empty = enable adaptive render with defaults. +const adaptiveRenderConfig = {} + const keyExtractor = (ordinal: ItemType) => String(ordinal) // Item type for list recycling pool separation. A message that leads its author group renders an @@ -138,7 +140,6 @@ const usePagination = (p: { return {onEndReached, onStartReached} } -const centerTolerancePx = 8 // A scroller within this many pixels of its end counts as at the end. const endTolerancePx = 2 @@ -212,6 +213,52 @@ const HighlightableRow = React.memo(({ordinal}: {ordinal: T.Chat.Ordinal}) => { }) HighlightableRow.displayName = 'HighlightableRow' +// Sending the list to the centered ordinal: a search hit, a reply-quote jump, a pinned message. +// +// Centring on the raw ordinal change is unreliable: navigating to a hit reloads the thread centred +// on it, so the target is briefly absent from messageOrdinals when the ordinal changes. Wait for it +// to arrive, then scroll once per target and no more. Re-issuing when the target's index moves looks +// reasonable - a prepend does shift it - but scrolling is what triggers that prepend, so it would +// re-centre the list out from under someone reading around the hit. The list holds the target in +// place while rows measure, and maintainVisibleContentPosition holds it across prepends. +// +// Reset per dataset rather than per conversation: re-centring on the ordinal already stored still +// clears and reloads the thread, so the list has to be sent to it again. +const useScrollToCentered = (p: { + centeredOrdinal: T.Chat.Ordinal | undefined + datasetKey: string + listRef: React.RefObject + messageOrdinals: ReadonlyArray + ready: boolean +}) => { + const {centeredOrdinal, datasetKey, listRef, messageOrdinals, ready} = p + const lastScrolledRef = React.useRef(undefined) + React.useLayoutEffect(() => { + lastScrolledRef.current = undefined + }, [datasetKey]) + + // Unconditional on purpose. A guard that stood this call down while the list's own + // initialScrollIndex bootstrap looked like it owned the target was tried and measured against the + // app, and there is no version of it that is safe: on the permalink path the thread mounts with no + // centred target, so the list is built with initialScrollAtEnd, and the bootstrap it re-arms when + // the centred dataset lands does not move it - the thread settles at its end with the target never + // shown. That path is indistinguishable from a warm in-thread jump by anything visible here (both + // arrive as "dataset with a resolvable initialScrollIndex"), so this call has to be the one + // authority that always fires. + React.useEffect(() => { + if (!ready || centeredOrdinal === undefined) { + lastScrolledRef.current = undefined + return + } + if (lastScrolledRef.current === centeredOrdinal) return + if (sortedIndexOf(messageOrdinals as unknown as number[], centeredOrdinal as unknown as number) < 0) { + return + } + lastScrolledRef.current = centeredOrdinal + void listRef.current?.scrollToItem({animated: false, item: centeredOrdinal, viewPosition: 0.5}) + }, [centeredOrdinal, datasetKey, listRef, messageOrdinals, ready]) +} + const DesktopThreadWrapper = function DesktopThreadWrapper() { const desktopStyles = useDesktopStyles() const editingOrdinal = InputState.useConversationInput(s => s.editing) @@ -221,7 +268,9 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const {clearVersion, containsLatestMessage, messageOrdinals, loaded} = data // Centered loads (search hit, reply-quote jump, pinned message) clear the thread before - // refetching, so the list sees a non-empty -> empty -> non-empty transition. + // refetching, so the list sees a non-empty -> empty -> non-empty transition it cannot recover + // from on its own. dataKey tells it the data is a new dataset, which is what makes it reset + // rather than wait for a container layout that never comes. const datasetKey = `${conversationIDKey}:${clearVersion}` const listRef = React.useRef(null) @@ -319,111 +368,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { [onScroll] ) - // Scroll to centered ordinal when it changes (search / thread navigation). - // Use a "last scrolled to" ref rather than a "did it change" ref so we still - // scroll when loaded becomes true after centeredOrdinal was already set. - // Reset per dataset, not per conversation: re-centering on the ordinal we are already parked - // on still reloads the thread, so the list has to scroll to it again. - const lastScrolledCenteredRef = React.useRef(undefined) - React.useLayoutEffect(() => { - lastScrolledCenteredRef.current = undefined - }, [datasetKey]) - - // Owns the in-flight centering loop. It has to outlive re-renders: the messages that make - // centering accurate arrive after it starts, so the loop must not be torn down by an effect - // cleanup when messageOrdinals changes. Only a new target or unmount stops it. - const centerLoopRef = React.useRef<{cancelled: boolean} | undefined>(undefined) - // The loop re-centers for up to ~3s; a user scrolling in that window must win. - const abortCentering = React.useCallback(() => { - if (centerLoopRef.current) centerLoopRef.current.cancelled = true - }, []) - React.useEffect(() => abortCentering, [abortCentering]) - - // Closed loop, not one shot: rows enter at estimatedItemSize and only settle as they measure, so - // the first scroll lands off by however wrong the estimates above the target were. Measure the - // row's real offset from the viewport center and correct until it holds still, then get out of - // the way: maintainVisibleContentPosition owns the offset from then on. Two controllers fighting - // over the same scroll offset would oscillate. - // - // Correct via LegendList's own scrollToOffset, never scrollIntoView: touching scrollTop directly - // desyncs LegendList's internal scroll state, and the next time it recomputes item positions it - // snaps somewhere unrelated. - const scrollToCentered = React.useEffectEvent((target: T.Chat.Ordinal) => { - abortCentering() - const loop = {cancelled: false} - centerLoopRef.current = loop - const run = async () => { - let settled = 0 - let pinnedChecks = 0 - let scrollAtLastRequest: number | undefined - for (let elapsed = 0; elapsed < 3000 && !loop.cancelled; ) { - const wrapper = wrapperRef.current as unknown as { - getBoundingClientRect: () => {height: number; top: number} - querySelector: (s: string) => {getBoundingClientRect: () => {height: number; top: number}} | null - } | null - const el = wrapper ? wrapper.querySelector(`[data-ordinal="${target}"]`) : null - if (!wrapper || !el) { - // Target is outside the rendered window; get it mounted first. - const idx = sortedIndexOf( - messageOrdinalsRef.current as unknown as number[], - target as unknown as number - ) - if (idx >= 0) { - void listRef.current?.scrollToIndex({animated: false, index: idx, viewPosition: 0.5}) - } - settled = 0 - pinnedChecks = 0 - await new Promise(resolve => setTimeout(resolve, 100)) - elapsed += 100 - continue - } - const elRect = el.getBoundingClientRect() - const wrapRect = wrapper.getBoundingClientRect() - const offBy = elRect.top + elRect.height / 2 - (wrapRect.top + wrapRect.height / 2) - const scroll = listRef.current?.getState().scroll - // Deadband, not exact centering: below this the row reads as centered, and chasing the - // remainder only fights maintainVisibleContentPosition's own sub-pixel adjustments. - if (Math.abs(offBy) <= centerTolerancePx || scroll === undefined) { - pinnedChecks = 0 - // Only the iteration right after a correction can diagnose a clamp. - scrollAtLastRequest = undefined - if (++settled >= 3) return - } else if (scroll === scrollAtLastRequest) { - // A hit near either end of the thread cannot be centered: the offset we ask for gets - // clamped and the row never reaches the middle. Our last correction moved the scroll - // position not at all, so we are pinned against an edge — stop rather than spin. - if (++pinnedChecks >= 3) return - } else { - pinnedChecks = 0 - scrollAtLastRequest = scroll - void listRef.current?.scrollToOffset({animated: false, offset: scroll + offBy}) - } - await new Promise(resolve => setTimeout(resolve, 50)) - elapsed += 50 - } - } - void run() - }) - - React.useEffect(() => { - if (!loaded) return - if (centeredOrdinal !== undefined) { - if (lastScrolledCenteredRef.current === centeredOrdinal) return - const idx = sortedIndexOf( - messageOrdinalsRef.current as unknown as number[], - centeredOrdinal as unknown as number - ) - if (idx < 0) return - lastScrolledCenteredRef.current = centeredOrdinal - scrollToCentered(centeredOrdinal) - } else if (lastScrolledCenteredRef.current !== undefined) { - lastScrolledCenteredRef.current = undefined - abortCentering() - if (containsLatestMessage) { - void listRef.current?.scrollToEnd({animated: false}) - } - } - }, [abortCentering, centeredOrdinal, loaded, containsLatestMessage, messageOrdinals]) + useScrollToCentered({centeredOrdinal, datasetKey, listRef, messageOrdinals, ready: loaded}) // Scroll to the message being edited const lastEditingOrdinalRef = React.useRef(undefined) @@ -527,12 +472,6 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const initialScrollIndex = useInitialScrollIndex(messageOrdinals, centeredOrdinal) - // A wheel means the user took over: stop centering so we don't scroll them away from where - // they landed. - const onWheel = React.useCallback(() => { - abortCentering() - }, [abortCentering]) - return (
void} onStartReached={onStartReached} @@ -621,174 +562,76 @@ const DesktopThreadWrapperWithProfiler = () => ( // ==================== NATIVE ==================== -type RNFlatListRef = { - scrollToOffset: (opts: {animated: boolean; offset: number}) => void - scrollToItem: (opts: {animated: boolean; item: unknown; viewPosition?: number}) => void -} - -const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray) => { - const source = messageOrdinals ?? noOrdinals - return React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source]) -} - const useNativeScrolling = (p: { - centeredOrdinal: T.Chat.Ordinal - messageOrdinals: ReadonlyArray - listRef: React.RefObject + scrollMessageToEnd: (o: {animated: boolean; closeKeyboard: boolean}) => Promise }) => { - const {listRef, centeredOrdinal, messageOrdinals} = p - const numOrdinals = messageOrdinals.length - const loadOlderMessages = useConversationThreadLoadOlderMessagesDueToScroll() - const getThreadLoadStatusOptions = useThreadLoadStatusOptionsGetter() + const {scrollMessageToEnd} = p - // KeyboardChatScrollView sets contentInset.top = K - insets.bottom and - // contentOffset.y = -(K - insets.bottom) when keyboard is open. Scrolling to - // offset=0 would place content K-insets.bottom pixels lower (behind the keyboard). - // We compute the correct resting offset: keyboardHeight.value (negative) + insets.bottom. - // When keyboard is closed keyboardHeight.value = 0 so the result is clamped to 0. - const {height: keyboardAnimHeight} = useReanimatedKeyboardAnimation() - const {bottom: insetsBottom} = useSafeAreaInsets() + // scrollMessageToEnd freezes the keyboard-aware scroll view, scrolls to the end, + // then unfreezes — so the newest message stays pinned above the input bar even + // while the keyboard is open. const scrollToBottom = React.useCallback(() => { - const offset = Math.min(keyboardAnimHeight.value + insetsBottom, 0) - listRef.current?.scrollToOffset({animated: false, offset}) - }, [insetsBottom, keyboardAnimHeight, listRef]) + void scrollMessageToEnd({animated: false, closeKeyboard: false}) + }, [scrollMessageToEnd]) const {setScrollRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { setScrollRef({scrollDown: noop, scrollToBottom, scrollUp: noop}) }, [setScrollRef, scrollToBottom]) - // only scroll to center once per - const lastScrollToCentered = React.useRef(-1) - React.useEffect(() => { - if (T.Chat.ordinalToNumber(centeredOrdinal) < 0) { - lastScrollToCentered.current = -1 - } - }, [centeredOrdinal]) - - const centeredOrdinalRef = React.useRef(centeredOrdinal) - // reset per centered target so each new search hit gets a fresh batch of retries - const scrollFailRetryRef = React.useRef(0) - React.useEffect(() => { - centeredOrdinalRef.current = centeredOrdinal - scrollFailRetryRef.current = 0 - }, [centeredOrdinal]) - const [scrollToCentered] = React.useState(() => () => { - const co = centeredOrdinalRef.current - if (lastScrollToCentered.current === co) { - return - } - lastScrollToCentered.current = co - // coarse: scrollToItem lands at the wrong offset for tall variable-height rows, - // but it gets the target area rendered. The closed-loop corrector in the - // component refines from there using the real viewable index range. - const reassert = (delay: number) => - setTimeout(() => { - const list = listRef.current - const cur = centeredOrdinalRef.current - if (!list || cur !== co || T.Chat.ordinalToNumber(cur) <= 0) { - return - } - list.scrollToItem({animated: false, item: cur, viewPosition: 0.5}) - }, delay) - ;[50, 250].forEach(reassert) - }) - - // The centered hit may be outside the rendered window, so scrollToItem fails - // silently. Wait for more rows to render and retry centering (capped) until it lands. - const [onScrollToIndexFailed] = React.useState(() => () => { - if (scrollFailRetryRef.current > 5) { - return - } - scrollFailRetryRef.current += 1 - setTimeout(() => { - const co = centeredOrdinalRef.current - if (T.Chat.ordinalToNumber(co) > 0) { - listRef.current?.scrollToItem({animated: false, item: co, viewPosition: 0.5}) - } - }, 200) - }) - - const onEndReached = () => { - loadOlderMessages(numOrdinals, getThreadLoadStatusOptions()) - } - return { - onEndReached, - onScrollToIndexFailed, scrollToBottom, - scrollToCentered, } } -// The maintainVisibleContentPosition prop must ALWAYS be set (never toggled to undefined): -// RN Fabric only re-snapshots the MVP anchor while the prop is set, so an unset->set -// transition adjusts contentOffset against a stale anchor frame from before the prop was -// unset — a spurious jump + autoscroll animation of the whole list (seen after dismissing -// the keyboard following a send). Instead we swap between two configs: -// - closed (keyboard hidden): autoscrollToTopThreshold=1 so new messages at the bottom -// auto-reveal when the user is pinned there. -// - noAutoscroll (keyboard open, or centered on a search hit, or empty list): MVP still -// anchors content, but autoscroll-to-top is off because: -// 1. with the keyboard open contentOffset.y = -(K-insets.bottom) <= 1, so the threshold -// would fire on insert and scroll to y=0, hiding new messages behind the keyboard. -// 2. while centered on a search hit, autoscroll yanks the centered row. -// With the keyboard open, MVP's insert adjustment briefly holds old content in place; -// the deferred scrollToBottom layout effect below re-pins the newest message. -const maintainVisibleContentPositionClosed = { - autoscrollToTopThreshold: 1, - minIndexForVisible: 0, -} -const maintainVisibleContentPositionNoAutoscroll = { - minIndexForVisible: 0, -} +// Reads the centered highlight itself (like desktop's HighlightableRow) so renderItem stays +// referentially stable — a renderItem identity change re-renders every visible row at once. +const NativeRow = React.memo(function NativeRow({ordinal}: {ordinal: T.Chat.Ordinal}) { + const {centeredHighlightOrdinal} = useConversationCenter() + return ( + <> + + + + ) +}) + +const nativeRenderItem = ({item: ordinal}: {item: T.Chat.Ordinal}) => const NativeConversationList = function NativeConversationList() { const nativeStyles = useNativeStyles() - const List = FlatList as unknown as React.ComponentType< - Record & {ref?: React.Ref} - > - const conversationIDKey = useConversationThreadID() - const listData = useConversationThreadSelector( - C.useShallow(s => ({ - loaded: s.loaded, - messageOrdinals: s.messageOrdinals, - })) - ) - const {centeredHighlightOrdinal, centeredOrdinal} = useConversationCenter() - const noCenteredOrdinal = T.Chat.numberToOrdinal(-1) - const centeredOrdinalOrNone = centeredOrdinal ?? noCenteredOrdinal - const centeredHighlightOrdinalOrNone = centeredHighlightOrdinal ?? noCenteredOrdinal - const {loaded} = listData - - const messageOrdinals = useInvertedMessageOrdinals(listData.messageOrdinals) + const listData = useThreadListData() + const {centeredOrdinal} = useConversationCenter() + const {clearVersion, loaded, containsLatestMessage, messageOrdinals} = listData + // Same reason as desktop: a centered load empties the thread before refilling it, and the list + // needs to be told that is a new dataset rather than left waiting on layout for rows it already + // threw away. + const datasetKey = `${conversationIDKey}:${clearVersion}` + const hasCentered = centeredOrdinal !== undefined - const listRef = React.useRef(null) + const listRef = React.useRef(null) const markInitiallyLoadedThreadAsRead = useConversationThreadMarkThreadAsRead() - const keyExtractor = (ordinal: ItemType) => { - return String(ordinal) - } - - const renderItem = (info?: {item?: ItemType}) => { - const ordinal = info?.item - if (!ordinal) { - return null - } - return - } - - const numOrdinals = messageOrdinals.length - const getItemType = useGetItemType() const insets = useSafeAreaInsets() - const isKeyboardVisible = useKeyboardState((s: {isVisible: boolean}) => s.isVisible) - // While the thread-search bar is open it overlays the bottom of the list. Reserve - // that height as extra content padding so centered/newest messages clear it. + // While the thread-search bar is open it overlays the bottom of the list. Reserve that height + // as extra content padding (so the newest message clears it) and lift the jump-to-recent button + // above both the keyboard and the bar. searchOverlayHeight is a reanimated SharedValue set by + // the search bar's onLayout; mirror it to state for the (static) content padding. const searchOverlayHeight = React.useContext(ThreadSearchOverlayContext) + const [searchPad, setSearchPad] = React.useState(0) + useAnimatedReaction( + () => searchOverlayHeight?.value ?? 0, + (h, prev) => { + if (h !== prev) { + scheduleOnRN(setSearchPad, h) + } + }, + [searchOverlayHeight] + ) const {height: keyboardAnimHeight, progress: keyboardProgress} = useReanimatedKeyboardAnimation() const insetsBottom = insets.bottom // The input/search bar lives in a KeyboardStickyView with offset @@ -804,119 +647,18 @@ const NativeConversationList = function NativeConversationList() { ], })) - const {scrollToCentered, scrollToBottom, onEndReached, onScrollToIndexFailed} = useNativeScrolling({ - centeredOrdinal: centeredOrdinalOrNone, - listRef, + const {onStartReached, onEndReached} = usePagination({ + containsLatestMessage, messageOrdinals, }) - // Closed-loop centering corrector. scrollToItem/scrollToIndex lands at the wrong - // offset here (inverted list + custom keyboard scrollview + tall variable-height - // image rows), so instead we read the actual viewable index range each frame and - // scrollToOffset by the item-delta until the target sits at viewport center. - const scrollOffsetRef = React.useRef(0) - const contentHeightRef = React.useRef(0) - const centeredRef = React.useRef(centeredOrdinalOrNone) - React.useEffect(() => { - centeredRef.current = centeredOrdinalOrNone - }, [centeredOrdinalOrNone]) - const ordsRef = React.useRef(messageOrdinals) - React.useEffect(() => { - ordsRef.current = messageOrdinals - }, [messageOrdinals]) - // {active, iters}: correcting toward a centered hit and how many steps taken - const correctRef = React.useRef({active: false, iters: 0}) - const vFirstRef = React.useRef(undefined) - const vLastRef = React.useRef(undefined) - const [correctCenter] = React.useState( - () => (first: number | null | undefined, last: number | null | undefined) => { - const st = correctRef.current - if (!st.active) return - const co = centeredRef.current - const ords = ordsRef.current - const num = ords.length - if (co <= 0 || !num || first == null || last == null) return - const targetIdx = ords.indexOf(co) - if (targetIdx < 0) return - const centerIdx = (first + last) / 2 - const diff = targetIdx - centerIdx - if (Math.abs(diff) <= 0.5 || st.iters > 12) { - st.active = false - return - } - st.iters += 1 - const avgH = contentHeightRef.current / num - // damp by 0.9 to avoid overshoot/oscillation; higher index = older = higher offset - const newOffset = Math.max(0, scrollOffsetRef.current + diff * avgH * 0.9) - listRef.current?.scrollToOffset({animated: false, offset: newOffset}) - } - ) - const [onScrollNative] = React.useState( - () => - (e: {nativeEvent: {contentOffset: {y: number}; contentSize: {height: number}}}) => { - scrollOffsetRef.current = e.nativeEvent.contentOffset.y - contentHeightRef.current = e.nativeEvent.contentSize.height - } - ) - const [onContentSizeChangeNative] = React.useState(() => (_w: number, h: number) => { - contentHeightRef.current = h - }) - // user touched the list: stop fighting them - const [onScrollBeginDrag] = React.useState(() => () => { - correctRef.current.active = false - }) + const {freeze, scrollMessageToEnd} = useKeyboardScrollToEnd({listRef}) + + const {scrollToBottom} = useNativeScrolling({scrollMessageToEnd}) const jumpToRecent = useJumpToRecent(scrollToBottom, messageOrdinals.length) - // When keyboard is open, maintainVisibleContentPosition adjusts contentOffset by the new - // message height when a message is added, undoing the scrollToBottom from onSubmit. - // Defer the re-scroll past the native MPV adjustment (which runs on the UI thread after - // React's commit) so the newest message stays visible. - const prevNumOrdinalsRef = React.useRef(numOrdinals) - // Tracks which conversation prevNumOrdinalsRef's baseline belongs to so the - // baseline resets on a real conversation switch (value compare) rather than on - // a react-native-screens freeze/thaw, which re-mounts effects. - const numBaselineConvRef = React.useRef(conversationIDKey) - const isKeyboardVisibleRef = React.useRef(isKeyboardVisible) - React.useLayoutEffect(() => { - isKeyboardVisibleRef.current = isKeyboardVisible - }) - React.useLayoutEffect(() => { - const sameConv = numBaselineConvRef.current === conversationIDKey - numBaselineConvRef.current = conversationIDKey - const prev = prevNumOrdinalsRef.current - prevNumOrdinalsRef.current = numOrdinals - if (sameConv && numOrdinals > prev && isKeyboardVisibleRef.current) { - const id = setTimeout(() => { - if (isKeyboardVisibleRef.current) { - scrollToBottom() - } - }, 0) - return () => clearTimeout(id) - } - return undefined - }, [conversationIDKey, numOrdinals, scrollToBottom]) - - // Center on the search hit once it actually appears in the loaded list. Centering - // on the raw centeredOrdinal change is unreliable: navigating to a hit reloads the - // thread centered on it, so messageOrdinals is briefly empty (idx -1) when the - // ordinal changes. Wait for the target to load, then scroll (scrollToCentered - // guards against repeats and re-asserts across frames). - React.useEffect(() => { - if (!(centeredOrdinalOrNone > 0 && messageOrdinals.includes(centeredOrdinalOrNone))) { - return undefined - } - // coarse scroll to get the target area rendered, then run the closed-loop - // corrector which refines via the real viewable index range - scrollToCentered() - correctRef.current = {active: true, iters: 0} - const ids = [50, 250, 500, 900].map(d => - setTimeout(() => correctCenter(vFirstRef.current, vLastRef.current), d) - ) - return () => { - ids.forEach(clearTimeout) - } - }, [centeredOrdinalOrNone, messageOrdinals, scrollToCentered, correctCenter]) + useScrollToCentered({centeredOrdinal, datasetKey, listRef, messageOrdinals, ready: true}) // These refs store the conversation they last applied to (not a boolean) so a // freeze/thaw of this screen — which re-mounts effects without a real @@ -937,99 +679,68 @@ const NativeConversationList = function NativeConversationList() { markedConvRef.current = conversationIDKey markInitiallyLoadedThreadAsRead() } + }, [conversationIDKey, loaded, markInitiallyLoadedThreadAsRead]) - if (centeredOrdinalOrNone > 0) { - scrollToCentered() - setTimeout(() => { - scrollToCentered() - }, 100) - } else if (numOrdinals > 0) { - scrollToBottom() - setTimeout(() => { - scrollToBottom() - }, 100) - } - }, [ - conversationIDKey, - centeredOrdinalOrNone, - loaded, - markInitiallyLoadedThreadAsRead, - numOrdinals, - scrollToBottom, - scrollToCentered, - ]) - - const onViewableItemsChanged = useNativeSafeOnViewableItemsChanged(onEndReached, messageOrdinals.length) - const [onViewableItemsChangedNative] = React.useState( - () => (info: {viewableItems: Array<{index: number | null}>}) => { - onViewableItemsChanged.current(info) - const first = info.viewableItems.at(0)?.index - const last = info.viewableItems.at(-1)?.index - vFirstRef.current = first - vLastRef.current = last - correctCenter(first, last) - } - ) + const initialScrollIndex = useInitialScrollIndex(messageOrdinals, centeredOrdinal) - const renderScrollComponent = React.useCallback( - (props: ScrollViewProps) => ( - - ), - [insets.bottom, searchOverlayHeight] + // Reserve bottom space so the newest message clears the sticky input bar, which is pulled up + // over the list bottom (KeyboardStickyView offset -insets.bottom) plus the floating typing + // indicator. Without this the list scrolls to its content end but the newest row sits behind + // the input bar. + const listContentStyle = React.useMemo( + () => ({paddingBottom: mobileTypingContainerHeight + insets.bottom + searchPad}), + [insets.bottom, searchPad] ) - const mvpAutoscroll = !(centeredOrdinalOrNone > 0 || !numOrdinals || isKeyboardVisible) - - const nativeContentContainerStyle = React.useMemo( - () => ({ - paddingBottom: 0, - paddingTop: mobileTypingContainerHeight + insets.bottom, - }), - [insets.bottom] - ) + // The input bar (KeyboardStickyView, closed offset -insets.bottom) overlaps the bottom of the + // list by insets.bottom, so without this the scroll indicator runs down behind it. Inset the + // indicator by exactly that overlap (NOT the full content padding, which also reserves space for + // the floating typing indicator that the scrollbar doesn't need to clear). + const scrollIndicatorInsets = React.useMemo(() => ({bottom: insets.bottom}), [insets.bottom]) return ( - {jumpToRecent && ( @@ -1054,42 +765,4 @@ const useNativeStyles = Kb.Styles.createStyleHook( }) as const ) -const minTimeDelta = 1000 -const minDistanceFromEnd = 10 - -const useNativeSafeOnViewableItemsChanged = (onEndReached: () => void, numOrdinals: number) => { - const nextCallbackRef = React.useRef(new Date().getTime()) - const onEndReachedRef = React.useRef(onEndReached) - React.useEffect(() => { - onEndReachedRef.current = onEndReached - }, [onEndReached]) - const numOrdinalsRef = React.useRef(numOrdinals) - React.useEffect(() => { - numOrdinalsRef.current = numOrdinals - nextCallbackRef.current = new Date().getTime() + minTimeDelta - }, [numOrdinals]) - - // this can't change ever, so we have to use refs to keep in sync - const onViewableItemsChanged = React.useRef( - ({viewableItems}: {viewableItems: Array<{index: number | null}>}) => { - const idx = viewableItems.at(-1)?.index ?? 0 - const lastIdx = numOrdinalsRef.current - 1 - const offset = numOrdinalsRef.current > 50 ? minDistanceFromEnd : 1 - const deltaIdx = idx - lastIdx + offset - // not far enough from the end - if (deltaIdx < 0) { - return - } - const t = new Date().getTime() - const deltaT = t - nextCallbackRef.current - // enough time elapsed? - if (deltaT > 0) { - nextCallbackRef.current = t + minTimeDelta - onEndReachedRef.current() - } - } - ) - return onViewableItemsChanged -} - export default isMobile ? NativeConversationList : DesktopThreadWrapperWithProfiler diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index 6b6c4bc021eb..808bd3947e53 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -15,6 +15,7 @@ import { import {useConversationParticipantsSelector} from '../data-hooks' import * as FS from '@/constants/fs' import {useCurrentUserState} from '@/stores/current-user' +import * as TestIDs from '@/tests/e2e/shared/test-ids' const ErrorMessage = () => { const styles = useStyles() @@ -153,7 +154,13 @@ function SpecialTopMessage() { } return ( - + {hasLoadedEver && loadMoreType === 'noMoreToLoad' && showRetentionNotice && } {hasOlderResetConversation && } diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx index 0e1f2dee7469..cfcc77593336 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx @@ -16,6 +16,7 @@ type Props = { import {useConversationThreadToggleSearch} from '../../../thread-context' import Swipeable, {type SwipeableMethods} from '@/common-adapters/swipeable-row' import {ThreadRefsContext} from '@/chat/conversation/normal/context' +import {useAdaptiveRender} from '@legendapp/list/react-native' function ReplyIcon({progress}: {progress: Animated.Value}) { const styles = useStyles() @@ -28,16 +29,23 @@ function ReplyIcon({progress}: {progress: Animated.Value}) { } function LongPressable(props: Props & {ref?: React.Ref}) { + if (!isMobile) { + return + } + return +} + +function LongPressableMobile(props: Props & {ref?: React.Ref}) { const styles = useStyles() const toggleThreadSearch = useConversationThreadToggleSearch() const setReplyTo = InputState.useConversationInputDispatch(s => s.setReplyTo) const ordinal = useOrdinal() const {focusInput} = React.useContext(ThreadRefsContext) const swipeRef = React.useRef(null) - - if (!isMobile) { - return - } + // Velocity-driven signal from LegendList: during fast scroll it flips to "light". We keep the + // Swipeable mounted (toggling its tree would remount children and flash images) and instead just + // disable its pan handlers in light mode, shedding the per-row touch evaluation during the fling. + const adaptiveMode = useAdaptiveRender() const {children, onLongPress, style} = props @@ -68,6 +76,7 @@ function LongPressable(props: Props & {ref?: React.Ref}) { return ( diff --git a/shared/chat/conversation/messages/wrapper/sent.native.tsx b/shared/chat/conversation/messages/wrapper/sent.native.tsx index 4629e2b1f6dc..785adfd9965d 100644 --- a/shared/chat/conversation/messages/wrapper/sent.native.tsx +++ b/shared/chat/conversation/messages/wrapper/sent.native.tsx @@ -1,11 +1,11 @@ import type * as React from 'react' import Animated, {FadeInDown} from 'react-native-reanimated' -// Slide-up + fade for a message you just sent. The thread list is an inverted -// FlatList (cells are flipped with scaleY: -1), so FadeInDown renders on screen -// as sliding up from below. Runs entirely on the UI thread with no re-renders. -// The entering animation only plays when this Animated.View MOUNTS — callers must -// key it per message (recycled containers reuse instances). +// Slide-up + fade for a message you just sent. The thread list (LegendList) is +// NOT inverted, so FadeInDown (enters from 25px below, sliding up into place) +// reads as the row rising from the input bar. Runs entirely on the UI thread +// with no re-renders. The entering animation only plays when this Animated.View +// MOUNTS — callers must key it per message (recycled containers reuse instances). export function Sent(p: {children: React.ReactNode}) { return ( diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 42cb9dd9ef89..e6b237cb58dc 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -34,6 +34,7 @@ import {emptyParticipantInfo} from '../../data-hooks' import {useInboxMetadataState} from '@/chat/inbox/metadata' import type {ConversationInputState} from '../../input-area/input-state' import {useChatTeamMemberRole} from '../../team-hooks' +import * as TestIDs from '@/tests/e2e/shared/test-ids' type AccountsInfoMap = ReadonlyMap type PaymentStatusMap = ReadonlyMap @@ -905,7 +906,11 @@ function RightSide(p: RProps) { )} > - + ) @@ -1018,7 +1023,13 @@ export function WrapperMessage(p: WrapperMessageProps) { const messageContext = {isHighlighted: showCenteredHighlight, ordinal} const row = ( - + {inProgress && } + {/* collapsable={false}: Android view flattening would drop this testID'd wrapper and + leave the count unreadable to the e2e suite. */} {hasResults && ( - + {noResults ? 'No results' : `${selectedIndex + 1} of ${hits.length}`} @@ -506,13 +514,28 @@ const ThreadSearchMobileInner = function ThreadSearchMobileInner(p: CommonProps) return ( - + Cancel - + {/* collapsable={false}: keep this testID'd wrapper (and the EditText under it) as a real + view on Android, where view flattening would otherwise render it as an empty leaf. */} + {inProgress && } + {/* collapsable={false}: Android view flattening would drop this testID'd wrapper and + leave the count unreadable to the e2e suite. */} {hasResults && ( - + {status === 'done' && numHits === 0 ? 'No results' : `${selectedIndex + 1} of ${numHits}`} @@ -541,11 +571,13 @@ const ThreadSearchMobileInner = function ThreadSearchMobileInner(p: CommonProps) color={numHits > 0 ? theme.blue : theme.black_50} onClick={onUp} type="iconfont-arrow-up" + testID={TestIDs.CHAT_THREAD_SEARCH_PREV} /> 0 ? theme.blue : theme.black_50} onClick={onDown} type="iconfont-arrow-down" + testID={TestIDs.CHAT_THREAD_SEARCH_NEXT} /> diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index 598b2c74fa11..9a30b23a5214 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -96,9 +96,11 @@ ConversationThreadIDContext.displayName = 'ConversationThreadIDContext' export type ConversationThreadState = { accountsInfoMap: Map - // Bumped on every messagesClear. The desktop list remounts on it: LegendList cannot recover - // from a non-empty -> empty -> non-empty data transition (it resets its layout state and waits - // for a container layout event that never comes), so the thread renders blank forever. + // Bumped on every messagesClear, and fed to the list as its dataKey (not as a React key - the + // list is not remounted). LegendList cannot recover from a non-empty -> empty -> non-empty data + // transition on its own (it resets its layout state and waits for a container layout event that + // never comes), so the thread renders blank forever; the dataKey change is what tells it this is + // a new dataset and makes it reset rather than wait. clearVersion: number explodingMode: number flipStatusMap: Map diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index c37177055a14..4805c487fb35 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -16,6 +16,7 @@ import {navToPath} from '@/constants/fs' import {showConversationInfoPanel, toggleConversationThreadSearch} from '@/chat/conversation/thread-context' import {muteConversation} from '@/chat/conversation/status-actions' import AccountSwitchHeaderAvatar from '@/router-v2/account-switch-header-avatar' +import * as TestIDs from '@/tests/e2e/shared/test-ids' const emptyMeta = Chat.makeConversationMeta() const emptyParticipantInfo = Chat.uiParticipantsToParticipantInfo([]) @@ -245,7 +246,12 @@ const Header = () => { direction="vertical" tooltip={`Search in this chat (${C.shortcutSymbol}F)`} > - + { ) : null return ( - + 0 && state.scrollLength > 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++} ++function updateContentMetricsState(ctx) { ++ var _a3; ++ const { state } = ctx; ++ const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; ++ const nextPadding = getAlignItemsAtEndPadding(ctx); ++ if (previousPadding !== nextPadding) { ++ const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; ++ const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); ++ if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { ++ state.scheduledWork.microtask(() => { ++ if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { ++ set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); ++ } ++ }, "alignItemsAtEndPaddingFallback"); ++ } else if (!isMaintainExpected) { ++ set$(ctx, "alignItemsAtEndPadding", nextPadding); ++ } ++ } + } + +-// src/core/deferredPublicOnScroll.ts +-function withResolvedContentOffset(state, event, resolvedOffset) { +- return { +- ...event, +- nativeEvent: { +- ...event.nativeEvent, +- contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++// src/core/addTotalSize.ts ++function addTotalSize(ctx, key, add, notifyTotalSize = true) { ++ const state = ctx.state; ++ const prevTotalSize = state.totalSize; ++ let totalSize = state.totalSize; ++ if (key === null) { ++ totalSize = add; ++ if (state.timeoutSetPaddingTop) { ++ clearTimeout(state.timeoutSetPaddingTop); ++ state.timeoutSetPaddingTop = void 0; + } +- }; ++ } else { ++ totalSize += add; ++ } ++ if (prevTotalSize !== totalSize) { ++ if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { ++ state.pendingTotalSize = totalSize; ++ } else { ++ state.pendingTotalSize = void 0; ++ state.totalSize = totalSize; ++ if (notifyTotalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } ++ updateContentMetricsState(ctx); ++ } ++ } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } + } +-function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { +- var _a3, _b, _c, _d; ++ ++// src/core/setSize.ts ++function setSize(ctx, itemKey, size, notifyTotalSize = true) { + const state = ctx.state; +- const deferredEvent = state.deferredPublicOnScrollEvent; +- state.deferredPublicOnScrollEvent = void 0; +- if (deferredEvent) { +- (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( +- _c, +- withResolvedContentOffset( +- state, +- deferredEvent, +- (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 +- ) +- ); ++ const { sizes } = state; ++ const previousSize = sizes.get(itemKey); ++ const diff = previousSize !== void 0 ? size - previousSize : size; ++ if (diff !== 0) { ++ addTotalSize(ctx, itemKey, diff, notifyTotalSize); + } ++ sizes.set(itemKey, size); + } + +-// src/core/initialScrollSession.ts +-var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; +-function hasInitialScrollSessionCompletion(completion) { +- return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); ++// src/utils/helpers.ts ++function isFunction(obj) { ++ return typeof obj === "function"; + } +-function clearInitialScrollSession(state) { +- state.initialScrollSession = void 0; +- return void 0; ++function isArray(obj) { ++ return Array.isArray(obj); + } +-function createInitialScrollSession(options) { +- const { bootstrap, completion, kind, previousDataLength } = options; +- return kind === "offset" ? { +- completion, +- kind, +- previousDataLength +- } : { +- bootstrap, +- completion, +- kind, +- previousDataLength +- }; ++var warned = /* @__PURE__ */ new Set(); ++function warnDevOnce(id, text) { ++ if (IS_DEV && !warned.has(id)) { ++ warned.add(id); ++ console.warn(`[legend-list] ${text}`); ++ } + } +-function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { +- var _a4, _b2; +- if (!state.initialScrollSession) { +- state.initialScrollSession = createInitialScrollSession({ +- completion: {}, +- kind, +- previousDataLength: 0 +- }); +- } else if (state.initialScrollSession.kind !== kind) { +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, +- completion: state.initialScrollSession.completion, +- kind, +- previousDataLength: state.initialScrollSession.previousDataLength +- }); ++function roundSize(size) { ++ return Math.floor(size * 8) / 8; ++} ++function isNullOrUndefined(value) { ++ return value === null || value === void 0; ++} ++function getPadding(s, type) { ++ var _a3, _b, _c; ++ const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; ++ return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; ++} ++function extractPadding(style, contentContainerStyle, type) { ++ return getPadding(style, type) + getPadding(contentContainerStyle, type); ++} ++function findContainerId(ctx, key) { ++ var _a3, _b; ++ const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); ++ if (directMatch !== void 0) { ++ return directMatch; + } +- (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; +- return state.initialScrollSession.completion; ++ const numContainers = peek$(ctx, "numContainers"); ++ for (let i = 0; i < numContainers; i++) { ++ const itemKey = peek$(ctx, `containerItemKey${i}`); ++ if (itemKey === key) { ++ return i; ++ } ++ } ++ return -1; + } +-var initialScrollCompletion = { +- didDispatchNativeScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); +- }, +- didRetrySilentInitialScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); +- }, +- markInitialScrollNativeDispatch(state) { +- ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; +- }, +- markSilentInitialScrollRetry(state) { +- ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; +- }, +- resetFlags(state) { +- if (!state.initialScrollSession) { +- return; ++ ++// src/utils/getItemSize.ts ++function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { getFixedItemSize, getItemType } = state.props; ++ let size = key ? state.sizesKnown.get(key) : void 0; ++ if (size === void 0 && key && getFixedItemSize) { ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); ++ if (fixedSize !== void 0) { ++ size = fixedSize + ctx.scrollAxisGap; ++ state.sizesKnown.set(key, size); + } +- const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); +- completion.didDispatchNativeScroll = void 0; +- completion.didRetrySilentInitialScroll = void 0; + } +-}; +-var initialScrollWatchdog = { +- clear(state) { +- initialScrollWatchdog.set(state, void 0); +- }, +- didReachTarget(newScroll, watchdog) { +- const nextDistance = Math.abs(newScroll - watchdog.targetOffset); +- return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- get(state) { +- var _a3, _b; +- return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; +- }, +- hasNonZeroTargetOffset(targetOffset) { +- return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- isAtZeroTargetOffset(targetOffset) { +- return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- set(state, watchdog) { +- var _a3, _b; +- if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { +- return; ++ return size; ++} ++function getKnownOrFixedItemSize(ctx, index) { ++ const key = getId(ctx.state, index); ++ return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++} ++function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { ++ for (let index = startIndex; index <= endIndex; index++) { ++ if (getKnownOrFixedItemSize(ctx, index) === void 0) { ++ return false; + } +- const completion = ensureInitialScrollSessionCompletion(state); +- completion.watchdog = watchdog ? { +- startScroll: watchdog.startScroll, +- targetOffset: watchdog.targetOffset +- } : void 0; + } +-}; +-function setInitialScrollSession(state, options = {}) { ++ return true; ++} ++function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { + var _a3, _b, _c, _d; +- const existingSession = state.initialScrollSession; +- const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; +- const completion = existingSession == null ? void 0 : existingSession.completion; +- const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; +- const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; +- if (!kind) { +- return clearInitialScrollSession(state); ++ const state = ctx.state; ++ const { ++ sizes, ++ averageSizes, ++ props: { estimatedItemSize, getItemType }, ++ scrollingTo ++ } = state; ++ const sizeKnown = state.sizesKnown.get(key); ++ if (sizeKnown !== void 0) { ++ return sizeKnown; + } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); ++ let size; ++ const renderedSize = sizes.get(key); ++ if (preferCachedSize) { ++ if (renderedSize !== void 0) { ++ return renderedSize; ++ } + } +- const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap, +- completion, +- kind, +- previousDataLength +- }); +- return state.initialScrollSession; ++ size = getKnownOrFixedSize(ctx, key, index, data, resolved); ++ if (size !== void 0) { ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++ } ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ if (useAverageSize && !scrollingTo) { ++ const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0 && renderedSize !== void 0) { ++ return renderedSize; ++ } ++ if (size === void 0 && useAverageSize && scrollingTo) { ++ const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0) { ++ size = estimatedItemSize + ctx.scrollAxisGap; ++ } ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++} ++function getItemSizeAtIndex(ctx, index) { ++ if (index === void 0 || index < 0) { ++ return void 0; ++ } ++ const targetId = getId(ctx.state, index); ++ return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++} ++ ++// src/core/calculateOffsetWithOffsetPosition.ts ++function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++ var _a3; ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ let offset = offsetParam; ++ if (viewOffset) { ++ offset -= viewOffset; ++ } ++ if (index !== void 0) { ++ const startOffsetAdjustment = getStartOffsetAdjustment(ctx); ++ if (startOffsetAdjustment) { ++ offset += startOffsetAdjustment; ++ } ++ } ++ if (viewPosition !== void 0 && index !== void 0) { ++ const dataLength = state.props.data.length; ++ if (dataLength === 0) { ++ return offset; ++ } ++ const isOutOfBounds = index < 0 || index >= dataLength; ++ const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; ++ const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); ++ const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); ++ const trailingInset = getContentInsetEnd(ctx); ++ offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); ++ if (!isOutOfBounds && index === state.props.data.length - 1) { ++ const footerSize = peek$(ctx, "footerSize") || 0; ++ offset += footerSize; ++ } ++ } ++ return offset; ++} ++ ++// src/core/clampScrollOffset.ts ++function clampScrollOffset(ctx, offset, scrollTarget) { ++ const state = ctx.state; ++ const contentSize = getContentSize(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 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); ++ return clampedOffset; ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; + } + + // src/utils/checkThreshold.ts +@@ -824,748 +932,760 @@ function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + }, + (snapshot) => { + state.startReachedSnapshot = snapshot; +- } +- ); +- } +-} +- +-// src/utils/checkThresholds.ts +-function checkThresholds(ctx, allowedEdge) { +- const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; +- checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); +- checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); +-} +- +-// src/core/recalculateSettledScroll.ts +-function recalculateSettledScroll(ctx) { +- var _a3, _b; +- const state = ctx.state; +- if ((_a3 = state.props) == null ? void 0 : _a3.data) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); +- } +- checkThresholds(ctx); +-} +- +-// src/core/adaptiveRender.ts +-var DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY = 3; +-var DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY = 1; +-var DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-function clearAdaptiveRenderExitTimeout(ctx) { +- ctx.state.scheduledWork.cancel("adaptiveRender"); +-} +-function scheduleAdaptiveRenderExit(ctx, exitDelay) { +- const state = ctx.state; +- clearAdaptiveRenderExitTimeout(ctx); +- if (exitDelay <= 0) { +- setAdaptiveRender(ctx, "normal", "scroll"); +- } else { +- state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); +- } +-} +-function setAdaptiveRender(ctx, mode, reason) { +- var _a3, _b; +- const previousMode = peek$(ctx, "adaptiveRender"); +- if (previousMode !== mode) { +- set$(ctx, "adaptiveRender", mode); +- (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); ++ } ++ ); + } + } +-function resetAdaptiveRender(ctx) { ++ ++// src/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++} ++ ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { + var _a3, _b; +- clearAdaptiveRenderExitTimeout(ctx); +- const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; +- if (peek$(ctx, "adaptiveRender") !== mode) { +- setAdaptiveRender(ctx, mode, "initial"); ++ const state = ctx.state; ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } ++ checkThresholds(ctx); + } +-function updateAdaptiveRender(ctx, scrollVelocity, options) { +- var _a3, _b, _c; ++ ++// src/core/finishScrollTo.ts ++function finishScrollTo(ctx) { ++ var _a3, _b; + const state = ctx.state; +- const adaptiveRender = state.props.adaptiveRender; +- const currentMode = peek$(ctx, "adaptiveRender"); +- if (peek$(ctx, "readyToRender")) { +- if (adaptiveRender) { +- const isWeb = Platform.OS === "web"; +- const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY : DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY; +- const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY : DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY; +- const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY : DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY; +- const threshold = currentMode === "light" ? exitVelocity : enterVelocity; +- const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; +- const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; +- if (nextMode !== previousMode) { +- if (nextMode === "light") { +- setAdaptiveRender(ctx, "light", "scroll"); +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } else if (currentMode === "light") { +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } +- } +- } else { +- resetAdaptiveRender(ctx); ++ if (state == null ? void 0 : state.scrollingTo) { ++ cancelScrollCompletionChecks(state); ++ const resolvePendingScroll = state.pendingScrollResolve; ++ state.pendingScrollResolve = void 0; ++ const scrollingTo = state.scrollingTo; ++ state.scrollHistory.length = 0; ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ if (state.pendingTotalSize !== void 0) { ++ addTotalSize(ctx, null, state.pendingTotalSize); ++ } ++ if (PlatformAdjustBreaksScroll) { ++ state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ } ++ if (scrollingTo.isInitialScroll || state.initialScroll) { ++ const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; ++ finishInitialScroll(ctx, { ++ onFinished: () => { ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ }, ++ preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, ++ recalculateItems: true, ++ schedulePreservedTargetClear: shouldPreserveResizeTarget, ++ syncObservedOffset: isOffsetSession, ++ waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame ++ }); ++ return; + } ++ recalculateSettledScroll(ctx); ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); + } + } + +-// src/utils/getEffectiveDrawDistance.ts +-var INITIAL_DRAW_DISTANCE = 50; +-function getEffectiveDrawDistance(ctx, mode) { +- var _a3; +- const drawDistance = ctx.state.props.drawDistance; +- const initialScroll = ctx.state.initialScroll; +- const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; +- const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; +- return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; ++// src/core/initialScrollSession.ts ++var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; ++function hasInitialScrollSessionCompletion(completion) { ++ return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); + } +-function scheduleFullDrawDistancePrewarm(ctx) { +- const { state } = ctx; +- if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { +- return; ++function clearInitialScrollSession(state) { ++ state.initialScrollSession = void 0; ++ return void 0; ++} ++function createInitialScrollSession(options) { ++ const { bootstrap, completion, kind, previousDataLength } = options; ++ return kind === "offset" ? { ++ completion, ++ kind, ++ previousDataLength ++ } : { ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }; ++} ++function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { ++ var _a4, _b2; ++ if (!state.initialScrollSession) { ++ state.initialScrollSession = createInitialScrollSession({ ++ completion: {}, ++ kind, ++ previousDataLength: 0 ++ }); ++ } else if (state.initialScrollSession.kind !== kind) { ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, ++ completion: state.initialScrollSession.completion, ++ kind, ++ previousDataLength: state.initialScrollSession.previousDataLength ++ }); + } +- state.scheduledWork.frame(() => { +- var _a3; +- return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); +- }, "fullDrawDistancePrewarm"); ++ (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; ++ return state.initialScrollSession.completion; + } +- +-// src/utils/setInitialRenderState.ts +-function resetInitialRenderState(ctx, { +- resetLayout, +- resetInitialScroll +-}) { +- const { state } = ctx; +- if (resetLayout) { +- state.didContainersLayout = false; +- state.queuedInitialLayout = false; ++var initialScrollCompletion = { ++ didDispatchNativeScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); ++ }, ++ didRetrySilentInitialScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); ++ }, ++ markInitialScrollNativeDispatch(state) { ++ ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; ++ }, ++ markSilentInitialScrollRetry(state) { ++ ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; ++ }, ++ resetFlags(state) { ++ if (!state.initialScrollSession) { ++ return; ++ } ++ const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); ++ completion.didDispatchNativeScroll = void 0; ++ completion.didRetrySilentInitialScroll = void 0; + } +- if (resetInitialScroll) { +- state.didFinishInitialScroll = false; ++}; ++var initialScrollWatchdog = { ++ clear(state) { ++ initialScrollWatchdog.set(state, void 0); ++ }, ++ didReachTarget(newScroll, watchdog) { ++ const nextDistance = Math.abs(newScroll - watchdog.targetOffset); ++ return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ get(state) { ++ var _a3, _b; ++ return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; ++ }, ++ hasNonZeroTargetOffset(targetOffset) { ++ return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ isAtZeroTargetOffset(targetOffset) { ++ return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ set(state, watchdog) { ++ var _a3, _b; ++ if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { ++ return; ++ } ++ const completion = ensureInitialScrollSessionCompletion(state); ++ completion.watchdog = watchdog ? { ++ startScroll: watchdog.startScroll, ++ targetOffset: watchdog.targetOffset ++ } : void 0; + } +- set$(ctx, "readyToRender", false); +- resetAdaptiveRender(ctx); +-} +-function setInitialRenderState(ctx, { +- didLayout, +- didInitialScroll +-}) { +- const { state } = ctx; +- const { +- loadStartTime, +- props: { onLoad } +- } = state; +- if (didLayout) { +- state.didContainersLayout = true; ++}; ++function setInitialScrollSession(state, options = {}) { ++ var _a3, _b, _c, _d; ++ const existingSession = state.initialScrollSession; ++ const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; ++ const completion = existingSession == null ? void 0 : existingSession.completion; ++ const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; ++ const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; ++ if (!kind) { ++ return clearInitialScrollSession(state); + } +- if (didInitialScroll) { +- state.didFinishInitialScroll = true; ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); +- if (isReadyToRender && !peek$(ctx, "readyToRender")) { +- set$(ctx, "readyToRender", true); +- setAdaptiveRender(ctx, "normal", "ready"); +- if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { +- scheduleFullDrawDistancePrewarm(ctx); +- } +- if (!state.didLoad) { +- state.didLoad = true; +- if (onLoad) { +- onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); +- } ++ const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }); ++ return state.initialScrollSession; ++} ++ ++// src/core/checkFinishedScroll.ts ++var INITIAL_SCROLL_MAX_FALLBACK_CHECKS = 20; ++var INITIAL_SCROLL_COMPLETION_TARGET_EPSILON = 1; ++var INITIAL_SCROLL_ZERO_TARGET_EPSILON = 1; ++var SILENT_INITIAL_SCROLL_RETRY_DELAY_MS = 16; ++var SILENT_INITIAL_SCROLL_TARGET_EPSILON = 1; ++function checkFinishedScroll(ctx, options) { ++ const scrollingTo = ctx.state.scrollingTo; ++ if (options == null ? void 0 : options.onlyIfAligned) { ++ if (!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ if (!getResolvedScrollCompletionState(ctx, scrollingTo).isAtResolvedTarget) { ++ return; + } + } ++ ctx.state.scheduledWork.frame(() => checkFinishedScrollFrame(ctx), "checkFinishedScrollFrame"); + } +- +-// src/core/finishInitialScroll.ts +-var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; +-function syncInitialScrollOffset(state, offset) { +- state.scroll = offset; +- state.scrollPending = offset; +- state.scrollPrev = offset; ++function hasScrollCompletionOwnership(state, options) { ++ const { clampedTargetOffset, scrollingTo } = options; ++ return !scrollingTo.isInitialScroll || state.hasScrolled || clampedTargetOffset <= INITIAL_SCROLL_COMPLETION_TARGET_EPSILON; + } +-function clearPreservedInitialScrollTargetTimeout(state) { +- state.scheduledWork.cancel("preservedInitialScroll"); ++function isSilentInitialDispatch(state, scrollingTo) { ++ return !!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled; + } +-function clearPreservedInitialScrollTarget(state) { +- clearPreservedInitialScrollTargetTimeout(state); +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- state.initialScroll = void 0; +- setInitialScrollSession(state); ++function getInitialScrollWatchdogTargetOffset(state) { ++ var _a3; ++ return (_a3 = initialScrollWatchdog.get(state)) == null ? void 0 : _a3.targetOffset; + } +-function supersedeInitialScroll(ctx) { +- var _a3, _b, _c; +- const state = ctx.state; +- const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; +- if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { +- if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { +- cancelAnimationFrame(bootstrapInitialScroll.frameHandle); +- } +- if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { +- cancelScrollCompletionChecks(state); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- } +- initialScrollCompletion.resetFlags(state); +- setInitialScrollSession(state, { bootstrap: null }); +- finishInitialScroll(ctx); +- } ++function isNativeInitialNonZeroTarget(state) { ++ const targetOffset = getInitialScrollWatchdogTargetOffset(state); ++ return !state.didFinishInitialScroll && initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset); + } +-function finishInitialScroll(ctx, options) { +- var _a3, _b, _c; +- const state = ctx.state; +- if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { +- syncInitialScrollOffset(state, options.resolvedOffset); +- } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { +- const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); +- if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { +- syncInitialScrollOffset(state, observedOffset); +- } ++function shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) { ++ var _a3, _b; ++ if (!scrollingTo.isInitialScroll || scrollingTo.animated || !state.didContainersLayout) { ++ return false; + } +- const complete = () => { +- var _a4, _b2, _c2, _d, _e; +- const shouldReleaseDeferredPublicOnScroll = Platform.OS === "web" && ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; +- const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; +- initialScrollWatchdog.clear(state); +- if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- setInitialScrollSession(state); +- clearPreservedInitialScrollTargetTimeout(state); +- if (options == null ? void 0 : options.schedulePreservedTargetClear) { +- state.scheduledWork.timeout( +- () => { +- var _a5; +- if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { +- return; +- } +- clearPreservedInitialScrollTarget(state); +- }, +- PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, +- "preservedInitialScroll" +- ); +- } +- } else { +- clearPreservedInitialScrollTarget(state); +- } +- if (options == null ? void 0 : options.recalculateItems) { +- recalculateSettledScroll(ctx); +- } +- setInitialRenderState(ctx, { didInitialScroll: true }); +- if (shouldReleaseDeferredPublicOnScroll) { +- releaseDeferredPublicOnScroll(ctx, finalScrollOffset); +- } +- (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); +- }; +- if (options == null ? void 0 : options.waitForCompletionFrame) { +- requestAnimationFrame(complete); +- return; ++ if (((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap") { ++ return false; + } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; ++ const targetOffset = (_b = scrollingTo.targetOffset) != null ? _b : scrollingTo.offset; ++ if (initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled) { ++ return false; ++ } ++ if (initialScrollWatchdog.isAtZeroTargetOffset(targetOffset) || Math.abs(state.scroll - targetOffset) > 1 || Math.abs(state.scrollPending - targetOffset) > 1) { ++ return false; ++ } ++ return !!scrollingTo.waitForInitialScrollCompletionFrame || isNativeInitialNonZeroTarget(state); + } +- +-// src/core/getStartOffsetAdjustment.ts +-function getStartOffsetAdjustment(ctx) { ++function shouldFinishInitialZeroTargetScroll(ctx) { ++ var _a3; + const { state } = ctx; +- const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; +- return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); ++ return !!((_a3 = state.scrollingTo) == null ? void 0 : _a3.isInitialScroll) && state.props.data.length > 0 && getContentSize(ctx) <= state.scrollLength && state.scrollPending <= INITIAL_SCROLL_ZERO_TARGET_EPSILON; + } +- +-// src/utils/getId.ts +-function getId(state, index) { +- const { data, keyExtractor } = state.props; +- if (!data) { +- return ""; +- } +- const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; +- const id = ret; +- state.idCache[index] = id; +- return id; ++function isEndAlignedLastItemTarget(ctx, scrollingTo) { ++ return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; + } +- +-// src/core/updateContentMetricsState.ts +-function getRawContentLength(ctx) { +- var _a3, _b, _c; +- const { state, values } = ctx; +- return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++function getCurrentTargetOffset(ctx, scrollingTo) { ++ var _a3; ++ const index = scrollingTo.index; ++ const shouldRecomputeEndTarget = isEndAlignedLastItemTarget(ctx, scrollingTo); ++ const requestedTargetOffset = shouldRecomputeEndTarget && index !== void 0 ? calculateOffsetWithOffsetPosition(ctx, calculateOffsetForIndex(ctx, index), scrollingTo) : (_a3 = scrollingTo.targetOffset) != null ? _a3 : clampScrollOffset(ctx, scrollingTo.offset - (scrollingTo.viewOffset || 0), scrollingTo); ++ return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); + } +-function getAlignItemsAtEndPadding(ctx) { ++function getResolvedScrollCompletionState(ctx, scrollingTo) { + const { state } = ctx; +- const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++ const scroll = state.scrollPending; ++ const adjust = state.scrollAdjustHandler.getAdjust(); ++ const clampedTargetOffset = getCurrentTargetOffset(ctx, scrollingTo); ++ const maxOffset = clampScrollOffset(ctx, scroll, scrollingTo); ++ const diff1 = Math.abs(scroll - clampedTargetOffset); ++ const adjustedTargetOffset = clampedTargetOffset + adjust; ++ const diff2 = Math.abs(scroll - adjustedTargetOffset); ++ const canUseAdjustedCompletion = !scrollingTo.animated || Platform.OS === "ios"; ++ return { ++ clampedTargetOffset, ++ isAtResolvedTarget: Math.abs(scroll - maxOffset) < 1 && (diff1 < 1 || canUseAdjustedCompletion && diff2 < 1) ++ }; + } +-function updateContentMetricsState(ctx) { +- var _a3; ++function checkFinishedScrollFrame(ctx) { ++ const scrollingTo = ctx.state.scrollingTo; ++ if (!scrollingTo) { ++ return; ++ } + const { state } = ctx; +- const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +- const nextPadding = getAlignItemsAtEndPadding(ctx); +- if (previousPadding !== nextPadding) { +- const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; +- const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); +- if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { +- state.scheduledWork.microtask(() => { +- if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { +- set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); +- } +- }, "alignItemsAtEndPaddingFallback"); +- } else if (!isMaintainExpected) { +- set$(ctx, "alignItemsAtEndPadding", nextPadding); +- } ++ const completionState = getResolvedScrollCompletionState(ctx, scrollingTo); ++ if (completionState.isAtResolvedTarget && hasScrollCompletionOwnership(state, { ++ clampedTargetOffset: completionState.clampedTargetOffset, ++ scrollingTo ++ })) { ++ finishScrollTo(ctx); + } + } +- +-// src/core/addTotalSize.ts +-function addTotalSize(ctx, key, add, notifyTotalSize = true) { ++function scrollToFallbackOffset(ctx, offset) { ++ var _a3; ++ (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ ++ animated: false, ++ x: ctx.state.props.horizontal ? offset : 0, ++ y: ctx.state.props.horizontal ? 0 : offset ++ }); ++} ++function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +- const prevTotalSize = state.totalSize; +- let totalSize = state.totalSize; +- if (key === null) { +- totalSize = add; +- if (state.timeoutSetPaddingTop) { +- clearTimeout(state.timeoutSetPaddingTop); +- state.timeoutSetPaddingTop = void 0; +- } +- } else { +- totalSize += add; ++ if (state.scheduledWork.has("checkFinishedScrollFallback")) { ++ return; + } +- if (prevTotalSize !== totalSize) { +- if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { +- state.pendingTotalSize = totalSize; +- } else { +- state.pendingTotalSize = void 0; +- state.totalSize = totalSize; +- if (notifyTotalSize) { +- set$(ctx, "totalSize", totalSize); ++ const scrollingTo = state.scrollingTo; ++ const shouldFinishInitialZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); ++ const silentInitialDispatch = isSilentInitialDispatch(state, scrollingTo); ++ const canFinishInitialWithoutNativeProgress = scrollingTo !== void 0 ? shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) : false; ++ const slowTimeout = (scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && !shouldFinishInitialZeroTarget && !canFinishInitialWithoutNativeProgress || !state.didContainersLayout; ++ const initialDelay = shouldFinishInitialZeroTarget || canFinishInitialWithoutNativeProgress ? 0 : silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : slowTimeout ? 500 : 100; ++ let numChecks = 0; ++ const scheduleFallbackCheck = (delay) => { ++ state.scheduledWork.timeout(checkHasScrolled, delay, "checkFinishedScrollFallback"); ++ }; ++ const checkHasScrolled = () => { ++ var _a3, _b, _c, _d; ++ const isStillScrollingTo = state.scrollingTo; ++ if (isStillScrollingTo) { ++ numChecks++; ++ const isNativeInitialPending = isNativeInitialNonZeroTarget(state) && !state.hasScrolled; ++ const maxChecks = silentInitialDispatch ? 5 : isNativeInitialPending ? INITIAL_SCROLL_MAX_FALLBACK_CHECKS : 5; ++ const shouldFinishZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); ++ const canFinishInitialScrollWithoutNativeProgress = shouldFinishInitialScrollWithoutNativeProgress( ++ state, ++ isStillScrollingTo ++ ); ++ const completionState = getResolvedScrollCompletionState(ctx, isStillScrollingTo); ++ const canFinishAfterSilentNativeDispatch = Platform.OS === "android" && silentInitialDispatch && completionState.isAtResolvedTarget && numChecks >= 1; ++ const shouldRetrySilentInitialNativeScroll = Platform.OS === "android" && canFinishAfterSilentNativeDispatch && !initialScrollCompletion.didRetrySilentInitialScroll(state); ++ const shouldFinishAfterObservedScroll = state.hasScrolled && (!isStillScrollingTo.isInitialScroll || completionState.isAtResolvedTarget); ++ const shouldRetryUnalignedInitialScroll = isStillScrollingTo.isInitialScroll && !completionState.isAtResolvedTarget && numChecks <= maxChecks; ++ const shouldRetryUnalignedEndScroll = Platform.OS === "ios" && !isStillScrollingTo.isInitialScroll && isEndAlignedLastItemTarget(ctx, isStillScrollingTo) && !completionState.isAtResolvedTarget && numChecks <= maxChecks; ++ if (shouldRetrySilentInitialNativeScroll) { ++ const targetOffset = (_b = (_a3 = getInitialScrollWatchdogTargetOffset(state)) != null ? _a3 : isStillScrollingTo.targetOffset) != null ? _b : 0; ++ const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; ++ initialScrollCompletion.markSilentInitialScrollRetry(state); ++ scrollToFallbackOffset(ctx, jiggleOffset); ++ state.scheduledWork.frame( ++ () => scrollToFallbackOffset(ctx, targetOffset), ++ "checkFinishedScrollRetryFrame" ++ ); ++ scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); ++ } else if (shouldRetryUnalignedEndScroll) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ scheduleFallbackCheck(100); ++ } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { ++ finishScrollTo(ctx); ++ } else if ((isNativeInitialPending || shouldRetryUnalignedInitialScroll) && numChecks <= maxChecks) { ++ const targetOffset = (_d = (_c = getInitialScrollWatchdogTargetOffset(state)) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : state.scrollPending; ++ scrollToFallbackOffset(ctx, targetOffset); ++ scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); ++ } else { ++ scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); + } +- updateContentMetricsState(ctx); + } +- } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { +- set$(ctx, "totalSize", totalSize); +- } ++ }; ++ scheduleFallbackCheck(initialDelay); + } + +-// src/core/setSize.ts +-function setSize(ctx, itemKey, size, notifyTotalSize = true) { ++// src/core/doScrollTo.native.ts ++function doScrollTo(ctx, params) { + const state = ctx.state; +- const { sizes } = state; +- const previousSize = sizes.get(itemKey); +- const diff = previousSize !== void 0 ? size - previousSize : size; +- if (diff !== 0) { +- addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ const { animated, horizontal, isInitialScroll, offset } = params; ++ const isAnimated = !!animated; ++ const { refScroller } = state; ++ const scroller = refScroller.current; ++ if (!scroller) { ++ return; + } +- sizes.set(itemKey, size); +-} +- +-// src/utils/helpers.ts +-function isFunction(obj) { +- return typeof obj === "function"; +-} +-function isArray(obj) { +- return Array.isArray(obj); +-} +-var warned = /* @__PURE__ */ new Set(); +-function warnDevOnce(id, text) { +- if (IS_DEV && !warned.has(id)) { +- warned.add(id); +- console.warn(`[legend-list] ${text}`); ++ const isHorizontal = !!horizontal; ++ const contentSize = isHorizontal ? getContentSize(ctx) : void 0; ++ const nativeOffset = toNativeHorizontalOffset(state, offset, contentSize); ++ scroller.scrollTo({ ++ animated: isAnimated, ++ x: isHorizontal ? nativeOffset : 0, ++ y: isHorizontal ? 0 : offset ++ }); ++ if (isInitialScroll) { ++ initialScrollCompletion.markInitialScrollNativeDispatch(state); + } +-} +-function roundSize(size) { +- return Math.floor(size * 8) / 8; +-} +-function isNullOrUndefined(value) { +- return value === null || value === void 0; +-} +-function getPadding(s, type) { +- var _a3, _b, _c; +- const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; +- return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; +-} +-function extractPadding(style, contentContainerStyle, type) { +- return getPadding(style, type) + getPadding(contentContainerStyle, type); +-} +-function findContainerId(ctx, key) { +- var _a3, _b; +- const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); +- if (directMatch !== void 0) { +- return directMatch; ++ if (isAnimated && Math.abs(state.scroll - offset) <= 1) { ++ checkFinishedScroll(ctx); + } +- const numContainers = peek$(ctx, "numContainers"); +- for (let i = 0; i < numContainers; i++) { +- const itemKey = peek$(ctx, `containerItemKey${i}`); +- if (itemKey === key) { +- return i; +- } ++ if (!isAnimated) { ++ state.scroll = offset; ++ checkFinishedScrollFallback(ctx); + } +- return -1; + } + +-// src/utils/getItemSize.ts +-function getKnownOrFixedSize(ctx, key, index, data, resolved) { +- var _a3, _b; ++// src/utils/requestAdjust.ts ++function requestAdjust(ctx, positionDiff, source) { + const state = ctx.state; +- const { getFixedItemSize, getItemType } = state.props; +- let size = key ? state.sizesKnown.get(key) : void 0; +- if (size === void 0 && key && getFixedItemSize) { +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); +- if (fixedSize !== void 0) { +- size = fixedSize + ctx.scrollAxisGap; +- state.sizesKnown.set(key, size); +- } +- } +- return size; +-} +-function getKnownOrFixedItemSize(ctx, index) { +- const key = getId(ctx.state, index); +- return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); +-} +-function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { +- for (let index = startIndex; index <= endIndex; index++) { +- if (getKnownOrFixedItemSize(ctx, index) === void 0) { +- return false; ++ if (Math.abs(positionDiff) > 0.1) { ++ const dataChanged = source === "data"; ++ const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; ++ const doit = () => { ++ if (needsScrollWorkaround) { ++ doScrollTo(ctx, { horizontal: state.props.horizontal, offset: state.scroll }); ++ } else { ++ state.scrollAdjustHandler.requestAdjust(positionDiff); ++ if (state.adjustingFromInitialMount) { ++ state.adjustingFromInitialMount--; ++ } ++ } ++ }; ++ state.scroll += positionDiff; ++ state.scrollForNextCalculateItemsInView = void 0; ++ const readyToRender = peek$(ctx, "readyToRender"); ++ if (readyToRender) { ++ doit(); ++ if (Platform.OS !== "web" && source !== "item-size") { ++ const threshold = state.scroll - positionDiff / 2; ++ if (!state.ignoreScrollFromMVCP) { ++ state.ignoreScrollFromMVCP = {}; ++ } ++ if (positionDiff > 0) { ++ state.ignoreScrollFromMVCP.lt = threshold; ++ } else { ++ state.ignoreScrollFromMVCP.gt = threshold; ++ } ++ const delay = needsScrollWorkaround ? 250 : 100; ++ state.scheduledWork.timeout( ++ () => { ++ var _a3; ++ state.ignoreScrollFromMVCP = void 0; ++ const shouldForceUpdate = state.ignoreScrollFromMVCPIgnored && state.scrollProcessingEnabled !== false; ++ if (shouldForceUpdate) { ++ state.ignoreScrollFromMVCPIgnored = false; ++ state.scrollPending = state.scroll; ++ (_a3 = state.reprocessCurrentScroll) == null ? void 0 : _a3.call(state); ++ } ++ }, ++ delay, ++ "ignoreScrollFromMVCP" ++ ); ++ } ++ } else { ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ requestAnimationFrame(doit); + } + } +- return true; + } +-function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { +- var _a3, _b, _c, _d; ++ ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 0.5; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_TTL_MS = 500; ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++} ++function beginScrollTargetSettle(ctx, params) { + const state = ctx.state; +- const { +- sizes, +- averageSizes, +- props: { estimatedItemSize, getItemType }, +- scrollingTo +- } = state; +- const sizeKnown = state.sizesKnown.get(key); +- if (sizeKnown !== void 0) { +- return sizeKnown; +- } +- let size; +- const renderedSize = sizes.get(key); +- if (preferCachedSize) { +- if (renderedSize !== void 0) { +- return renderedSize; +- } +- } +- size = getKnownOrFixedSize(ctx, key, index, data, resolved); +- if (size !== void 0) { +- setSize(ctx, key, size, notifyTotalSize); +- return size; +- } +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { +- const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0 && renderedSize !== void 0) { +- return renderedSize; +- } +- if (size === void 0 && useAverageSize && scrollingTo) { +- const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0) { +- size = estimatedItemSize + ctx.scrollAxisGap; +- } +- setSize(ctx, key, size, notifyTotalSize); +- return size; +-} +-function getItemSizeAtIndex(ctx, index) { +- if (index === void 0 || index < 0) { +- return void 0; ++ const { index, viewOffset, viewPosition } = params; ++ const { data } = state.props; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem) { ++ clearScrollTargetSettle(state); ++ return; + } +- const targetId = getId(ctx.state, index); +- return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++ state.scrollTargetSettle = { ++ expiresAt: Date.now() + SETTLE_TTL_MS, ++ id: getId(state, index), ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; + } +- +-// src/core/calculateOffsetWithOffsetPosition.ts +-function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++function settleScrollTarget(ctx) { + var _a3; + const state = ctx.state; +- const { index, viewOffset, viewPosition } = params; +- let offset = offsetParam; +- if (viewOffset) { +- offset -= viewOffset; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; + } +- if (index !== void 0) { +- const startOffsetAdjustment = getStartOffsetAdjustment(ctx); +- if (startOffsetAdjustment) { +- offset += startOffsetAdjustment; +- } ++ const index = state.indexByKey.get(settle.id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0) { ++ clearScrollTargetSettle(state); ++ return false; + } +- if (viewPosition !== void 0 && index !== void 0) { +- const dataLength = state.props.data.length; +- if (dataLength === 0) { +- return offset; +- } +- const isOutOfBounds = index < 0 || index >= dataLength; +- const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; +- const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); +- const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); +- const trailingInset = getContentInsetEnd(ctx); +- offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); +- if (!isOutOfBounds && index === state.props.data.length - 1) { +- const footerSize = peek$(ctx, "footerSize") || 0; +- offset += footerSize; ++ const now = Date.now(); ++ if (now > settle.expiresAt) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ const diff = targetOffset - state.scroll; ++ if (Math.abs(diff) <= SETTLE_POSITION_EPSILON) { ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); + } ++ return false; + } +- return offset; ++ settle.quietPasses = 0; ++ settle.expiresAt = now + SETTLE_TTL_MS; ++ if (((_a3 = state.scrollingTo) == null ? void 0 : _a3.index) === index) { ++ state.scrollingTo.offset = position; ++ state.scrollingTo.targetOffset = targetOffset; ++ } ++ requestAdjust(ctx, diff); ++ return true; + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/cancelImperativeScroll.ts ++function cancelScrollCompletionChecks({ scheduledWork }) { ++ scheduledWork.cancel("checkFinishedScrollFrame"); ++ scheduledWork.cancel("checkFinishedScrollRetryFrame"); ++ scheduledWork.cancel("checkFinishedScrollFallback"); ++ scheduledWork.cancel("platformScrollCompletion"); ++} ++function settlePendingImperativeScroll(state) { ++ var _a3, _b; ++ const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; ++ state.pendingScrollResolve = void 0; ++ state.pendingScrollToEnd = void 0; ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++} ++function cancelImperativeScroll(state) { ++ cancelScrollCompletionChecks(state); ++ state.scheduledWork.cancel("imperativeScrollReady"); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ clearScrollTargetSettle(state); ++ settlePendingImperativeScroll(state); ++} ++ ++// src/core/deferredPublicOnScroll.ts ++function withResolvedContentOffset(state, event, resolvedOffset) { ++ return { ++ ...event, ++ nativeEvent: { ++ ...event.nativeEvent, ++ contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++ } ++ }; ++} ++function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { ++ var _a3, _b, _c, _d; + const state = ctx.state; +- const contentSize = getContentSize(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 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); ++ const deferredEvent = state.deferredPublicOnScrollEvent; ++ state.deferredPublicOnScrollEvent = void 0; ++ if (deferredEvent) { ++ (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( ++ _c, ++ withResolvedContentOffset( ++ state, ++ deferredEvent, ++ (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 ++ ) ++ ); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; + } + +-// src/core/finishScrollTo.ts +-function finishScrollTo(ctx) { +- var _a3, _b; ++// src/core/adaptiveRender.ts ++var DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY = 3; ++var DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY = 1; ++var DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++function clearAdaptiveRenderExitTimeout(ctx) { ++ ctx.state.scheduledWork.cancel("adaptiveRender"); ++} ++function scheduleAdaptiveRenderExit(ctx, exitDelay) { + const state = ctx.state; +- if (state == null ? void 0 : state.scrollingTo) { +- cancelScrollCompletionChecks(state); +- const resolvePendingScroll = state.pendingScrollResolve; +- state.pendingScrollResolve = void 0; +- const scrollingTo = state.scrollingTo; +- state.scrollHistory.length = 0; +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- if (state.pendingTotalSize !== void 0) { +- addTotalSize(ctx, null, state.pendingTotalSize); +- } +- if (PlatformAdjustBreaksScroll) { +- state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); +- } +- if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; +- finishInitialScroll(ctx, { +- onFinished: () => { +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); +- }, +- preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +- recalculateItems: true, +- schedulePreservedTargetClear: shouldPreserveResizeTarget, +- syncObservedOffset: isOffsetSession, +- waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame +- }); +- return; +- } +- recalculateSettledScroll(ctx); +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ clearAdaptiveRenderExitTimeout(ctx); ++ if (exitDelay <= 0) { ++ setAdaptiveRender(ctx, "normal", "scroll"); ++ } else { ++ state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); + } + } +- +-// src/core/checkFinishedScroll.ts +-var INITIAL_SCROLL_MAX_FALLBACK_CHECKS = 20; +-var INITIAL_SCROLL_COMPLETION_TARGET_EPSILON = 1; +-var INITIAL_SCROLL_ZERO_TARGET_EPSILON = 1; +-var SILENT_INITIAL_SCROLL_RETRY_DELAY_MS = 16; +-var SILENT_INITIAL_SCROLL_TARGET_EPSILON = 1; +-function checkFinishedScroll(ctx, options) { +- const scrollingTo = ctx.state.scrollingTo; +- if (options == null ? void 0 : options.onlyIfAligned) { +- if (!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { +- return; +- } +- if (!getResolvedScrollCompletionState(ctx, scrollingTo).isAtResolvedTarget) { +- return; +- } ++function setAdaptiveRender(ctx, mode, reason) { ++ var _a3, _b; ++ const previousMode = peek$(ctx, "adaptiveRender"); ++ if (previousMode !== mode) { ++ set$(ctx, "adaptiveRender", mode); ++ (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); + } +- ctx.state.scheduledWork.frame(() => checkFinishedScrollFrame(ctx), "checkFinishedScrollFrame"); + } +-function hasScrollCompletionOwnership(state, options) { +- const { clampedTargetOffset, scrollingTo } = options; +- return !scrollingTo.isInitialScroll || state.hasScrolled || clampedTargetOffset <= INITIAL_SCROLL_COMPLETION_TARGET_EPSILON; ++function resetAdaptiveRender(ctx) { ++ var _a3, _b; ++ clearAdaptiveRenderExitTimeout(ctx); ++ const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; ++ if (peek$(ctx, "adaptiveRender") !== mode) { ++ setAdaptiveRender(ctx, mode, "initial"); ++ } + } +-function isSilentInitialDispatch(state, scrollingTo) { +- return !!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled; ++function updateAdaptiveRender(ctx, scrollVelocity, options) { ++ var _a3, _b, _c; ++ const state = ctx.state; ++ const adaptiveRender = state.props.adaptiveRender; ++ const currentMode = peek$(ctx, "adaptiveRender"); ++ if (peek$(ctx, "readyToRender")) { ++ if (adaptiveRender) { ++ const isWeb = Platform.OS === "web"; ++ const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY : DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY; ++ const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY : DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY; ++ const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY : DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY; ++ const threshold = currentMode === "light" ? exitVelocity : enterVelocity; ++ const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; ++ const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; ++ if (nextMode !== previousMode) { ++ if (nextMode === "light") { ++ setAdaptiveRender(ctx, "light", "scroll"); ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } else if (currentMode === "light") { ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } ++ } ++ } else { ++ resetAdaptiveRender(ctx); ++ } ++ } + } +-function getInitialScrollWatchdogTargetOffset(state) { ++ ++// src/utils/getEffectiveDrawDistance.ts ++var INITIAL_DRAW_DISTANCE = 50; ++function getEffectiveDrawDistance(ctx, mode) { + var _a3; +- return (_a3 = initialScrollWatchdog.get(state)) == null ? void 0 : _a3.targetOffset; +-} +-function isNativeInitialNonZeroTarget(state) { +- const targetOffset = getInitialScrollWatchdogTargetOffset(state); +- return !state.didFinishInitialScroll && initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset); ++ const drawDistance = ctx.state.props.drawDistance; ++ const initialScroll = ctx.state.initialScroll; ++ const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; ++ const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; ++ return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; + } +-function shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) { +- var _a3, _b; +- if (!scrollingTo.isInitialScroll || scrollingTo.animated || !state.didContainersLayout) { +- return false; +- } +- if (((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap") { +- return false; ++function scheduleFullDrawDistancePrewarm(ctx) { ++ const { state } = ctx; ++ if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++ return; + } +- const targetOffset = (_b = scrollingTo.targetOffset) != null ? _b : scrollingTo.offset; +- if (initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled) { +- return false; ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ ++// src/utils/setInitialRenderState.ts ++function resetInitialRenderState(ctx, { ++ resetLayout, ++ resetInitialScroll ++}) { ++ const { state } = ctx; ++ if (resetLayout) { ++ state.didContainersLayout = false; ++ state.queuedInitialLayout = false; + } +- if (initialScrollWatchdog.isAtZeroTargetOffset(targetOffset) || Math.abs(state.scroll - targetOffset) > 1 || Math.abs(state.scrollPending - targetOffset) > 1) { +- return false; ++ if (resetInitialScroll) { ++ state.didFinishInitialScroll = false; + } +- return !!scrollingTo.waitForInitialScrollCompletionFrame || isNativeInitialNonZeroTarget(state); ++ set$(ctx, "readyToRender", false); ++ resetAdaptiveRender(ctx); + } +-function shouldFinishInitialZeroTargetScroll(ctx) { +- var _a3; ++function setInitialRenderState(ctx, { ++ didLayout, ++ didInitialScroll ++}) { + const { state } = ctx; +- return !!((_a3 = state.scrollingTo) == null ? void 0 : _a3.isInitialScroll) && state.props.data.length > 0 && getContentSize(ctx) <= state.scrollLength && state.scrollPending <= INITIAL_SCROLL_ZERO_TARGET_EPSILON; +-} +-function isEndAlignedLastItemTarget(ctx, scrollingTo) { +- return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; ++ const { ++ loadStartTime, ++ props: { onLoad } ++ } = state; ++ if (didLayout) { ++ state.didContainersLayout = true; ++ } ++ if (didInitialScroll) { ++ state.didFinishInitialScroll = true; ++ } ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ if (isReadyToRender && !peek$(ctx, "readyToRender")) { ++ set$(ctx, "readyToRender", true); ++ setAdaptiveRender(ctx, "normal", "ready"); ++ if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { ++ scheduleFullDrawDistancePrewarm(ctx); ++ } ++ if (!state.didLoad) { ++ state.didLoad = true; ++ if (onLoad) { ++ onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); ++ } ++ } ++ } + } +-function getCurrentTargetOffset(ctx, scrollingTo) { +- var _a3; +- const index = scrollingTo.index; +- const shouldRecomputeEndTarget = isEndAlignedLastItemTarget(ctx, scrollingTo); +- const requestedTargetOffset = shouldRecomputeEndTarget && index !== void 0 ? calculateOffsetWithOffsetPosition(ctx, calculateOffsetForIndex(ctx, index), scrollingTo) : (_a3 = scrollingTo.targetOffset) != null ? _a3 : clampScrollOffset(ctx, scrollingTo.offset - (scrollingTo.viewOffset || 0), scrollingTo); +- return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); ++ ++// src/core/finishInitialScroll.ts ++var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; ++function syncInitialScrollOffset(state, offset) { ++ state.scroll = offset; ++ state.scrollPending = offset; ++ state.scrollPrev = offset; + } +-function getResolvedScrollCompletionState(ctx, scrollingTo) { +- const { state } = ctx; +- const scroll = state.scrollPending; +- const adjust = state.scrollAdjustHandler.getAdjust(); +- const clampedTargetOffset = getCurrentTargetOffset(ctx, scrollingTo); +- const maxOffset = clampScrollOffset(ctx, scroll, scrollingTo); +- const diff1 = Math.abs(scroll - clampedTargetOffset); +- const adjustedTargetOffset = clampedTargetOffset + adjust; +- const diff2 = Math.abs(scroll - adjustedTargetOffset); +- const canUseAdjustedCompletion = !scrollingTo.animated || Platform.OS === "ios"; +- return { +- clampedTargetOffset, +- isAtResolvedTarget: Math.abs(scroll - maxOffset) < 1 && (diff1 < 1 || canUseAdjustedCompletion && diff2 < 1) +- }; ++function clearPreservedInitialScrollTargetTimeout(state) { ++ state.scheduledWork.cancel("preservedInitialScroll"); + } +-function checkFinishedScrollFrame(ctx) { +- const scrollingTo = ctx.state.scrollingTo; +- if (!scrollingTo) { +- return; +- } +- const { state } = ctx; +- const completionState = getResolvedScrollCompletionState(ctx, scrollingTo); +- if (completionState.isAtResolvedTarget && hasScrollCompletionOwnership(state, { +- clampedTargetOffset: completionState.clampedTargetOffset, +- scrollingTo +- })) { +- finishScrollTo(ctx); +- } ++function clearPreservedInitialScrollTarget(state) { ++ clearPreservedInitialScrollTargetTimeout(state); ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ state.initialScroll = void 0; ++ setInitialScrollSession(state); + } +-function scrollToFallbackOffset(ctx, offset) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, +- x: ctx.state.props.horizontal ? offset : 0, +- y: ctx.state.props.horizontal ? 0 : offset +- }); ++function supersedeInitialScroll(ctx) { ++ var _a3, _b, _c; ++ const state = ctx.state; ++ const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; ++ if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { ++ if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { ++ cancelAnimationFrame(bootstrapInitialScroll.frameHandle); ++ } ++ if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { ++ cancelScrollCompletionChecks(state); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ } ++ initialScrollCompletion.resetFlags(state); ++ setInitialScrollSession(state, { bootstrap: null }); ++ finishInitialScroll(ctx); ++ } + } +-function checkFinishedScrollFallback(ctx) { ++function finishInitialScroll(ctx, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- if (state.scheduledWork.has("checkFinishedScrollFallback")) { +- return; ++ if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { ++ syncInitialScrollOffset(state, options.resolvedOffset); ++ } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { ++ const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); ++ if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { ++ syncInitialScrollOffset(state, observedOffset); ++ } + } +- const scrollingTo = state.scrollingTo; +- const shouldFinishInitialZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); +- const silentInitialDispatch = isSilentInitialDispatch(state, scrollingTo); +- const canFinishInitialWithoutNativeProgress = scrollingTo !== void 0 ? shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) : false; +- const slowTimeout = (scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && !shouldFinishInitialZeroTarget && !canFinishInitialWithoutNativeProgress || !state.didContainersLayout; +- const initialDelay = shouldFinishInitialZeroTarget || canFinishInitialWithoutNativeProgress ? 0 : silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : slowTimeout ? 500 : 100; +- let numChecks = 0; +- const scheduleFallbackCheck = (delay) => { +- state.scheduledWork.timeout(checkHasScrolled, delay, "checkFinishedScrollFallback"); +- }; +- const checkHasScrolled = () => { +- var _a3, _b, _c, _d; +- const isStillScrollingTo = state.scrollingTo; +- if (isStillScrollingTo) { +- numChecks++; +- const isNativeInitialPending = isNativeInitialNonZeroTarget(state) && !state.hasScrolled; +- const maxChecks = silentInitialDispatch ? 5 : isNativeInitialPending ? INITIAL_SCROLL_MAX_FALLBACK_CHECKS : 5; +- const shouldFinishZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); +- const canFinishInitialScrollWithoutNativeProgress = shouldFinishInitialScrollWithoutNativeProgress( +- state, +- isStillScrollingTo +- ); +- const completionState = getResolvedScrollCompletionState(ctx, isStillScrollingTo); +- const canFinishAfterSilentNativeDispatch = Platform.OS === "android" && silentInitialDispatch && completionState.isAtResolvedTarget && numChecks >= 1; +- const shouldRetrySilentInitialNativeScroll = Platform.OS === "android" && canFinishAfterSilentNativeDispatch && !initialScrollCompletion.didRetrySilentInitialScroll(state); +- const shouldFinishAfterObservedScroll = state.hasScrolled && (!isStillScrollingTo.isInitialScroll || completionState.isAtResolvedTarget); +- const shouldRetryUnalignedInitialScroll = isStillScrollingTo.isInitialScroll && !completionState.isAtResolvedTarget && numChecks <= maxChecks; +- const shouldRetryUnalignedEndScroll = Platform.OS === "ios" && !isStillScrollingTo.isInitialScroll && isEndAlignedLastItemTarget(ctx, isStillScrollingTo) && !completionState.isAtResolvedTarget && numChecks <= maxChecks; +- if (shouldRetrySilentInitialNativeScroll) { +- const targetOffset = (_b = (_a3 = getInitialScrollWatchdogTargetOffset(state)) != null ? _a3 : isStillScrollingTo.targetOffset) != null ? _b : 0; +- const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; +- initialScrollCompletion.markSilentInitialScrollRetry(state); +- scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" ++ const complete = () => { ++ var _a4, _b2, _c2, _d, _e; ++ const shouldReleaseDeferredPublicOnScroll = Platform.OS === "web" && ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; ++ const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; ++ initialScrollWatchdog.clear(state); ++ if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ setInitialScrollSession(state); ++ clearPreservedInitialScrollTargetTimeout(state); ++ if (options == null ? void 0 : options.schedulePreservedTargetClear) { ++ state.scheduledWork.timeout( ++ () => { ++ var _a5; ++ if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { ++ return; ++ } ++ clearPreservedInitialScrollTarget(state); ++ }, ++ PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, ++ "preservedInitialScroll" + ); +- scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); +- } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); +- scheduleFallbackCheck(100); +- } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { +- finishScrollTo(ctx); +- } else if ((isNativeInitialPending || shouldRetryUnalignedInitialScroll) && numChecks <= maxChecks) { +- const targetOffset = (_d = (_c = getInitialScrollWatchdogTargetOffset(state)) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : state.scrollPending; +- scrollToFallbackOffset(ctx, targetOffset); +- scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); +- } else { +- scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); + } ++ } else { ++ clearPreservedInitialScrollTarget(state); ++ } ++ if (options == null ? void 0 : options.recalculateItems) { ++ recalculateSettledScroll(ctx); ++ } ++ setInitialRenderState(ctx, { didInitialScroll: true }); ++ if (shouldReleaseDeferredPublicOnScroll) { ++ releaseDeferredPublicOnScroll(ctx, finalScrollOffset); + } ++ (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); + }; +- scheduleFallbackCheck(initialDelay); +-} +- +-// src/core/doScrollTo.native.ts +-function doScrollTo(ctx, params) { +- const state = ctx.state; +- const { animated, horizontal, isInitialScroll, offset } = params; +- const isAnimated = !!animated; +- const { refScroller } = state; +- const scroller = refScroller.current; +- if (!scroller) { ++ if (options == null ? void 0 : options.waitForCompletionFrame) { ++ requestAnimationFrame(complete); + return; + } +- const isHorizontal = !!horizontal; +- const contentSize = isHorizontal ? getContentSize(ctx) : void 0; +- const nativeOffset = toNativeHorizontalOffset(state, offset, contentSize); +- scroller.scrollTo({ +- animated: isAnimated, +- x: isHorizontal ? nativeOffset : 0, +- y: isHorizontal ? 0 : offset +- }); +- if (isInitialScroll) { +- initialScrollCompletion.markInitialScrollNativeDispatch(state); +- } +- if (isAnimated && Math.abs(state.scroll - offset) <= 1) { +- checkFinishedScroll(ctx); +- } +- if (!isAnimated) { +- state.scroll = offset; +- checkFinishedScrollFallback(ctx); +- } ++ complete(); + } + + // src/core/scrollRequestTracker.ts +@@ -1612,60 +1732,6 @@ function getScrollRequestTracker(ctx) { + return ctx.scrollRequestTracker; + } + +-// src/utils/requestAdjust.ts +-function requestAdjust(ctx, positionDiff, source) { +- const state = ctx.state; +- if (Math.abs(positionDiff) > 0.1) { +- const dataChanged = source === "data"; +- const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; +- const doit = () => { +- if (needsScrollWorkaround) { +- doScrollTo(ctx, { horizontal: state.props.horizontal, offset: state.scroll }); +- } else { +- state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } +- } +- }; +- state.scroll += positionDiff; +- state.scrollForNextCalculateItemsInView = void 0; +- const readyToRender = peek$(ctx, "readyToRender"); +- if (readyToRender) { +- doit(); +- if (Platform.OS !== "web" && source !== "item-size") { +- const threshold = state.scroll - positionDiff / 2; +- if (!state.ignoreScrollFromMVCP) { +- state.ignoreScrollFromMVCP = {}; +- } +- if (positionDiff > 0) { +- state.ignoreScrollFromMVCP.lt = threshold; +- } else { +- state.ignoreScrollFromMVCP.gt = threshold; +- } +- const delay = needsScrollWorkaround ? 250 : 100; +- state.scheduledWork.timeout( +- () => { +- var _a3; +- state.ignoreScrollFromMVCP = void 0; +- const shouldForceUpdate = state.ignoreScrollFromMVCPIgnored && state.scrollProcessingEnabled !== false; +- if (shouldForceUpdate) { +- state.ignoreScrollFromMVCPIgnored = false; +- state.scrollPending = state.scroll; +- (_a3 = state.reprocessCurrentScroll) == null ? void 0 : _a3.call(state); +- } +- }, +- delay, +- "ignoreScrollFromMVCP" +- ); +- } +- } else { +- state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); +- } +- } +-} +- + // src/core/doMaintainScrollAtEnd.ts + function finishMaintainScrollAtEnd(ctx) { + var _a3; +@@ -2302,7 +2368,7 @@ function pinScrollTargetRenderRange(ctx, targetOffset, targetIndex) { + } + } + function scrollTo(ctx, params) { +- var _a3, _b; ++ var _a3, _b, _c; + const state = ctx.state; + const { noScrollingTo, forceScroll, ...scrollTarget } = params; + const { +@@ -2334,6 +2400,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (scrollTarget.index !== void 0 && scrollTarget.viewPosition !== void 0) { ++ beginScrollTargetSettle(ctx, { ++ index: scrollTarget.index, ++ viewOffset: (_b = scrollTarget.viewOffset) != null ? _b : 0, ++ viewPosition: scrollTarget.viewPosition ++ }); ++ } else { ++ clearScrollTargetSettle(state); ++ } + } + } + state.scrollPending = targetOffset; +@@ -2341,7 +2416,7 @@ function scrollTo(ctx, params) { + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state); ++ (_c = state.triggerCalculateItemsInView) == null ? void 0 : _c.call(state); + } + } else { + updateScroll(ctx, targetOffset, true, { markHasScrolled: false }); +@@ -4415,7 +4490,8 @@ function calculateItemsInView(ctx, params = {}) { + const scrollBeforeMVCP = state.scroll; + const scrollAdjustPendingBeforeMVCP = (_f = peek$(ctx, "scrollAdjustPending")) != null ? _f : 0; + checkMVCP == null ? void 0 : checkMVCP(); +- const didMVCPAdjustScroll = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); ++ const didSettleScrollTarget = suppressInitialScrollSideEffects ? false : settleScrollTarget(ctx); ++ const didMVCPAdjustScroll = didSettleScrollTarget || !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); + if (didMVCPAdjustScroll) { + updateScroll2(state.scroll); + updateScrollRange(); +@@ -5940,6 +6016,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { ++ var _a3; + const { state } = ctx; + const previousHeaderSize = peek$(ctx, "headerSize") || 0; + const didChange = previousHeaderSize !== size; +@@ -5949,6 +6026,9 @@ function setHeaderSize(ctx, size) { + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { + requestAdjust(ctx, size - previousHeaderSize); ++ } else if ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.onHeaderLayout) { ++ handleBootstrapInitialScrollLayoutChange(ctx); ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7036,13 +7116,14 @@ function getRenderedItem(ctx, key, containerId) { + + // src/utils/normalizeMaintainScrollAtEnd.ts + function normalizeMaintainScrollAtEndOn(on, hasExplicitOn) { +- var _a3, _b, _c, _d; ++ var _a3, _b, _c, _d, _e; + return { + animated: false, + onDataChange: hasExplicitOn ? (_a3 = on == null ? void 0 : on.dataChange) != null ? _a3 : false : true, + onFooterLayout: hasExplicitOn ? (_b = on == null ? void 0 : on.footerLayout) != null ? _b : false : true, +- onItemLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.itemLayout) != null ? _c : false : true, +- onLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.layout) != null ? _d : false : true ++ onHeaderLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.headerLayout) != null ? _c : false : true, ++ onItemLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.itemLayout) != null ? _d : false : true, ++ onLayout: hasExplicitOn ? (_e = on == null ? void 0 : on.layout) != null ? _e : false : true + }; + } + function normalizeMaintainScrollAtEnd(value) { +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 4ad913c..8c313ee 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -437,171 +437,279 @@ var EDGE_POSITION_EPSILON = 1; + var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + +-// src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { +- scheduledWork.cancel("checkFinishedScrollFrame"); +- scheduledWork.cancel("checkFinishedScrollRetryFrame"); +- scheduledWork.cancel("checkFinishedScrollFallback"); +- scheduledWork.cancel("platformScrollCompletion"); ++// src/core/getStartOffsetAdjustment.ts ++function getStartOffsetAdjustment(ctx) { ++ const { state } = ctx; ++ const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; ++ return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); + } +-function settlePendingImperativeScroll(state) { +- var _a3, _b; +- const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; +- state.pendingScrollResolve = void 0; +- state.pendingScrollToEnd = void 0; +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ ++// src/utils/getId.ts ++function getId(state, index) { ++ const { data, keyExtractor } = state.props; ++ if (!data) { ++ return ""; ++ } ++ const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; ++ const id = ret; ++ state.idCache[index] = id; ++ return id; + } +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- settlePendingImperativeScroll(state); ++ ++// src/core/updateContentMetricsState.ts ++function getRawContentLength(ctx) { ++ var _a3, _b, _c; ++ const { state, values } = ctx; ++ return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++} ++function getAlignItemsAtEndPadding(ctx) { ++ const { state } = ctx; ++ const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++} ++function updateContentMetricsState(ctx) { ++ var _a3; ++ const { state } = ctx; ++ const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; ++ const nextPadding = getAlignItemsAtEndPadding(ctx); ++ if (previousPadding !== nextPadding) { ++ const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; ++ const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); ++ if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { ++ state.scheduledWork.microtask(() => { ++ if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { ++ set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); ++ } ++ }, "alignItemsAtEndPaddingFallback"); ++ } else if (!isMaintainExpected) { ++ set$(ctx, "alignItemsAtEndPadding", nextPadding); ++ } ++ } + } + +-// src/core/deferredPublicOnScroll.ts +-function withResolvedContentOffset(state, event, resolvedOffset) { +- return { +- ...event, +- nativeEvent: { +- ...event.nativeEvent, +- contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++// src/core/addTotalSize.ts ++function addTotalSize(ctx, key, add, notifyTotalSize = true) { ++ const state = ctx.state; ++ const prevTotalSize = state.totalSize; ++ let totalSize = state.totalSize; ++ if (key === null) { ++ totalSize = add; ++ if (state.timeoutSetPaddingTop) { ++ clearTimeout(state.timeoutSetPaddingTop); ++ state.timeoutSetPaddingTop = void 0; + } +- }; ++ } else { ++ totalSize += add; ++ } ++ if (prevTotalSize !== totalSize) { ++ if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { ++ state.pendingTotalSize = totalSize; ++ } else { ++ state.pendingTotalSize = void 0; ++ state.totalSize = totalSize; ++ if (notifyTotalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } ++ updateContentMetricsState(ctx); ++ } ++ } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } + } +-function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { +- var _a3, _b, _c, _d; ++ ++// src/core/setSize.ts ++function setSize(ctx, itemKey, size, notifyTotalSize = true) { + const state = ctx.state; +- const deferredEvent = state.deferredPublicOnScrollEvent; +- state.deferredPublicOnScrollEvent = void 0; +- if (deferredEvent) { +- (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( +- _c, +- withResolvedContentOffset( +- state, +- deferredEvent, +- (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 +- ) +- ); ++ const { sizes } = state; ++ const previousSize = sizes.get(itemKey); ++ const diff = previousSize !== void 0 ? size - previousSize : size; ++ if (diff !== 0) { ++ addTotalSize(ctx, itemKey, diff, notifyTotalSize); + } ++ sizes.set(itemKey, size); + } + +-// src/core/initialScrollSession.ts +-var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; +-function hasInitialScrollSessionCompletion(completion) { +- return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); ++// src/utils/helpers.ts ++function isFunction(obj) { ++ return typeof obj === "function"; + } +-function clearInitialScrollSession(state) { +- state.initialScrollSession = void 0; +- return void 0; ++function isArray(obj) { ++ return Array.isArray(obj); + } +-function createInitialScrollSession(options) { +- const { bootstrap, completion, kind, previousDataLength } = options; +- return kind === "offset" ? { +- completion, +- kind, +- previousDataLength +- } : { +- bootstrap, +- completion, +- kind, +- previousDataLength +- }; ++var warned = /* @__PURE__ */ new Set(); ++function warnDevOnce(id, text) { ++ if (IS_DEV && !warned.has(id)) { ++ warned.add(id); ++ console.warn(`[legend-list] ${text}`); ++ } + } +-function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { +- var _a4, _b2; +- if (!state.initialScrollSession) { +- state.initialScrollSession = createInitialScrollSession({ +- completion: {}, +- kind, +- previousDataLength: 0 +- }); +- } else if (state.initialScrollSession.kind !== kind) { +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, +- completion: state.initialScrollSession.completion, +- kind, +- previousDataLength: state.initialScrollSession.previousDataLength +- }); ++function roundSize(size) { ++ return Math.floor(size * 8) / 8; ++} ++function isNullOrUndefined(value) { ++ return value === null || value === void 0; ++} ++function getPadding(s, type) { ++ var _a3, _b, _c; ++ const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; ++ return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; ++} ++function extractPadding(style, contentContainerStyle, type) { ++ return getPadding(style, type) + getPadding(contentContainerStyle, type); ++} ++function findContainerId(ctx, key) { ++ var _a3, _b; ++ const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); ++ if (directMatch !== void 0) { ++ return directMatch; + } +- (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; +- return state.initialScrollSession.completion; ++ const numContainers = peek$(ctx, "numContainers"); ++ for (let i = 0; i < numContainers; i++) { ++ const itemKey = peek$(ctx, `containerItemKey${i}`); ++ if (itemKey === key) { ++ return i; ++ } ++ } ++ return -1; + } +-var initialScrollCompletion = { +- didDispatchNativeScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); +- }, +- didRetrySilentInitialScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); +- }, +- markInitialScrollNativeDispatch(state) { +- ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; +- }, +- markSilentInitialScrollRetry(state) { +- ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; +- }, +- resetFlags(state) { +- if (!state.initialScrollSession) { +- return; ++ ++// src/utils/getItemSize.ts ++function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { getFixedItemSize, getItemType } = state.props; ++ let size = key ? state.sizesKnown.get(key) : void 0; ++ if (size === void 0 && key && getFixedItemSize) { ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); ++ if (fixedSize !== void 0) { ++ size = fixedSize + ctx.scrollAxisGap; ++ state.sizesKnown.set(key, size); + } +- const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); +- completion.didDispatchNativeScroll = void 0; +- completion.didRetrySilentInitialScroll = void 0; + } +-}; +-var initialScrollWatchdog = { +- clear(state) { +- initialScrollWatchdog.set(state, void 0); +- }, +- didReachTarget(newScroll, watchdog) { +- const nextDistance = Math.abs(newScroll - watchdog.targetOffset); +- return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- get(state) { +- var _a3, _b; +- return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; +- }, +- hasNonZeroTargetOffset(targetOffset) { +- return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- isAtZeroTargetOffset(targetOffset) { +- return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- set(state, watchdog) { +- var _a3, _b; +- if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { +- return; ++ return size; ++} ++function getKnownOrFixedItemSize(ctx, index) { ++ const key = getId(ctx.state, index); ++ return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++} ++function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { ++ for (let index = startIndex; index <= endIndex; index++) { ++ if (getKnownOrFixedItemSize(ctx, index) === void 0) { ++ return false; + } +- const completion = ensureInitialScrollSessionCompletion(state); +- completion.watchdog = watchdog ? { +- startScroll: watchdog.startScroll, +- targetOffset: watchdog.targetOffset +- } : void 0; + } +-}; +-function setInitialScrollSession(state, options = {}) { ++ return true; ++} ++function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { + var _a3, _b, _c, _d; +- const existingSession = state.initialScrollSession; +- const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; +- const completion = existingSession == null ? void 0 : existingSession.completion; +- const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; +- const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; +- if (!kind) { +- return clearInitialScrollSession(state); ++ const state = ctx.state; ++ const { ++ sizes, ++ averageSizes, ++ props: { estimatedItemSize, getItemType }, ++ scrollingTo ++ } = state; ++ const sizeKnown = state.sizesKnown.get(key); ++ if (sizeKnown !== void 0) { ++ return sizeKnown; + } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); ++ let size; ++ const renderedSize = sizes.get(key); ++ if (preferCachedSize) { ++ if (renderedSize !== void 0) { ++ return renderedSize; ++ } + } +- const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap, +- completion, +- kind, +- previousDataLength +- }); +- return state.initialScrollSession; ++ size = getKnownOrFixedSize(ctx, key, index, data, resolved); ++ if (size !== void 0) { ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++ } ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ if (useAverageSize && !scrollingTo) { ++ const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0 && renderedSize !== void 0) { ++ return renderedSize; ++ } ++ if (size === void 0 && useAverageSize && scrollingTo) { ++ const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0) { ++ size = estimatedItemSize + ctx.scrollAxisGap; ++ } ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++} ++function getItemSizeAtIndex(ctx, index) { ++ if (index === void 0 || index < 0) { ++ return void 0; ++ } ++ const targetId = getId(ctx.state, index); ++ return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++} ++ ++// src/core/calculateOffsetWithOffsetPosition.ts ++function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++ var _a3; ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ let offset = offsetParam; ++ if (viewOffset) { ++ offset -= viewOffset; ++ } ++ if (index !== void 0) { ++ const startOffsetAdjustment = getStartOffsetAdjustment(ctx); ++ if (startOffsetAdjustment) { ++ offset += startOffsetAdjustment; ++ } ++ } ++ if (viewPosition !== void 0 && index !== void 0) { ++ const dataLength = state.props.data.length; ++ if (dataLength === 0) { ++ return offset; ++ } ++ const isOutOfBounds = index < 0 || index >= dataLength; ++ const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; ++ const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); ++ const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); ++ const trailingInset = getContentInsetEnd(ctx); ++ offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); ++ if (!isOutOfBounds && index === state.props.data.length - 1) { ++ const footerSize = peek$(ctx, "footerSize") || 0; ++ offset += footerSize; ++ } ++ } ++ return offset; ++} ++ ++// src/core/clampScrollOffset.ts ++function clampScrollOffset(ctx, offset, scrollTarget) { ++ const state = ctx.state; ++ const contentSize = getContentSize(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 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); ++ return clampedOffset; ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; + } + + // src/utils/checkThreshold.ts +@@ -803,748 +911,760 @@ function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + }, + (snapshot) => { + state.startReachedSnapshot = snapshot; +- } +- ); +- } +-} +- +-// src/utils/checkThresholds.ts +-function checkThresholds(ctx, allowedEdge) { +- const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; +- checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); +- checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); +-} +- +-// src/core/recalculateSettledScroll.ts +-function recalculateSettledScroll(ctx) { +- var _a3, _b; +- const state = ctx.state; +- if ((_a3 = state.props) == null ? void 0 : _a3.data) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); +- } +- checkThresholds(ctx); +-} +- +-// src/core/adaptiveRender.ts +-var DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY = 3; +-var DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY = 1; +-var DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-function clearAdaptiveRenderExitTimeout(ctx) { +- ctx.state.scheduledWork.cancel("adaptiveRender"); +-} +-function scheduleAdaptiveRenderExit(ctx, exitDelay) { +- const state = ctx.state; +- clearAdaptiveRenderExitTimeout(ctx); +- if (exitDelay <= 0) { +- setAdaptiveRender(ctx, "normal", "scroll"); +- } else { +- state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); +- } +-} +-function setAdaptiveRender(ctx, mode, reason) { +- var _a3, _b; +- const previousMode = peek$(ctx, "adaptiveRender"); +- if (previousMode !== mode) { +- set$(ctx, "adaptiveRender", mode); +- (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); ++ } ++ ); + } + } +-function resetAdaptiveRender(ctx) { ++ ++// src/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++} ++ ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { + var _a3, _b; +- clearAdaptiveRenderExitTimeout(ctx); +- const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; +- if (peek$(ctx, "adaptiveRender") !== mode) { +- setAdaptiveRender(ctx, mode, "initial"); ++ const state = ctx.state; ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } ++ checkThresholds(ctx); + } +-function updateAdaptiveRender(ctx, scrollVelocity, options) { +- var _a3, _b, _c; ++ ++// src/core/finishScrollTo.ts ++function finishScrollTo(ctx) { ++ var _a3, _b; + const state = ctx.state; +- const adaptiveRender = state.props.adaptiveRender; +- const currentMode = peek$(ctx, "adaptiveRender"); +- if (peek$(ctx, "readyToRender")) { +- if (adaptiveRender) { +- const isWeb = Platform.OS === "web"; +- const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY : DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY; +- const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY : DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY; +- const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY : DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY; +- const threshold = currentMode === "light" ? exitVelocity : enterVelocity; +- const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; +- const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; +- if (nextMode !== previousMode) { +- if (nextMode === "light") { +- setAdaptiveRender(ctx, "light", "scroll"); +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } else if (currentMode === "light") { +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } +- } +- } else { +- resetAdaptiveRender(ctx); ++ if (state == null ? void 0 : state.scrollingTo) { ++ cancelScrollCompletionChecks(state); ++ const resolvePendingScroll = state.pendingScrollResolve; ++ state.pendingScrollResolve = void 0; ++ const scrollingTo = state.scrollingTo; ++ state.scrollHistory.length = 0; ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ if (state.pendingTotalSize !== void 0) { ++ addTotalSize(ctx, null, state.pendingTotalSize); ++ } ++ if (PlatformAdjustBreaksScroll) { ++ state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ } ++ if (scrollingTo.isInitialScroll || state.initialScroll) { ++ const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; ++ finishInitialScroll(ctx, { ++ onFinished: () => { ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ }, ++ preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, ++ recalculateItems: true, ++ schedulePreservedTargetClear: shouldPreserveResizeTarget, ++ syncObservedOffset: isOffsetSession, ++ waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame ++ }); ++ return; + } ++ recalculateSettledScroll(ctx); ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); + } + } + +-// src/utils/getEffectiveDrawDistance.ts +-var INITIAL_DRAW_DISTANCE = 50; +-function getEffectiveDrawDistance(ctx, mode) { +- var _a3; +- const drawDistance = ctx.state.props.drawDistance; +- const initialScroll = ctx.state.initialScroll; +- const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; +- const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; +- return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; ++// src/core/initialScrollSession.ts ++var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; ++function hasInitialScrollSessionCompletion(completion) { ++ return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); + } +-function scheduleFullDrawDistancePrewarm(ctx) { +- const { state } = ctx; +- if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { +- return; ++function clearInitialScrollSession(state) { ++ state.initialScrollSession = void 0; ++ return void 0; ++} ++function createInitialScrollSession(options) { ++ const { bootstrap, completion, kind, previousDataLength } = options; ++ return kind === "offset" ? { ++ completion, ++ kind, ++ previousDataLength ++ } : { ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }; ++} ++function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { ++ var _a4, _b2; ++ if (!state.initialScrollSession) { ++ state.initialScrollSession = createInitialScrollSession({ ++ completion: {}, ++ kind, ++ previousDataLength: 0 ++ }); ++ } else if (state.initialScrollSession.kind !== kind) { ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, ++ completion: state.initialScrollSession.completion, ++ kind, ++ previousDataLength: state.initialScrollSession.previousDataLength ++ }); + } +- state.scheduledWork.frame(() => { +- var _a3; +- return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); +- }, "fullDrawDistancePrewarm"); ++ (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; ++ return state.initialScrollSession.completion; + } +- +-// src/utils/setInitialRenderState.ts +-function resetInitialRenderState(ctx, { +- resetLayout, +- resetInitialScroll +-}) { +- const { state } = ctx; +- if (resetLayout) { +- state.didContainersLayout = false; +- state.queuedInitialLayout = false; ++var initialScrollCompletion = { ++ didDispatchNativeScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); ++ }, ++ didRetrySilentInitialScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); ++ }, ++ markInitialScrollNativeDispatch(state) { ++ ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; ++ }, ++ markSilentInitialScrollRetry(state) { ++ ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; ++ }, ++ resetFlags(state) { ++ if (!state.initialScrollSession) { ++ return; ++ } ++ const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); ++ completion.didDispatchNativeScroll = void 0; ++ completion.didRetrySilentInitialScroll = void 0; + } +- if (resetInitialScroll) { +- state.didFinishInitialScroll = false; ++}; ++var initialScrollWatchdog = { ++ clear(state) { ++ initialScrollWatchdog.set(state, void 0); ++ }, ++ didReachTarget(newScroll, watchdog) { ++ const nextDistance = Math.abs(newScroll - watchdog.targetOffset); ++ return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ get(state) { ++ var _a3, _b; ++ return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; ++ }, ++ hasNonZeroTargetOffset(targetOffset) { ++ return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ isAtZeroTargetOffset(targetOffset) { ++ return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ set(state, watchdog) { ++ var _a3, _b; ++ if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { ++ return; ++ } ++ const completion = ensureInitialScrollSessionCompletion(state); ++ completion.watchdog = watchdog ? { ++ startScroll: watchdog.startScroll, ++ targetOffset: watchdog.targetOffset ++ } : void 0; + } +- set$(ctx, "readyToRender", false); +- resetAdaptiveRender(ctx); +-} +-function setInitialRenderState(ctx, { +- didLayout, +- didInitialScroll +-}) { +- const { state } = ctx; +- const { +- loadStartTime, +- props: { onLoad } +- } = state; +- if (didLayout) { +- state.didContainersLayout = true; ++}; ++function setInitialScrollSession(state, options = {}) { ++ var _a3, _b, _c, _d; ++ const existingSession = state.initialScrollSession; ++ const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; ++ const completion = existingSession == null ? void 0 : existingSession.completion; ++ const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; ++ const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; ++ if (!kind) { ++ return clearInitialScrollSession(state); + } +- if (didInitialScroll) { +- state.didFinishInitialScroll = true; ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); +- if (isReadyToRender && !peek$(ctx, "readyToRender")) { +- set$(ctx, "readyToRender", true); +- setAdaptiveRender(ctx, "normal", "ready"); +- if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { +- scheduleFullDrawDistancePrewarm(ctx); +- } +- if (!state.didLoad) { +- state.didLoad = true; +- if (onLoad) { +- onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); +- } ++ const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }); ++ return state.initialScrollSession; ++} ++ ++// src/core/checkFinishedScroll.ts ++var INITIAL_SCROLL_MAX_FALLBACK_CHECKS = 20; ++var INITIAL_SCROLL_COMPLETION_TARGET_EPSILON = 1; ++var INITIAL_SCROLL_ZERO_TARGET_EPSILON = 1; ++var SILENT_INITIAL_SCROLL_RETRY_DELAY_MS = 16; ++var SILENT_INITIAL_SCROLL_TARGET_EPSILON = 1; ++function checkFinishedScroll(ctx, options) { ++ const scrollingTo = ctx.state.scrollingTo; ++ if (options == null ? void 0 : options.onlyIfAligned) { ++ if (!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ if (!getResolvedScrollCompletionState(ctx, scrollingTo).isAtResolvedTarget) { ++ return; + } + } ++ ctx.state.scheduledWork.frame(() => checkFinishedScrollFrame(ctx), "checkFinishedScrollFrame"); + } +- +-// src/core/finishInitialScroll.ts +-var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; +-function syncInitialScrollOffset(state, offset) { +- state.scroll = offset; +- state.scrollPending = offset; +- state.scrollPrev = offset; ++function hasScrollCompletionOwnership(state, options) { ++ const { clampedTargetOffset, scrollingTo } = options; ++ return !scrollingTo.isInitialScroll || state.hasScrolled || clampedTargetOffset <= INITIAL_SCROLL_COMPLETION_TARGET_EPSILON; + } +-function clearPreservedInitialScrollTargetTimeout(state) { +- state.scheduledWork.cancel("preservedInitialScroll"); ++function isSilentInitialDispatch(state, scrollingTo) { ++ return !!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled; + } +-function clearPreservedInitialScrollTarget(state) { +- clearPreservedInitialScrollTargetTimeout(state); +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- state.initialScroll = void 0; +- setInitialScrollSession(state); ++function getInitialScrollWatchdogTargetOffset(state) { ++ var _a3; ++ return (_a3 = initialScrollWatchdog.get(state)) == null ? void 0 : _a3.targetOffset; + } +-function supersedeInitialScroll(ctx) { +- var _a3, _b, _c; +- const state = ctx.state; +- const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; +- if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { +- if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { +- cancelAnimationFrame(bootstrapInitialScroll.frameHandle); +- } +- if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { +- cancelScrollCompletionChecks(state); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- } +- initialScrollCompletion.resetFlags(state); +- setInitialScrollSession(state, { bootstrap: null }); +- finishInitialScroll(ctx); +- } ++function isNativeInitialNonZeroTarget(state) { ++ const targetOffset = getInitialScrollWatchdogTargetOffset(state); ++ return !state.didFinishInitialScroll && initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset); + } +-function finishInitialScroll(ctx, options) { +- var _a3, _b, _c; +- const state = ctx.state; +- if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { +- syncInitialScrollOffset(state, options.resolvedOffset); +- } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { +- const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); +- if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { +- syncInitialScrollOffset(state, observedOffset); +- } ++function shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) { ++ var _a3, _b; ++ if (!scrollingTo.isInitialScroll || scrollingTo.animated || !state.didContainersLayout) { ++ return false; + } +- const complete = () => { +- var _a4, _b2, _c2, _d, _e; +- const shouldReleaseDeferredPublicOnScroll = Platform.OS === "web" && ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; +- const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; +- initialScrollWatchdog.clear(state); +- if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- setInitialScrollSession(state); +- clearPreservedInitialScrollTargetTimeout(state); +- if (options == null ? void 0 : options.schedulePreservedTargetClear) { +- state.scheduledWork.timeout( +- () => { +- var _a5; +- if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { +- return; +- } +- clearPreservedInitialScrollTarget(state); +- }, +- PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, +- "preservedInitialScroll" +- ); +- } +- } else { +- clearPreservedInitialScrollTarget(state); +- } +- if (options == null ? void 0 : options.recalculateItems) { +- recalculateSettledScroll(ctx); +- } +- setInitialRenderState(ctx, { didInitialScroll: true }); +- if (shouldReleaseDeferredPublicOnScroll) { +- releaseDeferredPublicOnScroll(ctx, finalScrollOffset); +- } +- (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); +- }; +- if (options == null ? void 0 : options.waitForCompletionFrame) { +- requestAnimationFrame(complete); +- return; ++ if (((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap") { ++ return false; + } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; ++ const targetOffset = (_b = scrollingTo.targetOffset) != null ? _b : scrollingTo.offset; ++ if (initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled) { ++ return false; ++ } ++ if (initialScrollWatchdog.isAtZeroTargetOffset(targetOffset) || Math.abs(state.scroll - targetOffset) > 1 || Math.abs(state.scrollPending - targetOffset) > 1) { ++ return false; ++ } ++ return !!scrollingTo.waitForInitialScrollCompletionFrame || isNativeInitialNonZeroTarget(state); + } +- +-// src/core/getStartOffsetAdjustment.ts +-function getStartOffsetAdjustment(ctx) { ++function shouldFinishInitialZeroTargetScroll(ctx) { ++ var _a3; + const { state } = ctx; +- const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; +- return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); ++ return !!((_a3 = state.scrollingTo) == null ? void 0 : _a3.isInitialScroll) && state.props.data.length > 0 && getContentSize(ctx) <= state.scrollLength && state.scrollPending <= INITIAL_SCROLL_ZERO_TARGET_EPSILON; + } +- +-// src/utils/getId.ts +-function getId(state, index) { +- const { data, keyExtractor } = state.props; +- if (!data) { +- return ""; +- } +- const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; +- const id = ret; +- state.idCache[index] = id; +- return id; ++function isEndAlignedLastItemTarget(ctx, scrollingTo) { ++ return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; + } +- +-// src/core/updateContentMetricsState.ts +-function getRawContentLength(ctx) { +- var _a3, _b, _c; +- const { state, values } = ctx; +- return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++function getCurrentTargetOffset(ctx, scrollingTo) { ++ var _a3; ++ const index = scrollingTo.index; ++ const shouldRecomputeEndTarget = isEndAlignedLastItemTarget(ctx, scrollingTo); ++ const requestedTargetOffset = shouldRecomputeEndTarget && index !== void 0 ? calculateOffsetWithOffsetPosition(ctx, calculateOffsetForIndex(ctx, index), scrollingTo) : (_a3 = scrollingTo.targetOffset) != null ? _a3 : clampScrollOffset(ctx, scrollingTo.offset - (scrollingTo.viewOffset || 0), scrollingTo); ++ return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); + } +-function getAlignItemsAtEndPadding(ctx) { ++function getResolvedScrollCompletionState(ctx, scrollingTo) { + const { state } = ctx; +- const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++ const scroll = state.scrollPending; ++ const adjust = state.scrollAdjustHandler.getAdjust(); ++ const clampedTargetOffset = getCurrentTargetOffset(ctx, scrollingTo); ++ const maxOffset = clampScrollOffset(ctx, scroll, scrollingTo); ++ const diff1 = Math.abs(scroll - clampedTargetOffset); ++ const adjustedTargetOffset = clampedTargetOffset + adjust; ++ const diff2 = Math.abs(scroll - adjustedTargetOffset); ++ const canUseAdjustedCompletion = !scrollingTo.animated || Platform.OS === "ios"; ++ return { ++ clampedTargetOffset, ++ isAtResolvedTarget: Math.abs(scroll - maxOffset) < 1 && (diff1 < 1 || canUseAdjustedCompletion && diff2 < 1) ++ }; + } +-function updateContentMetricsState(ctx) { +- var _a3; ++function checkFinishedScrollFrame(ctx) { ++ const scrollingTo = ctx.state.scrollingTo; ++ if (!scrollingTo) { ++ return; ++ } + const { state } = ctx; +- const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +- const nextPadding = getAlignItemsAtEndPadding(ctx); +- if (previousPadding !== nextPadding) { +- const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; +- const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); +- if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { +- state.scheduledWork.microtask(() => { +- if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { +- set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); +- } +- }, "alignItemsAtEndPaddingFallback"); +- } else if (!isMaintainExpected) { +- set$(ctx, "alignItemsAtEndPadding", nextPadding); +- } ++ const completionState = getResolvedScrollCompletionState(ctx, scrollingTo); ++ if (completionState.isAtResolvedTarget && hasScrollCompletionOwnership(state, { ++ clampedTargetOffset: completionState.clampedTargetOffset, ++ scrollingTo ++ })) { ++ finishScrollTo(ctx); + } + } +- +-// src/core/addTotalSize.ts +-function addTotalSize(ctx, key, add, notifyTotalSize = true) { ++function scrollToFallbackOffset(ctx, offset) { ++ var _a3; ++ (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ ++ animated: false, ++ x: ctx.state.props.horizontal ? offset : 0, ++ y: ctx.state.props.horizontal ? 0 : offset ++ }); ++} ++function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +- const prevTotalSize = state.totalSize; +- let totalSize = state.totalSize; +- if (key === null) { +- totalSize = add; +- if (state.timeoutSetPaddingTop) { +- clearTimeout(state.timeoutSetPaddingTop); +- state.timeoutSetPaddingTop = void 0; +- } +- } else { +- totalSize += add; ++ if (state.scheduledWork.has("checkFinishedScrollFallback")) { ++ return; + } +- if (prevTotalSize !== totalSize) { +- if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { +- state.pendingTotalSize = totalSize; +- } else { +- state.pendingTotalSize = void 0; +- state.totalSize = totalSize; +- if (notifyTotalSize) { +- set$(ctx, "totalSize", totalSize); ++ const scrollingTo = state.scrollingTo; ++ const shouldFinishInitialZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); ++ const silentInitialDispatch = isSilentInitialDispatch(state, scrollingTo); ++ const canFinishInitialWithoutNativeProgress = scrollingTo !== void 0 ? shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) : false; ++ const slowTimeout = (scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && !shouldFinishInitialZeroTarget && !canFinishInitialWithoutNativeProgress || !state.didContainersLayout; ++ const initialDelay = shouldFinishInitialZeroTarget || canFinishInitialWithoutNativeProgress ? 0 : silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : slowTimeout ? 500 : 100; ++ let numChecks = 0; ++ const scheduleFallbackCheck = (delay) => { ++ state.scheduledWork.timeout(checkHasScrolled, delay, "checkFinishedScrollFallback"); ++ }; ++ const checkHasScrolled = () => { ++ var _a3, _b, _c, _d; ++ const isStillScrollingTo = state.scrollingTo; ++ if (isStillScrollingTo) { ++ numChecks++; ++ const isNativeInitialPending = isNativeInitialNonZeroTarget(state) && !state.hasScrolled; ++ const maxChecks = silentInitialDispatch ? 5 : isNativeInitialPending ? INITIAL_SCROLL_MAX_FALLBACK_CHECKS : 5; ++ const shouldFinishZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); ++ const canFinishInitialScrollWithoutNativeProgress = shouldFinishInitialScrollWithoutNativeProgress( ++ state, ++ isStillScrollingTo ++ ); ++ const completionState = getResolvedScrollCompletionState(ctx, isStillScrollingTo); ++ const canFinishAfterSilentNativeDispatch = Platform.OS === "android" && silentInitialDispatch && completionState.isAtResolvedTarget && numChecks >= 1; ++ const shouldRetrySilentInitialNativeScroll = Platform.OS === "android" && canFinishAfterSilentNativeDispatch && !initialScrollCompletion.didRetrySilentInitialScroll(state); ++ const shouldFinishAfterObservedScroll = state.hasScrolled && (!isStillScrollingTo.isInitialScroll || completionState.isAtResolvedTarget); ++ const shouldRetryUnalignedInitialScroll = isStillScrollingTo.isInitialScroll && !completionState.isAtResolvedTarget && numChecks <= maxChecks; ++ const shouldRetryUnalignedEndScroll = Platform.OS === "ios" && !isStillScrollingTo.isInitialScroll && isEndAlignedLastItemTarget(ctx, isStillScrollingTo) && !completionState.isAtResolvedTarget && numChecks <= maxChecks; ++ if (shouldRetrySilentInitialNativeScroll) { ++ const targetOffset = (_b = (_a3 = getInitialScrollWatchdogTargetOffset(state)) != null ? _a3 : isStillScrollingTo.targetOffset) != null ? _b : 0; ++ const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; ++ initialScrollCompletion.markSilentInitialScrollRetry(state); ++ scrollToFallbackOffset(ctx, jiggleOffset); ++ state.scheduledWork.frame( ++ () => scrollToFallbackOffset(ctx, targetOffset), ++ "checkFinishedScrollRetryFrame" ++ ); ++ scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); ++ } else if (shouldRetryUnalignedEndScroll) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ scheduleFallbackCheck(100); ++ } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { ++ finishScrollTo(ctx); ++ } else if ((isNativeInitialPending || shouldRetryUnalignedInitialScroll) && numChecks <= maxChecks) { ++ const targetOffset = (_d = (_c = getInitialScrollWatchdogTargetOffset(state)) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : state.scrollPending; ++ scrollToFallbackOffset(ctx, targetOffset); ++ scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); ++ } else { ++ scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); + } +- updateContentMetricsState(ctx); + } +- } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { +- set$(ctx, "totalSize", totalSize); +- } ++ }; ++ scheduleFallbackCheck(initialDelay); + } + +-// src/core/setSize.ts +-function setSize(ctx, itemKey, size, notifyTotalSize = true) { ++// src/core/doScrollTo.native.ts ++function doScrollTo(ctx, params) { + const state = ctx.state; +- const { sizes } = state; +- const previousSize = sizes.get(itemKey); +- const diff = previousSize !== void 0 ? size - previousSize : size; +- if (diff !== 0) { +- addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ const { animated, horizontal, isInitialScroll, offset } = params; ++ const isAnimated = !!animated; ++ const { refScroller } = state; ++ const scroller = refScroller.current; ++ if (!scroller) { ++ return; + } +- sizes.set(itemKey, size); +-} +- +-// src/utils/helpers.ts +-function isFunction(obj) { +- return typeof obj === "function"; +-} +-function isArray(obj) { +- return Array.isArray(obj); +-} +-var warned = /* @__PURE__ */ new Set(); +-function warnDevOnce(id, text) { +- if (IS_DEV && !warned.has(id)) { +- warned.add(id); +- console.warn(`[legend-list] ${text}`); ++ const isHorizontal = !!horizontal; ++ const contentSize = isHorizontal ? getContentSize(ctx) : void 0; ++ const nativeOffset = toNativeHorizontalOffset(state, offset, contentSize); ++ scroller.scrollTo({ ++ animated: isAnimated, ++ x: isHorizontal ? nativeOffset : 0, ++ y: isHorizontal ? 0 : offset ++ }); ++ if (isInitialScroll) { ++ initialScrollCompletion.markInitialScrollNativeDispatch(state); + } +-} +-function roundSize(size) { +- return Math.floor(size * 8) / 8; +-} +-function isNullOrUndefined(value) { +- return value === null || value === void 0; +-} +-function getPadding(s, type) { +- var _a3, _b, _c; +- const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; +- return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; +-} +-function extractPadding(style, contentContainerStyle, type) { +- return getPadding(style, type) + getPadding(contentContainerStyle, type); +-} +-function findContainerId(ctx, key) { +- var _a3, _b; +- const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); +- if (directMatch !== void 0) { +- return directMatch; ++ if (isAnimated && Math.abs(state.scroll - offset) <= 1) { ++ checkFinishedScroll(ctx); + } +- const numContainers = peek$(ctx, "numContainers"); +- for (let i = 0; i < numContainers; i++) { +- const itemKey = peek$(ctx, `containerItemKey${i}`); +- if (itemKey === key) { +- return i; +- } ++ if (!isAnimated) { ++ state.scroll = offset; ++ checkFinishedScrollFallback(ctx); + } +- return -1; + } + +-// src/utils/getItemSize.ts +-function getKnownOrFixedSize(ctx, key, index, data, resolved) { +- var _a3, _b; ++// src/utils/requestAdjust.ts ++function requestAdjust(ctx, positionDiff, source) { + const state = ctx.state; +- const { getFixedItemSize, getItemType } = state.props; +- let size = key ? state.sizesKnown.get(key) : void 0; +- if (size === void 0 && key && getFixedItemSize) { +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); +- if (fixedSize !== void 0) { +- size = fixedSize + ctx.scrollAxisGap; +- state.sizesKnown.set(key, size); +- } +- } +- return size; +-} +-function getKnownOrFixedItemSize(ctx, index) { +- const key = getId(ctx.state, index); +- return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); +-} +-function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { +- for (let index = startIndex; index <= endIndex; index++) { +- if (getKnownOrFixedItemSize(ctx, index) === void 0) { +- return false; ++ if (Math.abs(positionDiff) > 0.1) { ++ const dataChanged = source === "data"; ++ const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; ++ const doit = () => { ++ if (needsScrollWorkaround) { ++ doScrollTo(ctx, { horizontal: state.props.horizontal, offset: state.scroll }); ++ } else { ++ state.scrollAdjustHandler.requestAdjust(positionDiff); ++ if (state.adjustingFromInitialMount) { ++ state.adjustingFromInitialMount--; ++ } ++ } ++ }; ++ state.scroll += positionDiff; ++ state.scrollForNextCalculateItemsInView = void 0; ++ const readyToRender = peek$(ctx, "readyToRender"); ++ if (readyToRender) { ++ doit(); ++ if (Platform.OS !== "web" && source !== "item-size") { ++ const threshold = state.scroll - positionDiff / 2; ++ if (!state.ignoreScrollFromMVCP) { ++ state.ignoreScrollFromMVCP = {}; ++ } ++ if (positionDiff > 0) { ++ state.ignoreScrollFromMVCP.lt = threshold; ++ } else { ++ state.ignoreScrollFromMVCP.gt = threshold; ++ } ++ const delay = needsScrollWorkaround ? 250 : 100; ++ state.scheduledWork.timeout( ++ () => { ++ var _a3; ++ state.ignoreScrollFromMVCP = void 0; ++ const shouldForceUpdate = state.ignoreScrollFromMVCPIgnored && state.scrollProcessingEnabled !== false; ++ if (shouldForceUpdate) { ++ state.ignoreScrollFromMVCPIgnored = false; ++ state.scrollPending = state.scroll; ++ (_a3 = state.reprocessCurrentScroll) == null ? void 0 : _a3.call(state); ++ } ++ }, ++ delay, ++ "ignoreScrollFromMVCP" ++ ); ++ } ++ } else { ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ requestAnimationFrame(doit); + } + } +- return true; + } +-function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { +- var _a3, _b, _c, _d; ++ ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 0.5; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_TTL_MS = 500; ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++} ++function beginScrollTargetSettle(ctx, params) { + const state = ctx.state; +- const { +- sizes, +- averageSizes, +- props: { estimatedItemSize, getItemType }, +- scrollingTo +- } = state; +- const sizeKnown = state.sizesKnown.get(key); +- if (sizeKnown !== void 0) { +- return sizeKnown; +- } +- let size; +- const renderedSize = sizes.get(key); +- if (preferCachedSize) { +- if (renderedSize !== void 0) { +- return renderedSize; +- } +- } +- size = getKnownOrFixedSize(ctx, key, index, data, resolved); +- if (size !== void 0) { +- setSize(ctx, key, size, notifyTotalSize); +- return size; +- } +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { +- const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0 && renderedSize !== void 0) { +- return renderedSize; +- } +- if (size === void 0 && useAverageSize && scrollingTo) { +- const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0) { +- size = estimatedItemSize + ctx.scrollAxisGap; +- } +- setSize(ctx, key, size, notifyTotalSize); +- return size; +-} +-function getItemSizeAtIndex(ctx, index) { +- if (index === void 0 || index < 0) { +- return void 0; ++ const { index, viewOffset, viewPosition } = params; ++ const { data } = state.props; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem) { ++ clearScrollTargetSettle(state); ++ return; + } +- const targetId = getId(ctx.state, index); +- return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++ state.scrollTargetSettle = { ++ expiresAt: Date.now() + SETTLE_TTL_MS, ++ id: getId(state, index), ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; + } +- +-// src/core/calculateOffsetWithOffsetPosition.ts +-function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++function settleScrollTarget(ctx) { + var _a3; + const state = ctx.state; +- const { index, viewOffset, viewPosition } = params; +- let offset = offsetParam; +- if (viewOffset) { +- offset -= viewOffset; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; + } +- if (index !== void 0) { +- const startOffsetAdjustment = getStartOffsetAdjustment(ctx); +- if (startOffsetAdjustment) { +- offset += startOffsetAdjustment; +- } ++ const index = state.indexByKey.get(settle.id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0) { ++ clearScrollTargetSettle(state); ++ return false; + } +- if (viewPosition !== void 0 && index !== void 0) { +- const dataLength = state.props.data.length; +- if (dataLength === 0) { +- return offset; +- } +- const isOutOfBounds = index < 0 || index >= dataLength; +- const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; +- const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); +- const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); +- const trailingInset = getContentInsetEnd(ctx); +- offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); +- if (!isOutOfBounds && index === state.props.data.length - 1) { +- const footerSize = peek$(ctx, "footerSize") || 0; +- offset += footerSize; ++ const now = Date.now(); ++ if (now > settle.expiresAt) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ const diff = targetOffset - state.scroll; ++ if (Math.abs(diff) <= SETTLE_POSITION_EPSILON) { ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); + } ++ return false; + } +- return offset; ++ settle.quietPasses = 0; ++ settle.expiresAt = now + SETTLE_TTL_MS; ++ if (((_a3 = state.scrollingTo) == null ? void 0 : _a3.index) === index) { ++ state.scrollingTo.offset = position; ++ state.scrollingTo.targetOffset = targetOffset; ++ } ++ requestAdjust(ctx, diff); ++ return true; + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/cancelImperativeScroll.ts ++function cancelScrollCompletionChecks({ scheduledWork }) { ++ scheduledWork.cancel("checkFinishedScrollFrame"); ++ scheduledWork.cancel("checkFinishedScrollRetryFrame"); ++ scheduledWork.cancel("checkFinishedScrollFallback"); ++ scheduledWork.cancel("platformScrollCompletion"); ++} ++function settlePendingImperativeScroll(state) { ++ var _a3, _b; ++ const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; ++ state.pendingScrollResolve = void 0; ++ state.pendingScrollToEnd = void 0; ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++} ++function cancelImperativeScroll(state) { ++ cancelScrollCompletionChecks(state); ++ state.scheduledWork.cancel("imperativeScrollReady"); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ clearScrollTargetSettle(state); ++ settlePendingImperativeScroll(state); ++} ++ ++// src/core/deferredPublicOnScroll.ts ++function withResolvedContentOffset(state, event, resolvedOffset) { ++ return { ++ ...event, ++ nativeEvent: { ++ ...event.nativeEvent, ++ contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++ } ++ }; ++} ++function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { ++ var _a3, _b, _c, _d; + const state = ctx.state; +- const contentSize = getContentSize(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 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); ++ const deferredEvent = state.deferredPublicOnScrollEvent; ++ state.deferredPublicOnScrollEvent = void 0; ++ if (deferredEvent) { ++ (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( ++ _c, ++ withResolvedContentOffset( ++ state, ++ deferredEvent, ++ (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 ++ ) ++ ); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; + } + +-// src/core/finishScrollTo.ts +-function finishScrollTo(ctx) { +- var _a3, _b; ++// src/core/adaptiveRender.ts ++var DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY = 3; ++var DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY = 1; ++var DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++function clearAdaptiveRenderExitTimeout(ctx) { ++ ctx.state.scheduledWork.cancel("adaptiveRender"); ++} ++function scheduleAdaptiveRenderExit(ctx, exitDelay) { + const state = ctx.state; +- if (state == null ? void 0 : state.scrollingTo) { +- cancelScrollCompletionChecks(state); +- const resolvePendingScroll = state.pendingScrollResolve; +- state.pendingScrollResolve = void 0; +- const scrollingTo = state.scrollingTo; +- state.scrollHistory.length = 0; +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- if (state.pendingTotalSize !== void 0) { +- addTotalSize(ctx, null, state.pendingTotalSize); +- } +- if (PlatformAdjustBreaksScroll) { +- state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); +- } +- if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; +- finishInitialScroll(ctx, { +- onFinished: () => { +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); +- }, +- preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +- recalculateItems: true, +- schedulePreservedTargetClear: shouldPreserveResizeTarget, +- syncObservedOffset: isOffsetSession, +- waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame +- }); +- return; +- } +- recalculateSettledScroll(ctx); +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ clearAdaptiveRenderExitTimeout(ctx); ++ if (exitDelay <= 0) { ++ setAdaptiveRender(ctx, "normal", "scroll"); ++ } else { ++ state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); + } + } +- +-// src/core/checkFinishedScroll.ts +-var INITIAL_SCROLL_MAX_FALLBACK_CHECKS = 20; +-var INITIAL_SCROLL_COMPLETION_TARGET_EPSILON = 1; +-var INITIAL_SCROLL_ZERO_TARGET_EPSILON = 1; +-var SILENT_INITIAL_SCROLL_RETRY_DELAY_MS = 16; +-var SILENT_INITIAL_SCROLL_TARGET_EPSILON = 1; +-function checkFinishedScroll(ctx, options) { +- const scrollingTo = ctx.state.scrollingTo; +- if (options == null ? void 0 : options.onlyIfAligned) { +- if (!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { +- return; +- } +- if (!getResolvedScrollCompletionState(ctx, scrollingTo).isAtResolvedTarget) { +- return; +- } ++function setAdaptiveRender(ctx, mode, reason) { ++ var _a3, _b; ++ const previousMode = peek$(ctx, "adaptiveRender"); ++ if (previousMode !== mode) { ++ set$(ctx, "adaptiveRender", mode); ++ (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); + } +- ctx.state.scheduledWork.frame(() => checkFinishedScrollFrame(ctx), "checkFinishedScrollFrame"); + } +-function hasScrollCompletionOwnership(state, options) { +- const { clampedTargetOffset, scrollingTo } = options; +- return !scrollingTo.isInitialScroll || state.hasScrolled || clampedTargetOffset <= INITIAL_SCROLL_COMPLETION_TARGET_EPSILON; ++function resetAdaptiveRender(ctx) { ++ var _a3, _b; ++ clearAdaptiveRenderExitTimeout(ctx); ++ const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; ++ if (peek$(ctx, "adaptiveRender") !== mode) { ++ setAdaptiveRender(ctx, mode, "initial"); ++ } + } +-function isSilentInitialDispatch(state, scrollingTo) { +- return !!(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled; ++function updateAdaptiveRender(ctx, scrollVelocity, options) { ++ var _a3, _b, _c; ++ const state = ctx.state; ++ const adaptiveRender = state.props.adaptiveRender; ++ const currentMode = peek$(ctx, "adaptiveRender"); ++ if (peek$(ctx, "readyToRender")) { ++ if (adaptiveRender) { ++ const isWeb = Platform.OS === "web"; ++ const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY : DEFAULT_ADAPTIVE_RENDER_ENTER_VELOCITY; ++ const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY : DEFAULT_ADAPTIVE_RENDER_EXIT_VELOCITY; ++ const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : isWeb ? DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY : DEFAULT_ADAPTIVE_RENDER_EXIT_DELAY; ++ const threshold = currentMode === "light" ? exitVelocity : enterVelocity; ++ const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; ++ const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; ++ if (nextMode !== previousMode) { ++ if (nextMode === "light") { ++ setAdaptiveRender(ctx, "light", "scroll"); ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } else if (currentMode === "light") { ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } ++ } ++ } else { ++ resetAdaptiveRender(ctx); ++ } ++ } + } +-function getInitialScrollWatchdogTargetOffset(state) { ++ ++// src/utils/getEffectiveDrawDistance.ts ++var INITIAL_DRAW_DISTANCE = 50; ++function getEffectiveDrawDistance(ctx, mode) { + var _a3; +- return (_a3 = initialScrollWatchdog.get(state)) == null ? void 0 : _a3.targetOffset; +-} +-function isNativeInitialNonZeroTarget(state) { +- const targetOffset = getInitialScrollWatchdogTargetOffset(state); +- return !state.didFinishInitialScroll && initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset); ++ const drawDistance = ctx.state.props.drawDistance; ++ const initialScroll = ctx.state.initialScroll; ++ const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; ++ const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; ++ return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; + } +-function shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) { +- var _a3, _b; +- if (!scrollingTo.isInitialScroll || scrollingTo.animated || !state.didContainersLayout) { +- return false; +- } +- if (((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap") { +- return false; ++function scheduleFullDrawDistancePrewarm(ctx) { ++ const { state } = ctx; ++ if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++ return; + } +- const targetOffset = (_b = scrollingTo.targetOffset) != null ? _b : scrollingTo.offset; +- if (initialScrollWatchdog.hasNonZeroTargetOffset(targetOffset) && initialScrollCompletion.didDispatchNativeScroll(state) && !state.hasScrolled) { +- return false; ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ ++// src/utils/setInitialRenderState.ts ++function resetInitialRenderState(ctx, { ++ resetLayout, ++ resetInitialScroll ++}) { ++ const { state } = ctx; ++ if (resetLayout) { ++ state.didContainersLayout = false; ++ state.queuedInitialLayout = false; + } +- if (initialScrollWatchdog.isAtZeroTargetOffset(targetOffset) || Math.abs(state.scroll - targetOffset) > 1 || Math.abs(state.scrollPending - targetOffset) > 1) { +- return false; ++ if (resetInitialScroll) { ++ state.didFinishInitialScroll = false; + } +- return !!scrollingTo.waitForInitialScrollCompletionFrame || isNativeInitialNonZeroTarget(state); ++ set$(ctx, "readyToRender", false); ++ resetAdaptiveRender(ctx); + } +-function shouldFinishInitialZeroTargetScroll(ctx) { +- var _a3; ++function setInitialRenderState(ctx, { ++ didLayout, ++ didInitialScroll ++}) { + const { state } = ctx; +- return !!((_a3 = state.scrollingTo) == null ? void 0 : _a3.isInitialScroll) && state.props.data.length > 0 && getContentSize(ctx) <= state.scrollLength && state.scrollPending <= INITIAL_SCROLL_ZERO_TARGET_EPSILON; +-} +-function isEndAlignedLastItemTarget(ctx, scrollingTo) { +- return scrollingTo.index === ctx.state.props.data.length - 1 && scrollingTo.viewPosition === 1; ++ const { ++ loadStartTime, ++ props: { onLoad } ++ } = state; ++ if (didLayout) { ++ state.didContainersLayout = true; ++ } ++ if (didInitialScroll) { ++ state.didFinishInitialScroll = true; ++ } ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ if (isReadyToRender && !peek$(ctx, "readyToRender")) { ++ set$(ctx, "readyToRender", true); ++ setAdaptiveRender(ctx, "normal", "ready"); ++ if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { ++ scheduleFullDrawDistancePrewarm(ctx); ++ } ++ if (!state.didLoad) { ++ state.didLoad = true; ++ if (onLoad) { ++ onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); ++ } ++ } ++ } + } +-function getCurrentTargetOffset(ctx, scrollingTo) { +- var _a3; +- const index = scrollingTo.index; +- const shouldRecomputeEndTarget = isEndAlignedLastItemTarget(ctx, scrollingTo); +- const requestedTargetOffset = shouldRecomputeEndTarget && index !== void 0 ? calculateOffsetWithOffsetPosition(ctx, calculateOffsetForIndex(ctx, index), scrollingTo) : (_a3 = scrollingTo.targetOffset) != null ? _a3 : clampScrollOffset(ctx, scrollingTo.offset - (scrollingTo.viewOffset || 0), scrollingTo); +- return clampScrollOffset(ctx, requestedTargetOffset, scrollingTo); ++ ++// src/core/finishInitialScroll.ts ++var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; ++function syncInitialScrollOffset(state, offset) { ++ state.scroll = offset; ++ state.scrollPending = offset; ++ state.scrollPrev = offset; + } +-function getResolvedScrollCompletionState(ctx, scrollingTo) { +- const { state } = ctx; +- const scroll = state.scrollPending; +- const adjust = state.scrollAdjustHandler.getAdjust(); +- const clampedTargetOffset = getCurrentTargetOffset(ctx, scrollingTo); +- const maxOffset = clampScrollOffset(ctx, scroll, scrollingTo); +- const diff1 = Math.abs(scroll - clampedTargetOffset); +- const adjustedTargetOffset = clampedTargetOffset + adjust; +- const diff2 = Math.abs(scroll - adjustedTargetOffset); +- const canUseAdjustedCompletion = !scrollingTo.animated || Platform.OS === "ios"; +- return { +- clampedTargetOffset, +- isAtResolvedTarget: Math.abs(scroll - maxOffset) < 1 && (diff1 < 1 || canUseAdjustedCompletion && diff2 < 1) +- }; ++function clearPreservedInitialScrollTargetTimeout(state) { ++ state.scheduledWork.cancel("preservedInitialScroll"); + } +-function checkFinishedScrollFrame(ctx) { +- const scrollingTo = ctx.state.scrollingTo; +- if (!scrollingTo) { +- return; +- } +- const { state } = ctx; +- const completionState = getResolvedScrollCompletionState(ctx, scrollingTo); +- if (completionState.isAtResolvedTarget && hasScrollCompletionOwnership(state, { +- clampedTargetOffset: completionState.clampedTargetOffset, +- scrollingTo +- })) { +- finishScrollTo(ctx); +- } ++function clearPreservedInitialScrollTarget(state) { ++ clearPreservedInitialScrollTargetTimeout(state); ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ state.initialScroll = void 0; ++ setInitialScrollSession(state); + } +-function scrollToFallbackOffset(ctx, offset) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ +- animated: false, +- x: ctx.state.props.horizontal ? offset : 0, +- y: ctx.state.props.horizontal ? 0 : offset +- }); ++function supersedeInitialScroll(ctx) { ++ var _a3, _b, _c; ++ const state = ctx.state; ++ const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; ++ if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { ++ if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { ++ cancelAnimationFrame(bootstrapInitialScroll.frameHandle); ++ } ++ if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { ++ cancelScrollCompletionChecks(state); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ } ++ initialScrollCompletion.resetFlags(state); ++ setInitialScrollSession(state, { bootstrap: null }); ++ finishInitialScroll(ctx); ++ } + } +-function checkFinishedScrollFallback(ctx) { ++function finishInitialScroll(ctx, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- if (state.scheduledWork.has("checkFinishedScrollFallback")) { +- return; ++ if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { ++ syncInitialScrollOffset(state, options.resolvedOffset); ++ } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { ++ const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); ++ if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { ++ syncInitialScrollOffset(state, observedOffset); ++ } + } +- const scrollingTo = state.scrollingTo; +- const shouldFinishInitialZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); +- const silentInitialDispatch = isSilentInitialDispatch(state, scrollingTo); +- const canFinishInitialWithoutNativeProgress = scrollingTo !== void 0 ? shouldFinishInitialScrollWithoutNativeProgress(state, scrollingTo) : false; +- const slowTimeout = (scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) && !shouldFinishInitialZeroTarget && !canFinishInitialWithoutNativeProgress || !state.didContainersLayout; +- const initialDelay = shouldFinishInitialZeroTarget || canFinishInitialWithoutNativeProgress ? 0 : silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : slowTimeout ? 500 : 100; +- let numChecks = 0; +- const scheduleFallbackCheck = (delay) => { +- state.scheduledWork.timeout(checkHasScrolled, delay, "checkFinishedScrollFallback"); +- }; +- const checkHasScrolled = () => { +- var _a3, _b, _c, _d; +- const isStillScrollingTo = state.scrollingTo; +- if (isStillScrollingTo) { +- numChecks++; +- const isNativeInitialPending = isNativeInitialNonZeroTarget(state) && !state.hasScrolled; +- const maxChecks = silentInitialDispatch ? 5 : isNativeInitialPending ? INITIAL_SCROLL_MAX_FALLBACK_CHECKS : 5; +- const shouldFinishZeroTarget = shouldFinishInitialZeroTargetScroll(ctx); +- const canFinishInitialScrollWithoutNativeProgress = shouldFinishInitialScrollWithoutNativeProgress( +- state, +- isStillScrollingTo +- ); +- const completionState = getResolvedScrollCompletionState(ctx, isStillScrollingTo); +- const canFinishAfterSilentNativeDispatch = Platform.OS === "android" && silentInitialDispatch && completionState.isAtResolvedTarget && numChecks >= 1; +- const shouldRetrySilentInitialNativeScroll = Platform.OS === "android" && canFinishAfterSilentNativeDispatch && !initialScrollCompletion.didRetrySilentInitialScroll(state); +- const shouldFinishAfterObservedScroll = state.hasScrolled && (!isStillScrollingTo.isInitialScroll || completionState.isAtResolvedTarget); +- const shouldRetryUnalignedInitialScroll = isStillScrollingTo.isInitialScroll && !completionState.isAtResolvedTarget && numChecks <= maxChecks; +- const shouldRetryUnalignedEndScroll = Platform.OS === "ios" && !isStillScrollingTo.isInitialScroll && isEndAlignedLastItemTarget(ctx, isStillScrollingTo) && !completionState.isAtResolvedTarget && numChecks <= maxChecks; +- if (shouldRetrySilentInitialNativeScroll) { +- const targetOffset = (_b = (_a3 = getInitialScrollWatchdogTargetOffset(state)) != null ? _a3 : isStillScrollingTo.targetOffset) != null ? _b : 0; +- const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; +- initialScrollCompletion.markSilentInitialScrollRetry(state); +- scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" ++ const complete = () => { ++ var _a4, _b2, _c2, _d, _e; ++ const shouldReleaseDeferredPublicOnScroll = Platform.OS === "web" && ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; ++ const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; ++ initialScrollWatchdog.clear(state); ++ if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ setInitialScrollSession(state); ++ clearPreservedInitialScrollTargetTimeout(state); ++ if (options == null ? void 0 : options.schedulePreservedTargetClear) { ++ state.scheduledWork.timeout( ++ () => { ++ var _a5; ++ if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { ++ return; ++ } ++ clearPreservedInitialScrollTarget(state); ++ }, ++ PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, ++ "preservedInitialScroll" + ); +- scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); +- } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); +- scheduleFallbackCheck(100); +- } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { +- finishScrollTo(ctx); +- } else if ((isNativeInitialPending || shouldRetryUnalignedInitialScroll) && numChecks <= maxChecks) { +- const targetOffset = (_d = (_c = getInitialScrollWatchdogTargetOffset(state)) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : state.scrollPending; +- scrollToFallbackOffset(ctx, targetOffset); +- scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); +- } else { +- scheduleFallbackCheck(silentInitialDispatch ? SILENT_INITIAL_SCROLL_RETRY_DELAY_MS : 100); + } ++ } else { ++ clearPreservedInitialScrollTarget(state); ++ } ++ if (options == null ? void 0 : options.recalculateItems) { ++ recalculateSettledScroll(ctx); ++ } ++ setInitialRenderState(ctx, { didInitialScroll: true }); ++ if (shouldReleaseDeferredPublicOnScroll) { ++ releaseDeferredPublicOnScroll(ctx, finalScrollOffset); + } ++ (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); + }; +- scheduleFallbackCheck(initialDelay); +-} +- +-// src/core/doScrollTo.native.ts +-function doScrollTo(ctx, params) { +- const state = ctx.state; +- const { animated, horizontal, isInitialScroll, offset } = params; +- const isAnimated = !!animated; +- const { refScroller } = state; +- const scroller = refScroller.current; +- if (!scroller) { ++ if (options == null ? void 0 : options.waitForCompletionFrame) { ++ requestAnimationFrame(complete); + return; + } +- const isHorizontal = !!horizontal; +- const contentSize = isHorizontal ? getContentSize(ctx) : void 0; +- const nativeOffset = toNativeHorizontalOffset(state, offset, contentSize); +- scroller.scrollTo({ +- animated: isAnimated, +- x: isHorizontal ? nativeOffset : 0, +- y: isHorizontal ? 0 : offset +- }); +- if (isInitialScroll) { +- initialScrollCompletion.markInitialScrollNativeDispatch(state); +- } +- if (isAnimated && Math.abs(state.scroll - offset) <= 1) { +- checkFinishedScroll(ctx); +- } +- if (!isAnimated) { +- state.scroll = offset; +- checkFinishedScrollFallback(ctx); +- } ++ complete(); + } + + // src/core/scrollRequestTracker.ts +@@ -1591,60 +1711,6 @@ function getScrollRequestTracker(ctx) { + return ctx.scrollRequestTracker; + } + +-// src/utils/requestAdjust.ts +-function requestAdjust(ctx, positionDiff, source) { +- const state = ctx.state; +- if (Math.abs(positionDiff) > 0.1) { +- const dataChanged = source === "data"; +- const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; +- const doit = () => { +- if (needsScrollWorkaround) { +- doScrollTo(ctx, { horizontal: state.props.horizontal, offset: state.scroll }); +- } else { +- state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } +- } +- }; +- state.scroll += positionDiff; +- state.scrollForNextCalculateItemsInView = void 0; +- const readyToRender = peek$(ctx, "readyToRender"); +- if (readyToRender) { +- doit(); +- if (Platform.OS !== "web" && source !== "item-size") { +- const threshold = state.scroll - positionDiff / 2; +- if (!state.ignoreScrollFromMVCP) { +- state.ignoreScrollFromMVCP = {}; +- } +- if (positionDiff > 0) { +- state.ignoreScrollFromMVCP.lt = threshold; +- } else { +- state.ignoreScrollFromMVCP.gt = threshold; +- } +- const delay = needsScrollWorkaround ? 250 : 100; +- state.scheduledWork.timeout( +- () => { +- var _a3; +- state.ignoreScrollFromMVCP = void 0; +- const shouldForceUpdate = state.ignoreScrollFromMVCPIgnored && state.scrollProcessingEnabled !== false; +- if (shouldForceUpdate) { +- state.ignoreScrollFromMVCPIgnored = false; +- state.scrollPending = state.scroll; +- (_a3 = state.reprocessCurrentScroll) == null ? void 0 : _a3.call(state); +- } +- }, +- delay, +- "ignoreScrollFromMVCP" +- ); +- } +- } else { +- state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); +- } +- } +-} +- + // src/core/doMaintainScrollAtEnd.ts + function finishMaintainScrollAtEnd(ctx) { + var _a3; +@@ -2281,7 +2347,7 @@ function pinScrollTargetRenderRange(ctx, targetOffset, targetIndex) { + } + } + function scrollTo(ctx, params) { +- var _a3, _b; ++ var _a3, _b, _c; + const state = ctx.state; + const { noScrollingTo, forceScroll, ...scrollTarget } = params; + const { +@@ -2313,6 +2379,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (scrollTarget.index !== void 0 && scrollTarget.viewPosition !== void 0) { ++ beginScrollTargetSettle(ctx, { ++ index: scrollTarget.index, ++ viewOffset: (_b = scrollTarget.viewOffset) != null ? _b : 0, ++ viewPosition: scrollTarget.viewPosition ++ }); ++ } else { ++ clearScrollTargetSettle(state); ++ } + } + } + state.scrollPending = targetOffset; +@@ -2320,7 +2395,7 @@ function scrollTo(ctx, params) { + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state); ++ (_c = state.triggerCalculateItemsInView) == null ? void 0 : _c.call(state); + } + } else { + updateScroll(ctx, targetOffset, true, { markHasScrolled: false }); +@@ -4394,7 +4469,8 @@ function calculateItemsInView(ctx, params = {}) { + const scrollBeforeMVCP = state.scroll; + const scrollAdjustPendingBeforeMVCP = (_f = peek$(ctx, "scrollAdjustPending")) != null ? _f : 0; + checkMVCP == null ? void 0 : checkMVCP(); +- const didMVCPAdjustScroll = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); ++ const didSettleScrollTarget = suppressInitialScrollSideEffects ? false : settleScrollTarget(ctx); ++ const didMVCPAdjustScroll = didSettleScrollTarget || !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); + if (didMVCPAdjustScroll) { + updateScroll2(state.scroll); + updateScrollRange(); +@@ -5919,6 +5995,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { ++ var _a3; + const { state } = ctx; + const previousHeaderSize = peek$(ctx, "headerSize") || 0; + const didChange = previousHeaderSize !== size; +@@ -5928,6 +6005,9 @@ function setHeaderSize(ctx, size) { + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { + requestAdjust(ctx, size - previousHeaderSize); ++ } else if ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.onHeaderLayout) { ++ handleBootstrapInitialScrollLayoutChange(ctx); ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7015,13 +7095,14 @@ function getRenderedItem(ctx, key, containerId) { + + // src/utils/normalizeMaintainScrollAtEnd.ts + function normalizeMaintainScrollAtEndOn(on, hasExplicitOn) { +- var _a3, _b, _c, _d; ++ var _a3, _b, _c, _d, _e; + return { + animated: false, + onDataChange: hasExplicitOn ? (_a3 = on == null ? void 0 : on.dataChange) != null ? _a3 : false : true, + onFooterLayout: hasExplicitOn ? (_b = on == null ? void 0 : on.footerLayout) != null ? _b : false : true, +- onItemLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.itemLayout) != null ? _c : false : true, +- onLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.layout) != null ? _d : false : true ++ onHeaderLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.headerLayout) != null ? _c : false : true, ++ onItemLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.itemLayout) != null ? _d : false : true, ++ onLayout: hasExplicitOn ? (_e = on == null ? void 0 : on.layout) != null ? _e : false : true + }; + } + function normalizeMaintainScrollAtEnd(value) { +diff --git a/node_modules/@legendapp/list/react-native.web.d.ts b/node_modules/@legendapp/list/react-native.web.d.ts +index b6c7481..ace847e 100644 +--- a/node_modules/@legendapp/list/react-native.web.d.ts ++++ b/node_modules/@legendapp/list/react-native.web.d.ts +@@ -511,6 +511,12 @@ interface AlwaysRenderConfig { + interface MaintainScrollAtEndOnOptions { + dataChange?: boolean; + footerLayout?: boolean; ++ /** ++ * Keep the list pinned to the end when the header changes size. A header that measures larger ++ * than its estimate pushes every item down, which leaves a list that had already scrolled to ++ * its end short of it by the difference. ++ */ ++ headerLayout?: boolean; + itemLayout?: boolean; + layout?: boolean; + } +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index b2240b5..cbff6eb 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -430,171 +430,271 @@ var EDGE_POSITION_EPSILON = 1; + var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + +-// src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { +- scheduledWork.cancel("checkFinishedScrollFrame"); +- scheduledWork.cancel("checkFinishedScrollRetryFrame"); +- scheduledWork.cancel("checkFinishedScrollFallback"); +- scheduledWork.cancel("platformScrollCompletion"); +-} +-function settlePendingImperativeScroll(state) { +- var _a3, _b; +- const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; +- state.pendingScrollResolve = void 0; +- state.pendingScrollToEnd = void 0; +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++// src/core/getStartOffsetAdjustment.ts ++function getStartOffsetAdjustment(ctx) { ++ const { state } = ctx; ++ const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; ++ return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); + } +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- settlePendingImperativeScroll(state); ++ ++// src/utils/getId.ts ++function getId(state, index) { ++ const { data, keyExtractor } = state.props; ++ if (!data) { ++ return ""; ++ } ++ const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; ++ const id = ret; ++ state.idCache[index] = id; ++ return id; + } + +-// src/core/deferredPublicOnScroll.ts +-function withResolvedContentOffset(state, event, resolvedOffset) { +- return { +- ...event, +- nativeEvent: { +- ...event.nativeEvent, +- contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++// src/core/updateContentMetricsState.ts ++function getRawContentLength(ctx) { ++ var _a3, _b, _c; ++ const { state, values } = ctx; ++ return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++} ++function getAlignItemsAtEndPadding(ctx) { ++ const { state } = ctx; ++ const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++} ++function updateContentMetricsState(ctx) { ++ var _a3; ++ const { state } = ctx; ++ const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; ++ const nextPadding = getAlignItemsAtEndPadding(ctx); ++ if (previousPadding !== nextPadding) { ++ const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; ++ const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); ++ if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { ++ state.scheduledWork.microtask(() => { ++ if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { ++ set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); ++ } ++ }, "alignItemsAtEndPaddingFallback"); ++ } else if (!isMaintainExpected) { ++ set$(ctx, "alignItemsAtEndPadding", nextPadding); + } +- }; ++ } + } +-function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { +- var _a3, _b, _c, _d; ++ ++// src/core/addTotalSize.ts ++function addTotalSize(ctx, key, add, notifyTotalSize = true) { + const state = ctx.state; +- const deferredEvent = state.deferredPublicOnScrollEvent; +- state.deferredPublicOnScrollEvent = void 0; +- if (deferredEvent) { +- (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( +- _c, +- withResolvedContentOffset( +- state, +- deferredEvent, +- (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 +- ) +- ); ++ const prevTotalSize = state.totalSize; ++ let totalSize = state.totalSize; ++ if (key === null) { ++ totalSize = add; ++ if (state.timeoutSetPaddingTop) { ++ clearTimeout(state.timeoutSetPaddingTop); ++ state.timeoutSetPaddingTop = void 0; ++ } ++ } else { ++ totalSize += add; ++ } ++ if (prevTotalSize !== totalSize) { ++ { ++ state.pendingTotalSize = void 0; ++ state.totalSize = totalSize; ++ if (notifyTotalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } ++ updateContentMetricsState(ctx); ++ } ++ } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { ++ set$(ctx, "totalSize", totalSize); + } + } + +-// src/core/initialScrollSession.ts +-var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; +-function hasInitialScrollSessionCompletion(completion) { +- return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); ++// src/core/setSize.ts ++function setSize(ctx, itemKey, size, notifyTotalSize = true) { ++ const state = ctx.state; ++ const { sizes } = state; ++ const previousSize = sizes.get(itemKey); ++ const diff = previousSize !== void 0 ? size - previousSize : size; ++ if (diff !== 0) { ++ addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ } ++ sizes.set(itemKey, size); + } +-function clearInitialScrollSession(state) { +- state.initialScrollSession = void 0; +- return void 0; ++ ++// src/utils/helpers.ts ++function isFunction(obj) { ++ return typeof obj === "function"; + } +-function createInitialScrollSession(options) { +- const { bootstrap, completion, kind, previousDataLength } = options; +- return kind === "offset" ? { +- completion, +- kind, +- previousDataLength +- } : { +- bootstrap, +- completion, +- kind, +- previousDataLength +- }; ++function isArray(obj) { ++ return Array.isArray(obj); + } +-function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { +- var _a4, _b2; +- if (!state.initialScrollSession) { +- state.initialScrollSession = createInitialScrollSession({ +- completion: {}, +- kind, +- previousDataLength: 0 +- }); +- } else if (state.initialScrollSession.kind !== kind) { +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, +- completion: state.initialScrollSession.completion, +- kind, +- previousDataLength: state.initialScrollSession.previousDataLength +- }); ++var warned = /* @__PURE__ */ new Set(); ++function warnDevOnce(id, text) { ++ if (IS_DEV && !warned.has(id)) { ++ warned.add(id); ++ console.warn(`[legend-list] ${text}`); + } +- (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; +- return state.initialScrollSession.completion; + } +-var initialScrollCompletion = { +- didDispatchNativeScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); +- }, +- didRetrySilentInitialScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); +- }, +- markInitialScrollNativeDispatch(state) { +- ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; +- }, +- markSilentInitialScrollRetry(state) { +- ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; +- }, +- resetFlags(state) { +- if (!state.initialScrollSession) { +- return; ++function roundSize(size) { ++ return Math.floor(size * 8) / 8; ++} ++function isNullOrUndefined(value) { ++ return value === null || value === void 0; ++} ++function getPadding(s, type) { ++ var _a3, _b, _c; ++ const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; ++ return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; ++} ++function extractPadding(style, contentContainerStyle, type) { ++ return getPadding(style, type) + getPadding(contentContainerStyle, type); ++} ++function findContainerId(ctx, key) { ++ var _a3, _b; ++ const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); ++ if (directMatch !== void 0) { ++ return directMatch; ++ } ++ const numContainers = peek$(ctx, "numContainers"); ++ for (let i = 0; i < numContainers; i++) { ++ const itemKey = peek$(ctx, `containerItemKey${i}`); ++ if (itemKey === key) { ++ return i; + } +- const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); +- completion.didDispatchNativeScroll = void 0; +- completion.didRetrySilentInitialScroll = void 0; + } +-}; +-var initialScrollWatchdog = { +- clear(state) { +- initialScrollWatchdog.set(state, void 0); +- }, +- didReachTarget(newScroll, watchdog) { +- const nextDistance = Math.abs(newScroll - watchdog.targetOffset); +- return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- get(state) { +- var _a3, _b; +- return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; +- }, +- hasNonZeroTargetOffset(targetOffset) { +- return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- isAtZeroTargetOffset(targetOffset) { +- return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- set(state, watchdog) { +- var _a3, _b; +- if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { +- return; ++ return -1; ++} ++ ++// src/utils/getItemSize.ts ++function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { getFixedItemSize, getItemType } = state.props; ++ let size = key ? state.sizesKnown.get(key) : void 0; ++ if (size === void 0 && key && getFixedItemSize) { ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); ++ if (fixedSize !== void 0) { ++ size = fixedSize + ctx.scrollAxisGap; ++ state.sizesKnown.set(key, size); + } +- const completion = ensureInitialScrollSessionCompletion(state); +- completion.watchdog = watchdog ? { +- startScroll: watchdog.startScroll, +- targetOffset: watchdog.targetOffset +- } : void 0; + } +-}; +-function setInitialScrollSession(state, options = {}) { ++ return size; ++} ++function getKnownOrFixedItemSize(ctx, index) { ++ const key = getId(ctx.state, index); ++ return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++} ++function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { ++ for (let index = startIndex; index <= endIndex; index++) { ++ if (getKnownOrFixedItemSize(ctx, index) === void 0) { ++ return false; ++ } ++ } ++ return true; ++} ++function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { + var _a3, _b, _c, _d; +- const existingSession = state.initialScrollSession; +- const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; +- const completion = existingSession == null ? void 0 : existingSession.completion; +- const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; +- const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; +- if (!kind) { +- return clearInitialScrollSession(state); ++ const state = ctx.state; ++ const { ++ sizes, ++ averageSizes, ++ props: { estimatedItemSize, getItemType }, ++ scrollingTo ++ } = state; ++ const sizeKnown = state.sizesKnown.get(key); ++ if (sizeKnown !== void 0) { ++ return sizeKnown; + } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); ++ let size; ++ const renderedSize = sizes.get(key); ++ if (preferCachedSize) { ++ if (renderedSize !== void 0) { ++ return renderedSize; ++ } + } +- const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap, +- completion, +- kind, +- previousDataLength +- }); +- return state.initialScrollSession; ++ size = getKnownOrFixedSize(ctx, key, index, data, resolved); ++ if (size !== void 0) { ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++ } ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ if (useAverageSize && !scrollingTo) { ++ const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0 && renderedSize !== void 0) { ++ return renderedSize; ++ } ++ if (size === void 0 && useAverageSize && scrollingTo) { ++ const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0) { ++ size = estimatedItemSize + ctx.scrollAxisGap; ++ } ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++} ++function getItemSizeAtIndex(ctx, index) { ++ if (index === void 0 || index < 0) { ++ return void 0; ++ } ++ const targetId = getId(ctx.state, index); ++ return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++} ++ ++// src/core/calculateOffsetWithOffsetPosition.ts ++function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++ var _a3; ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ let offset = offsetParam; ++ if (viewOffset) { ++ offset -= viewOffset; ++ } ++ if (index !== void 0) { ++ const startOffsetAdjustment = getStartOffsetAdjustment(ctx); ++ if (startOffsetAdjustment) { ++ offset += startOffsetAdjustment; ++ } ++ } ++ if (viewPosition !== void 0 && index !== void 0) { ++ const dataLength = state.props.data.length; ++ if (dataLength === 0) { ++ return offset; ++ } ++ const isOutOfBounds = index < 0 || index >= dataLength; ++ const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; ++ const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); ++ const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); ++ const trailingInset = getContentInsetEnd(ctx); ++ offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); ++ if (!isOutOfBounds && index === state.props.data.length - 1) { ++ const footerSize = peek$(ctx, "footerSize") || 0; ++ offset += footerSize; ++ } ++ } ++ return offset; ++} ++ ++// src/core/clampScrollOffset.ts ++function clampScrollOffset(ctx, offset, scrollTarget) { ++ const state = ctx.state; ++ const contentSize = getContentSize(ctx); ++ let clampedOffset = offset; ++ if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { ++ const baseMaxOffset = Math.max(0, 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); ++ return clampedOffset; + } + + // src/utils/checkThreshold.ts +@@ -817,626 +917,616 @@ function recalculateSettledScroll(ctx) { + } + checkThresholds(ctx); + } +-var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-function clearAdaptiveRenderExitTimeout(ctx) { +- ctx.state.scheduledWork.cancel("adaptiveRender"); +-} +-function scheduleAdaptiveRenderExit(ctx, exitDelay) { +- const state = ctx.state; +- clearAdaptiveRenderExitTimeout(ctx); +- if (exitDelay <= 0) { +- setAdaptiveRender(ctx, "normal", "scroll"); +- } else { +- state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); +- } +-} +-function setAdaptiveRender(ctx, mode, reason) { +- var _a3, _b; +- const previousMode = peek$(ctx, "adaptiveRender"); +- if (previousMode !== mode) { +- set$(ctx, "adaptiveRender", mode); +- (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); +- } +-} +-function resetAdaptiveRender(ctx) { ++ ++// src/core/finishScrollTo.ts ++function finishScrollTo(ctx) { + var _a3, _b; +- clearAdaptiveRenderExitTimeout(ctx); +- const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; +- if (peek$(ctx, "adaptiveRender") !== mode) { +- setAdaptiveRender(ctx, mode, "initial"); +- } +-} +-function updateAdaptiveRender(ctx, scrollVelocity, options) { +- var _a3, _b, _c; + const state = ctx.state; +- const adaptiveRender = state.props.adaptiveRender; +- const currentMode = peek$(ctx, "adaptiveRender"); +- if (peek$(ctx, "readyToRender")) { +- if (adaptiveRender) { +- const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; +- const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; +- const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; +- const threshold = currentMode === "light" ? exitVelocity : enterVelocity; +- const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; +- const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; +- if (nextMode !== previousMode) { +- if (nextMode === "light") { +- setAdaptiveRender(ctx, "light", "scroll"); +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } else if (currentMode === "light") { +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } +- } +- } else { +- resetAdaptiveRender(ctx); ++ if (state == null ? void 0 : state.scrollingTo) { ++ cancelScrollCompletionChecks(state); ++ const resolvePendingScroll = state.pendingScrollResolve; ++ state.pendingScrollResolve = void 0; ++ const scrollingTo = state.scrollingTo; ++ state.scrollHistory.length = 0; ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ if (state.pendingTotalSize !== void 0) { ++ addTotalSize(ctx, null, state.pendingTotalSize); ++ } ++ { ++ state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ } ++ if (scrollingTo.isInitialScroll || state.initialScroll) { ++ const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; ++ finishInitialScroll(ctx, { ++ onFinished: () => { ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ }, ++ preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, ++ recalculateItems: true, ++ schedulePreservedTargetClear: shouldPreserveResizeTarget, ++ syncObservedOffset: isOffsetSession, ++ waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame ++ }); ++ return; + } ++ recalculateSettledScroll(ctx); ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); + } + } + +-// src/utils/getEffectiveDrawDistance.ts +-var INITIAL_DRAW_DISTANCE = 50; +-function getEffectiveDrawDistance(ctx, mode) { +- var _a3; +- const drawDistance = ctx.state.props.drawDistance; +- const initialScroll = ctx.state.initialScroll; +- const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; +- const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; +- return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; +-} +-function scheduleFullDrawDistancePrewarm(ctx) { +- const { state } = ctx; +- if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++// src/core/doScrollTo.ts ++var SCROLL_END_IDLE_MS = 80; ++var SCROLL_END_MAX_MS = 1500; ++var SMOOTH_SCROLL_DURATION_MS = 320; ++var SCROLL_END_TARGET_EPSILON = 1; ++function doScrollTo(ctx, params) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { animated, horizontal, offset } = params; ++ state.scheduledWork.cancel("platformScrollCompletion"); ++ const scroller = state.refScroller.current; ++ const node = scroller == null ? void 0 : scroller.getScrollableNode(); ++ if (!scroller || !node) { + return; + } +- state.scheduledWork.frame(() => { +- var _a3; +- return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); +- }, "fullDrawDistancePrewarm"); +-} +- +-// src/utils/setInitialRenderState.ts +-function resetInitialRenderState(ctx, { +- resetLayout, +- resetInitialScroll +-}) { +- const { state } = ctx; +- if (resetLayout) { +- state.didContainersLayout = false; +- state.queuedInitialLayout = false; +- } +- if (resetInitialScroll) { +- state.didFinishInitialScroll = false; ++ const isAnimated = !!animated; ++ const isHorizontal = !!horizontal; ++ const contentSize = isHorizontal ? getContentSize(ctx) : void 0; ++ const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; ++ const top = isHorizontal ? 0 : offset; ++ scroller.scrollTo({ animated: isAnimated, x: left, y: top }); ++ if (isAnimated) { ++ const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; ++ listenForScrollEnd(ctx, { ++ readOffset: () => scroller.getCurrentScrollOffset(), ++ target, ++ targetOffset: offset ++ }); ++ } else { ++ state.scroll = offset; ++ const targetToken = state.scrollingTo; ++ state.scheduledWork.timeout( ++ () => { ++ if (targetToken === state.scrollingTo) { ++ finishScrollTo(ctx); ++ } ++ }, ++ 100, ++ "platformScrollCompletion" ++ ); + } +- set$(ctx, "readyToRender", false); +- resetAdaptiveRender(ctx); + } +-function setInitialRenderState(ctx, { +- didLayout, +- didInitialScroll +-}) { +- const { state } = ctx; +- const { +- loadStartTime, +- props: { onLoad } +- } = state; +- if (didLayout) { +- state.didContainersLayout = true; +- } +- if (didInitialScroll) { +- state.didFinishInitialScroll = true; ++function listenForScrollEnd(ctx, params) { ++ const { readOffset, target, targetOffset } = params; ++ if (!target) { ++ finishScrollTo(ctx); ++ return; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); +- if (isReadyToRender && !peek$(ctx, "readyToRender")) { +- set$(ctx, "readyToRender", true); +- setAdaptiveRender(ctx, "normal", "ready"); +- if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { +- scheduleFullDrawDistancePrewarm(ctx); ++ const supportsScrollEnd = "onscrollend" in target; ++ let idleTimeout; ++ let settled = false; ++ const { scheduledWork, scrollingTo: targetToken } = ctx.state; ++ const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); ++ const cleanup = () => { ++ target.removeEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.removeEventListener("scrollend", onScrollEnd); + } +- if (!state.didLoad) { +- state.didLoad = true; +- if (onLoad) { +- onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); +- } ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); + } +- } +-} +- +-// src/core/finishInitialScroll.ts +-var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; +-function syncInitialScrollOffset(state, offset) { +- state.scroll = offset; +- state.scrollPending = offset; +- state.scrollPrev = offset; +-} +-function clearPreservedInitialScrollTargetTimeout(state) { +- state.scheduledWork.cancel("preservedInitialScroll"); +-} +-function clearPreservedInitialScrollTarget(state) { +- clearPreservedInitialScrollTargetTimeout(state); +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- state.initialScroll = void 0; +- setInitialScrollSession(state); +-} +-function supersedeInitialScroll(ctx) { +- var _a3, _b, _c; +- const state = ctx.state; +- const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; +- if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { +- if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { +- cancelAnimationFrame(bootstrapInitialScroll.frameHandle); ++ clearTimeout(maxTimeout); ++ }; ++ const cancel = () => { ++ if (!settled) { ++ settled = true; ++ cleanup(); + } +- if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { +- cancelScrollCompletionChecks(state); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; ++ }; ++ const finish = (reason) => { ++ if (settled) return; ++ if (targetToken !== ctx.state.scrollingTo) { ++ scheduledWork.cancel("platformScrollCompletion"); ++ return; + } +- initialScrollCompletion.resetFlags(state); +- setInitialScrollSession(state, { bootstrap: null }); +- finishInitialScroll(ctx); ++ const currentOffset = readOffset(); ++ const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; ++ if (reason === "scrollend" && !isNearTarget) { ++ return; ++ } ++ scheduledWork.cancel("platformScrollCompletion"); ++ finishScrollTo(ctx); ++ }; ++ const onScroll2 = () => { ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); ++ } ++ idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ }; ++ const onScrollEnd = () => finish("scrollend"); ++ target.addEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.addEventListener("scrollend", onScrollEnd); ++ } else { ++ idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); + } ++ scheduledWork.register("platformScrollCompletion", cancel); + } +-function finishInitialScroll(ctx, options) { +- var _a3, _b, _c; ++ ++// src/utils/requestAdjust.ts ++function requestAdjust(ctx, positionDiff, source) { + const state = ctx.state; +- if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { +- syncInitialScrollOffset(state, options.resolvedOffset); +- } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { +- const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); +- if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { +- syncInitialScrollOffset(state, observedOffset); +- } +- } +- const complete = () => { +- var _a4, _b2, _c2, _d, _e; +- const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; +- const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; +- initialScrollWatchdog.clear(state); +- if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- setInitialScrollSession(state); +- clearPreservedInitialScrollTargetTimeout(state); +- if (options == null ? void 0 : options.schedulePreservedTargetClear) { +- state.scheduledWork.timeout( +- () => { +- var _a5; +- if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { +- return; +- } +- clearPreservedInitialScrollTarget(state); +- }, +- PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, +- "preservedInitialScroll" +- ); ++ if (Math.abs(positionDiff) > 0.1) { ++ const doit = () => { ++ { ++ state.scrollAdjustHandler.requestAdjust(positionDiff); ++ if (state.adjustingFromInitialMount) { ++ state.adjustingFromInitialMount--; ++ } + } ++ }; ++ state.scroll += positionDiff; ++ state.scrollForNextCalculateItemsInView = void 0; ++ const readyToRender = peek$(ctx, "readyToRender"); ++ if (readyToRender) { ++ doit(); + } else { +- clearPreservedInitialScrollTarget(state); +- } +- if (options == null ? void 0 : options.recalculateItems) { +- recalculateSettledScroll(ctx); +- } +- setInitialRenderState(ctx, { didInitialScroll: true }); +- if (shouldReleaseDeferredPublicOnScroll) { +- releaseDeferredPublicOnScroll(ctx, finalScrollOffset); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ requestAnimationFrame(doit); + } +- (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); +- }; +- if (options == null ? void 0 : options.waitForCompletionFrame) { +- requestAnimationFrame(complete); +- return; + } +- complete(); + } + +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 0.5; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_TTL_MS = 500; ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++} ++function beginScrollTargetSettle(ctx, params) { + const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; ++ const { index, viewOffset, viewPosition } = params; ++ const { data } = state.props; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ state.scrollTargetSettle = { ++ expiresAt: Date.now() + SETTLE_TTL_MS, ++ id: getId(state, index), ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++} ++function settleScrollTarget(ctx) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const index = state.indexByKey.get(settle.id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const now = Date.now(); ++ if (now > settle.expiresAt) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ const diff = targetOffset - state.scroll; ++ if (Math.abs(diff) <= SETTLE_POSITION_EPSILON) { ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ settle.expiresAt = now + SETTLE_TTL_MS; ++ if (((_a3 = state.scrollingTo) == null ? void 0 : _a3.index) === index) { ++ state.scrollingTo.offset = position; ++ state.scrollingTo.targetOffset = targetOffset; ++ } ++ requestAdjust(ctx, diff); ++ return true; + } + +-// src/core/getStartOffsetAdjustment.ts +-function getStartOffsetAdjustment(ctx) { +- const { state } = ctx; +- const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; +- return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); ++// src/core/cancelImperativeScroll.ts ++function cancelScrollCompletionChecks({ scheduledWork }) { ++ scheduledWork.cancel("checkFinishedScrollFrame"); ++ scheduledWork.cancel("checkFinishedScrollRetryFrame"); ++ scheduledWork.cancel("checkFinishedScrollFallback"); ++ scheduledWork.cancel("platformScrollCompletion"); ++} ++function settlePendingImperativeScroll(state) { ++ var _a3, _b; ++ const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; ++ state.pendingScrollResolve = void 0; ++ state.pendingScrollToEnd = void 0; ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++} ++function cancelImperativeScroll(state) { ++ cancelScrollCompletionChecks(state); ++ state.scheduledWork.cancel("imperativeScrollReady"); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ clearScrollTargetSettle(state); ++ settlePendingImperativeScroll(state); + } + +-// src/utils/getId.ts +-function getId(state, index) { +- const { data, keyExtractor } = state.props; +- if (!data) { +- return ""; ++// src/core/deferredPublicOnScroll.ts ++function withResolvedContentOffset(state, event, resolvedOffset) { ++ return { ++ ...event, ++ nativeEvent: { ++ ...event.nativeEvent, ++ contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++ } ++ }; ++} ++function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { ++ var _a3, _b, _c, _d; ++ const state = ctx.state; ++ const deferredEvent = state.deferredPublicOnScrollEvent; ++ state.deferredPublicOnScrollEvent = void 0; ++ if (deferredEvent) { ++ (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( ++ _c, ++ withResolvedContentOffset( ++ state, ++ deferredEvent, ++ (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 ++ ) ++ ); + } +- const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; +- const id = ret; +- state.idCache[index] = id; +- return id; + } + +-// src/core/updateContentMetricsState.ts +-function getRawContentLength(ctx) { +- var _a3, _b, _c; +- const { state, values } = ctx; +- return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++// src/core/initialScrollSession.ts ++var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; ++function hasInitialScrollSessionCompletion(completion) { ++ return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); + } +-function getAlignItemsAtEndPadding(ctx) { +- const { state } = ctx; +- const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++function clearInitialScrollSession(state) { ++ state.initialScrollSession = void 0; ++ return void 0; + } +-function updateContentMetricsState(ctx) { +- var _a3; +- const { state } = ctx; +- const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +- const nextPadding = getAlignItemsAtEndPadding(ctx); +- if (previousPadding !== nextPadding) { +- const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; +- const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); +- if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { +- state.scheduledWork.microtask(() => { +- if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { +- set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); +- } +- }, "alignItemsAtEndPaddingFallback"); +- } else if (!isMaintainExpected) { +- set$(ctx, "alignItemsAtEndPadding", nextPadding); +- } ++function createInitialScrollSession(options) { ++ const { bootstrap, completion, kind, previousDataLength } = options; ++ return kind === "offset" ? { ++ completion, ++ kind, ++ previousDataLength ++ } : { ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }; ++} ++function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { ++ var _a4, _b2; ++ if (!state.initialScrollSession) { ++ state.initialScrollSession = createInitialScrollSession({ ++ completion: {}, ++ kind, ++ previousDataLength: 0 ++ }); ++ } else if (state.initialScrollSession.kind !== kind) { ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, ++ completion: state.initialScrollSession.completion, ++ kind, ++ previousDataLength: state.initialScrollSession.previousDataLength ++ }); + } ++ (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; ++ return state.initialScrollSession.completion; + } +- +-// src/core/addTotalSize.ts +-function addTotalSize(ctx, key, add, notifyTotalSize = true) { +- const state = ctx.state; +- const prevTotalSize = state.totalSize; +- let totalSize = state.totalSize; +- if (key === null) { +- totalSize = add; +- if (state.timeoutSetPaddingTop) { +- clearTimeout(state.timeoutSetPaddingTop); +- state.timeoutSetPaddingTop = void 0; ++var initialScrollCompletion = { ++ didDispatchNativeScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); ++ }, ++ didRetrySilentInitialScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); ++ }, ++ markInitialScrollNativeDispatch(state) { ++ ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; ++ }, ++ markSilentInitialScrollRetry(state) { ++ ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; ++ }, ++ resetFlags(state) { ++ if (!state.initialScrollSession) { ++ return; + } +- } else { +- totalSize += add; ++ const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); ++ completion.didDispatchNativeScroll = void 0; ++ completion.didRetrySilentInitialScroll = void 0; + } +- if (prevTotalSize !== totalSize) { +- { +- state.pendingTotalSize = void 0; +- state.totalSize = totalSize; +- if (notifyTotalSize) { +- set$(ctx, "totalSize", totalSize); +- } +- updateContentMetricsState(ctx); ++}; ++var initialScrollWatchdog = { ++ clear(state) { ++ initialScrollWatchdog.set(state, void 0); ++ }, ++ didReachTarget(newScroll, watchdog) { ++ const nextDistance = Math.abs(newScroll - watchdog.targetOffset); ++ return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ get(state) { ++ var _a3, _b; ++ return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; ++ }, ++ hasNonZeroTargetOffset(targetOffset) { ++ return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ isAtZeroTargetOffset(targetOffset) { ++ return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ set(state, watchdog) { ++ var _a3, _b; ++ if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { ++ return; + } +- } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { +- set$(ctx, "totalSize", totalSize); ++ const completion = ensureInitialScrollSessionCompletion(state); ++ completion.watchdog = watchdog ? { ++ startScroll: watchdog.startScroll, ++ targetOffset: watchdog.targetOffset ++ } : void 0; ++ } ++}; ++function setInitialScrollSession(state, options = {}) { ++ var _a3, _b, _c, _d; ++ const existingSession = state.initialScrollSession; ++ const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; ++ const completion = existingSession == null ? void 0 : existingSession.completion; ++ const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; ++ const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; ++ if (!kind) { ++ return clearInitialScrollSession(state); + } +-} +- +-// src/core/setSize.ts +-function setSize(ctx, itemKey, size, notifyTotalSize = true) { +- const state = ctx.state; +- const { sizes } = state; +- const previousSize = sizes.get(itemKey); +- const diff = previousSize !== void 0 ? size - previousSize : size; +- if (diff !== 0) { +- addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); + } +- sizes.set(itemKey, size); +-} +- +-// src/utils/helpers.ts +-function isFunction(obj) { +- return typeof obj === "function"; ++ const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }); ++ return state.initialScrollSession; + } +-function isArray(obj) { +- return Array.isArray(obj); ++var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++function clearAdaptiveRenderExitTimeout(ctx) { ++ ctx.state.scheduledWork.cancel("adaptiveRender"); + } +-var warned = /* @__PURE__ */ new Set(); +-function warnDevOnce(id, text) { +- if (IS_DEV && !warned.has(id)) { +- warned.add(id); +- console.warn(`[legend-list] ${text}`); ++function scheduleAdaptiveRenderExit(ctx, exitDelay) { ++ const state = ctx.state; ++ clearAdaptiveRenderExitTimeout(ctx); ++ if (exitDelay <= 0) { ++ setAdaptiveRender(ctx, "normal", "scroll"); ++ } else { ++ state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); + } + } +-function roundSize(size) { +- return Math.floor(size * 8) / 8; +-} +-function isNullOrUndefined(value) { +- return value === null || value === void 0; +-} +-function getPadding(s, type) { +- var _a3, _b, _c; +- const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; +- return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; +-} +-function extractPadding(style, contentContainerStyle, type) { +- return getPadding(style, type) + getPadding(contentContainerStyle, type); +-} +-function findContainerId(ctx, key) { ++function setAdaptiveRender(ctx, mode, reason) { + var _a3, _b; +- const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); +- if (directMatch !== void 0) { +- return directMatch; +- } +- const numContainers = peek$(ctx, "numContainers"); +- for (let i = 0; i < numContainers; i++) { +- const itemKey = peek$(ctx, `containerItemKey${i}`); +- if (itemKey === key) { +- return i; +- } ++ const previousMode = peek$(ctx, "adaptiveRender"); ++ if (previousMode !== mode) { ++ set$(ctx, "adaptiveRender", mode); ++ (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); + } +- return -1; + } +- +-// src/utils/getItemSize.ts +-function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++function resetAdaptiveRender(ctx) { + var _a3, _b; ++ clearAdaptiveRenderExitTimeout(ctx); ++ const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; ++ if (peek$(ctx, "adaptiveRender") !== mode) { ++ setAdaptiveRender(ctx, mode, "initial"); ++ } ++} ++function updateAdaptiveRender(ctx, scrollVelocity, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { getFixedItemSize, getItemType } = state.props; +- let size = key ? state.sizesKnown.get(key) : void 0; +- if (size === void 0 && key && getFixedItemSize) { +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); +- if (fixedSize !== void 0) { +- size = fixedSize + ctx.scrollAxisGap; +- state.sizesKnown.set(key, size); ++ const adaptiveRender = state.props.adaptiveRender; ++ const currentMode = peek$(ctx, "adaptiveRender"); ++ if (peek$(ctx, "readyToRender")) { ++ if (adaptiveRender) { ++ const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; ++ const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; ++ const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; ++ const threshold = currentMode === "light" ? exitVelocity : enterVelocity; ++ const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; ++ const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; ++ if (nextMode !== previousMode) { ++ if (nextMode === "light") { ++ setAdaptiveRender(ctx, "light", "scroll"); ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } else if (currentMode === "light") { ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } ++ } ++ } else { ++ resetAdaptiveRender(ctx); + } + } +- return size; + } +-function getKnownOrFixedItemSize(ctx, index) { +- const key = getId(ctx.state, index); +- return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++ ++// src/utils/getEffectiveDrawDistance.ts ++var INITIAL_DRAW_DISTANCE = 50; ++function getEffectiveDrawDistance(ctx, mode) { ++ var _a3; ++ const drawDistance = ctx.state.props.drawDistance; ++ const initialScroll = ctx.state.initialScroll; ++ const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; ++ const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; ++ return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; + } +-function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { +- for (let index = startIndex; index <= endIndex; index++) { +- if (getKnownOrFixedItemSize(ctx, index) === void 0) { +- return false; +- } ++function scheduleFullDrawDistancePrewarm(ctx) { ++ const { state } = ctx; ++ if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++ return; + } +- return true; ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); + } +-function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { +- var _a3, _b, _c, _d; +- const state = ctx.state; +- const { +- sizes, +- averageSizes, +- props: { estimatedItemSize, getItemType }, +- scrollingTo +- } = state; +- const sizeKnown = state.sizesKnown.get(key); +- if (sizeKnown !== void 0) { +- return sizeKnown; +- } +- let size; +- const renderedSize = sizes.get(key); +- if (preferCachedSize) { +- if (renderedSize !== void 0) { +- return renderedSize; +- } +- } +- size = getKnownOrFixedSize(ctx, key, index, data, resolved); +- if (size !== void 0) { +- setSize(ctx, key, size, notifyTotalSize); +- return size; +- } +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { +- const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0 && renderedSize !== void 0) { +- return renderedSize; +- } +- if (size === void 0 && useAverageSize && scrollingTo) { +- const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0) { +- size = estimatedItemSize + ctx.scrollAxisGap; ++ ++// src/utils/setInitialRenderState.ts ++function resetInitialRenderState(ctx, { ++ resetLayout, ++ resetInitialScroll ++}) { ++ const { state } = ctx; ++ if (resetLayout) { ++ state.didContainersLayout = false; ++ state.queuedInitialLayout = false; + } +- setSize(ctx, key, size, notifyTotalSize); +- return size; +-} +-function getItemSizeAtIndex(ctx, index) { +- if (index === void 0 || index < 0) { +- return void 0; ++ if (resetInitialScroll) { ++ state.didFinishInitialScroll = false; + } +- const targetId = getId(ctx.state, index); +- return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++ set$(ctx, "readyToRender", false); ++ resetAdaptiveRender(ctx); + } +- +-// src/core/calculateOffsetWithOffsetPosition.ts +-function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +- var _a3; +- const state = ctx.state; +- const { index, viewOffset, viewPosition } = params; +- let offset = offsetParam; +- if (viewOffset) { +- offset -= viewOffset; ++function setInitialRenderState(ctx, { ++ didLayout, ++ didInitialScroll ++}) { ++ const { state } = ctx; ++ const { ++ loadStartTime, ++ props: { onLoad } ++ } = state; ++ if (didLayout) { ++ state.didContainersLayout = true; + } +- if (index !== void 0) { +- const startOffsetAdjustment = getStartOffsetAdjustment(ctx); +- if (startOffsetAdjustment) { +- offset += startOffsetAdjustment; +- } ++ if (didInitialScroll) { ++ state.didFinishInitialScroll = true; + } +- if (viewPosition !== void 0 && index !== void 0) { +- const dataLength = state.props.data.length; +- if (dataLength === 0) { +- return offset; +- } +- const isOutOfBounds = index < 0 || index >= dataLength; +- const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; +- const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); +- const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); +- const trailingInset = getContentInsetEnd(ctx); +- offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); +- if (!isOutOfBounds && index === state.props.data.length - 1) { +- const footerSize = peek$(ctx, "footerSize") || 0; +- offset += footerSize; ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ if (isReadyToRender && !peek$(ctx, "readyToRender")) { ++ set$(ctx, "readyToRender", true); ++ setAdaptiveRender(ctx, "normal", "ready"); ++ if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { ++ scheduleFullDrawDistancePrewarm(ctx); ++ } ++ if (!state.didLoad) { ++ state.didLoad = true; ++ if (onLoad) { ++ onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); ++ } + } + } +- return offset; + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { +- const state = ctx.state; +- const contentSize = getContentSize(ctx); +- let clampedOffset = offset; +- if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { +- const baseMaxOffset = Math.max(0, 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); +- return clampedOffset; ++// src/core/finishInitialScroll.ts ++var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; ++function syncInitialScrollOffset(state, offset) { ++ state.scroll = offset; ++ state.scrollPending = offset; ++ state.scrollPrev = offset; + } +- +-// src/core/finishScrollTo.ts +-function finishScrollTo(ctx) { +- var _a3, _b; ++function clearPreservedInitialScrollTargetTimeout(state) { ++ state.scheduledWork.cancel("preservedInitialScroll"); ++} ++function clearPreservedInitialScrollTarget(state) { ++ clearPreservedInitialScrollTargetTimeout(state); ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ state.initialScroll = void 0; ++ setInitialScrollSession(state); ++} ++function supersedeInitialScroll(ctx) { ++ var _a3, _b, _c; + const state = ctx.state; +- if (state == null ? void 0 : state.scrollingTo) { +- cancelScrollCompletionChecks(state); +- const resolvePendingScroll = state.pendingScrollResolve; +- state.pendingScrollResolve = void 0; +- const scrollingTo = state.scrollingTo; +- state.scrollHistory.length = 0; +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- if (state.pendingTotalSize !== void 0) { +- addTotalSize(ctx, null, state.pendingTotalSize); +- } +- { +- state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; ++ if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { ++ if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { ++ cancelAnimationFrame(bootstrapInitialScroll.frameHandle); + } +- if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; +- finishInitialScroll(ctx, { +- onFinished: () => { +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); +- }, +- preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +- recalculateItems: true, +- schedulePreservedTargetClear: shouldPreserveResizeTarget, +- syncObservedOffset: isOffsetSession, +- waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame +- }); +- return; ++ if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { ++ cancelScrollCompletionChecks(state); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; + } +- recalculateSettledScroll(ctx); +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ initialScrollCompletion.resetFlags(state); ++ setInitialScrollSession(state, { bootstrap: null }); ++ finishInitialScroll(ctx); + } + } +- +-// src/core/doScrollTo.ts +-var SCROLL_END_IDLE_MS = 80; +-var SCROLL_END_MAX_MS = 1500; +-var SMOOTH_SCROLL_DURATION_MS = 320; +-var SCROLL_END_TARGET_EPSILON = 1; +-function doScrollTo(ctx, params) { +- var _a3, _b; ++function finishInitialScroll(ctx, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { animated, horizontal, offset } = params; +- state.scheduledWork.cancel("platformScrollCompletion"); +- const scroller = state.refScroller.current; +- const node = scroller == null ? void 0 : scroller.getScrollableNode(); +- if (!scroller || !node) { +- return; +- } +- const isAnimated = !!animated; +- const isHorizontal = !!horizontal; +- const contentSize = isHorizontal ? getContentSize(ctx) : void 0; +- const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; +- const top = isHorizontal ? 0 : offset; +- scroller.scrollTo({ animated: isAnimated, x: left, y: top }); +- if (isAnimated) { +- const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; +- listenForScrollEnd(ctx, { +- readOffset: () => scroller.getCurrentScrollOffset(), +- target, +- targetOffset: offset +- }); +- } else { +- state.scroll = offset; +- const targetToken = state.scrollingTo; +- state.scheduledWork.timeout( +- () => { +- if (targetToken === state.scrollingTo) { +- finishScrollTo(ctx); +- } +- }, +- 100, +- "platformScrollCompletion" +- ); +- } +-} +-function listenForScrollEnd(ctx, params) { +- const { readOffset, target, targetOffset } = params; +- if (!target) { +- finishScrollTo(ctx); +- return; +- } +- const supportsScrollEnd = "onscrollend" in target; +- let idleTimeout; +- let settled = false; +- const { scheduledWork, scrollingTo: targetToken } = ctx.state; +- const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); +- const cleanup = () => { +- target.removeEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.removeEventListener("scrollend", onScrollEnd); +- } +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); +- } +- clearTimeout(maxTimeout); +- }; +- const cancel = () => { +- if (!settled) { +- settled = true; +- cleanup(); ++ if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { ++ syncInitialScrollOffset(state, options.resolvedOffset); ++ } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { ++ const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); ++ if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { ++ syncInitialScrollOffset(state, observedOffset); + } +- }; +- const finish = (reason) => { +- if (settled) return; +- if (targetToken !== ctx.state.scrollingTo) { +- scheduledWork.cancel("platformScrollCompletion"); +- return; ++ } ++ const complete = () => { ++ var _a4, _b2, _c2, _d, _e; ++ const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; ++ const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; ++ initialScrollWatchdog.clear(state); ++ if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ setInitialScrollSession(state); ++ clearPreservedInitialScrollTargetTimeout(state); ++ if (options == null ? void 0 : options.schedulePreservedTargetClear) { ++ state.scheduledWork.timeout( ++ () => { ++ var _a5; ++ if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { ++ return; ++ } ++ clearPreservedInitialScrollTarget(state); ++ }, ++ PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, ++ "preservedInitialScroll" ++ ); ++ } ++ } else { ++ clearPreservedInitialScrollTarget(state); + } +- const currentOffset = readOffset(); +- const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; +- if (reason === "scrollend" && !isNearTarget) { +- return; ++ if (options == null ? void 0 : options.recalculateItems) { ++ recalculateSettledScroll(ctx); + } +- scheduledWork.cancel("platformScrollCompletion"); +- finishScrollTo(ctx); +- }; +- const onScroll2 = () => { +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); ++ setInitialRenderState(ctx, { didInitialScroll: true }); ++ if (shouldReleaseDeferredPublicOnScroll) { ++ releaseDeferredPublicOnScroll(ctx, finalScrollOffset); + } +- idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); + }; +- const onScrollEnd = () => finish("scrollend"); +- target.addEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.addEventListener("scrollend", onScrollEnd); +- } else { +- idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); ++ if (options == null ? void 0 : options.waitForCompletionFrame) { ++ requestAnimationFrame(complete); ++ return; + } +- scheduledWork.register("platformScrollCompletion", cancel); ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; + } + + // src/core/scrollRequestTracker.ts +@@ -1483,30 +1573,6 @@ function getScrollRequestTracker(ctx) { + return ctx.scrollRequestTracker; + } + +-// src/utils/requestAdjust.ts +-function requestAdjust(ctx, positionDiff, source) { +- const state = ctx.state; +- if (Math.abs(positionDiff) > 0.1) { +- const doit = () => { +- { +- state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } +- } +- }; +- state.scroll += positionDiff; +- state.scrollForNextCalculateItemsInView = void 0; +- const readyToRender = peek$(ctx, "readyToRender"); +- if (readyToRender) { +- doit(); +- } else { +- state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); +- } +- } +-} +- + // src/core/doMaintainScrollAtEnd.ts + function finishMaintainScrollAtEnd(ctx) { + var _a3; +@@ -2141,7 +2207,7 @@ function pinScrollTargetRenderRange(ctx, targetOffset, targetIndex) { + } + } + function scrollTo(ctx, params) { +- var _a3, _b; ++ var _a3, _b, _c; + const state = ctx.state; + const { noScrollingTo, forceScroll, ...scrollTarget } = params; + const { +@@ -2173,6 +2239,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (scrollTarget.index !== void 0 && scrollTarget.viewPosition !== void 0) { ++ beginScrollTargetSettle(ctx, { ++ index: scrollTarget.index, ++ viewOffset: (_b = scrollTarget.viewOffset) != null ? _b : 0, ++ viewPosition: scrollTarget.viewPosition ++ }); ++ } else { ++ clearScrollTargetSettle(state); ++ } + } + } + state.scrollPending = targetOffset; +@@ -2180,7 +2255,7 @@ function scrollTo(ctx, params) { + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state); ++ (_c = state.triggerCalculateItemsInView) == null ? void 0 : _c.call(state); + } + } else { + updateScroll(ctx, targetOffset, true, { markHasScrolled: false }); +@@ -4444,7 +4519,8 @@ function calculateItemsInView(ctx, params = {}) { + const scrollBeforeMVCP = state.scroll; + const scrollAdjustPendingBeforeMVCP = (_f = peek$(ctx, "scrollAdjustPending")) != null ? _f : 0; + checkMVCP == null ? void 0 : checkMVCP(); +- const didMVCPAdjustScroll = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); ++ const didSettleScrollTarget = suppressInitialScrollSideEffects ? false : settleScrollTarget(ctx); ++ const didMVCPAdjustScroll = didSettleScrollTarget || !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); + if (didMVCPAdjustScroll) { + updateScroll2(state.scroll); + updateScrollRange(); +@@ -6607,6 +6683,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { ++ var _a3; + const { state } = ctx; + const previousHeaderSize = peek$(ctx, "headerSize") || 0; + const didChange = previousHeaderSize !== size; +@@ -6616,6 +6693,9 @@ function setHeaderSize(ctx, size) { + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { + requestAdjust(ctx, size - previousHeaderSize); ++ } else if ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.onHeaderLayout) { ++ handleBootstrapInitialScrollLayoutChange(ctx); ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7679,13 +7759,14 @@ function getRenderedItem(ctx, key, containerId) { + + // src/utils/normalizeMaintainScrollAtEnd.ts + function normalizeMaintainScrollAtEndOn(on, hasExplicitOn) { +- var _a3, _b, _c, _d; ++ var _a3, _b, _c, _d, _e; + return { + animated: false, + onDataChange: hasExplicitOn ? (_a3 = on == null ? void 0 : on.dataChange) != null ? _a3 : false : true, + onFooterLayout: hasExplicitOn ? (_b = on == null ? void 0 : on.footerLayout) != null ? _b : false : true, +- onItemLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.itemLayout) != null ? _c : false : true, +- onLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.layout) != null ? _d : false : true ++ onHeaderLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.headerLayout) != null ? _c : false : true, ++ onItemLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.itemLayout) != null ? _d : false : true, ++ onLayout: hasExplicitOn ? (_e = on == null ? void 0 : on.layout) != null ? _e : false : true + }; + } + function normalizeMaintainScrollAtEnd(value) { +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index a97be05..83225ee 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -409,171 +409,271 @@ var EDGE_POSITION_EPSILON = 1; + var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + +-// src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { +- scheduledWork.cancel("checkFinishedScrollFrame"); +- scheduledWork.cancel("checkFinishedScrollRetryFrame"); +- scheduledWork.cancel("checkFinishedScrollFallback"); +- scheduledWork.cancel("platformScrollCompletion"); +-} +-function settlePendingImperativeScroll(state) { +- var _a3, _b; +- const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; +- state.pendingScrollResolve = void 0; +- state.pendingScrollToEnd = void 0; +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++// src/core/getStartOffsetAdjustment.ts ++function getStartOffsetAdjustment(ctx) { ++ const { state } = ctx; ++ const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; ++ return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); + } +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- settlePendingImperativeScroll(state); ++ ++// src/utils/getId.ts ++function getId(state, index) { ++ const { data, keyExtractor } = state.props; ++ if (!data) { ++ return ""; ++ } ++ const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; ++ const id = ret; ++ state.idCache[index] = id; ++ return id; + } + +-// src/core/deferredPublicOnScroll.ts +-function withResolvedContentOffset(state, event, resolvedOffset) { +- return { +- ...event, +- nativeEvent: { +- ...event.nativeEvent, +- contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++// src/core/updateContentMetricsState.ts ++function getRawContentLength(ctx) { ++ var _a3, _b, _c; ++ const { state, values } = ctx; ++ return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++} ++function getAlignItemsAtEndPadding(ctx) { ++ const { state } = ctx; ++ const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++} ++function updateContentMetricsState(ctx) { ++ var _a3; ++ const { state } = ctx; ++ const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; ++ const nextPadding = getAlignItemsAtEndPadding(ctx); ++ if (previousPadding !== nextPadding) { ++ const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; ++ const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); ++ if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { ++ state.scheduledWork.microtask(() => { ++ if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { ++ set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); ++ } ++ }, "alignItemsAtEndPaddingFallback"); ++ } else if (!isMaintainExpected) { ++ set$(ctx, "alignItemsAtEndPadding", nextPadding); + } +- }; ++ } + } +-function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { +- var _a3, _b, _c, _d; ++ ++// src/core/addTotalSize.ts ++function addTotalSize(ctx, key, add, notifyTotalSize = true) { + const state = ctx.state; +- const deferredEvent = state.deferredPublicOnScrollEvent; +- state.deferredPublicOnScrollEvent = void 0; +- if (deferredEvent) { +- (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( +- _c, +- withResolvedContentOffset( +- state, +- deferredEvent, +- (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 +- ) +- ); ++ const prevTotalSize = state.totalSize; ++ let totalSize = state.totalSize; ++ if (key === null) { ++ totalSize = add; ++ if (state.timeoutSetPaddingTop) { ++ clearTimeout(state.timeoutSetPaddingTop); ++ state.timeoutSetPaddingTop = void 0; ++ } ++ } else { ++ totalSize += add; ++ } ++ if (prevTotalSize !== totalSize) { ++ { ++ state.pendingTotalSize = void 0; ++ state.totalSize = totalSize; ++ if (notifyTotalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } ++ updateContentMetricsState(ctx); ++ } ++ } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { ++ set$(ctx, "totalSize", totalSize); + } + } + +-// src/core/initialScrollSession.ts +-var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; +-function hasInitialScrollSessionCompletion(completion) { +- return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); ++// src/core/setSize.ts ++function setSize(ctx, itemKey, size, notifyTotalSize = true) { ++ const state = ctx.state; ++ const { sizes } = state; ++ const previousSize = sizes.get(itemKey); ++ const diff = previousSize !== void 0 ? size - previousSize : size; ++ if (diff !== 0) { ++ addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ } ++ sizes.set(itemKey, size); + } +-function clearInitialScrollSession(state) { +- state.initialScrollSession = void 0; +- return void 0; ++ ++// src/utils/helpers.ts ++function isFunction(obj) { ++ return typeof obj === "function"; + } +-function createInitialScrollSession(options) { +- const { bootstrap, completion, kind, previousDataLength } = options; +- return kind === "offset" ? { +- completion, +- kind, +- previousDataLength +- } : { +- bootstrap, +- completion, +- kind, +- previousDataLength +- }; ++function isArray(obj) { ++ return Array.isArray(obj); + } +-function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { +- var _a4, _b2; +- if (!state.initialScrollSession) { +- state.initialScrollSession = createInitialScrollSession({ +- completion: {}, +- kind, +- previousDataLength: 0 +- }); +- } else if (state.initialScrollSession.kind !== kind) { +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, +- completion: state.initialScrollSession.completion, +- kind, +- previousDataLength: state.initialScrollSession.previousDataLength +- }); ++var warned = /* @__PURE__ */ new Set(); ++function warnDevOnce(id, text) { ++ if (IS_DEV && !warned.has(id)) { ++ warned.add(id); ++ console.warn(`[legend-list] ${text}`); + } +- (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; +- return state.initialScrollSession.completion; + } +-var initialScrollCompletion = { +- didDispatchNativeScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); +- }, +- didRetrySilentInitialScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); +- }, +- markInitialScrollNativeDispatch(state) { +- ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; +- }, +- markSilentInitialScrollRetry(state) { +- ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; +- }, +- resetFlags(state) { +- if (!state.initialScrollSession) { +- return; ++function roundSize(size) { ++ return Math.floor(size * 8) / 8; ++} ++function isNullOrUndefined(value) { ++ return value === null || value === void 0; ++} ++function getPadding(s, type) { ++ var _a3, _b, _c; ++ const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; ++ return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; ++} ++function extractPadding(style, contentContainerStyle, type) { ++ return getPadding(style, type) + getPadding(contentContainerStyle, type); ++} ++function findContainerId(ctx, key) { ++ var _a3, _b; ++ const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); ++ if (directMatch !== void 0) { ++ return directMatch; ++ } ++ const numContainers = peek$(ctx, "numContainers"); ++ for (let i = 0; i < numContainers; i++) { ++ const itemKey = peek$(ctx, `containerItemKey${i}`); ++ if (itemKey === key) { ++ return i; + } +- const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); +- completion.didDispatchNativeScroll = void 0; +- completion.didRetrySilentInitialScroll = void 0; + } +-}; +-var initialScrollWatchdog = { +- clear(state) { +- initialScrollWatchdog.set(state, void 0); +- }, +- didReachTarget(newScroll, watchdog) { +- const nextDistance = Math.abs(newScroll - watchdog.targetOffset); +- return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- get(state) { +- var _a3, _b; +- return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; +- }, +- hasNonZeroTargetOffset(targetOffset) { +- return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- isAtZeroTargetOffset(targetOffset) { +- return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- set(state, watchdog) { +- var _a3, _b; +- if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { +- return; ++ return -1; ++} ++ ++// src/utils/getItemSize.ts ++function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { getFixedItemSize, getItemType } = state.props; ++ let size = key ? state.sizesKnown.get(key) : void 0; ++ if (size === void 0 && key && getFixedItemSize) { ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); ++ if (fixedSize !== void 0) { ++ size = fixedSize + ctx.scrollAxisGap; ++ state.sizesKnown.set(key, size); + } +- const completion = ensureInitialScrollSessionCompletion(state); +- completion.watchdog = watchdog ? { +- startScroll: watchdog.startScroll, +- targetOffset: watchdog.targetOffset +- } : void 0; + } +-}; +-function setInitialScrollSession(state, options = {}) { ++ return size; ++} ++function getKnownOrFixedItemSize(ctx, index) { ++ const key = getId(ctx.state, index); ++ return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++} ++function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { ++ for (let index = startIndex; index <= endIndex; index++) { ++ if (getKnownOrFixedItemSize(ctx, index) === void 0) { ++ return false; ++ } ++ } ++ return true; ++} ++function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { + var _a3, _b, _c, _d; +- const existingSession = state.initialScrollSession; +- const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; +- const completion = existingSession == null ? void 0 : existingSession.completion; +- const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; +- const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; +- if (!kind) { +- return clearInitialScrollSession(state); ++ const state = ctx.state; ++ const { ++ sizes, ++ averageSizes, ++ props: { estimatedItemSize, getItemType }, ++ scrollingTo ++ } = state; ++ const sizeKnown = state.sizesKnown.get(key); ++ if (sizeKnown !== void 0) { ++ return sizeKnown; + } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); ++ let size; ++ const renderedSize = sizes.get(key); ++ if (preferCachedSize) { ++ if (renderedSize !== void 0) { ++ return renderedSize; ++ } + } +- const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap, +- completion, +- kind, +- previousDataLength +- }); +- return state.initialScrollSession; ++ size = getKnownOrFixedSize(ctx, key, index, data, resolved); ++ if (size !== void 0) { ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++ } ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ if (useAverageSize && !scrollingTo) { ++ const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0 && renderedSize !== void 0) { ++ return renderedSize; ++ } ++ if (size === void 0 && useAverageSize && scrollingTo) { ++ const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0) { ++ size = estimatedItemSize + ctx.scrollAxisGap; ++ } ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++} ++function getItemSizeAtIndex(ctx, index) { ++ if (index === void 0 || index < 0) { ++ return void 0; ++ } ++ const targetId = getId(ctx.state, index); ++ return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++} ++ ++// src/core/calculateOffsetWithOffsetPosition.ts ++function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++ var _a3; ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ let offset = offsetParam; ++ if (viewOffset) { ++ offset -= viewOffset; ++ } ++ if (index !== void 0) { ++ const startOffsetAdjustment = getStartOffsetAdjustment(ctx); ++ if (startOffsetAdjustment) { ++ offset += startOffsetAdjustment; ++ } ++ } ++ if (viewPosition !== void 0 && index !== void 0) { ++ const dataLength = state.props.data.length; ++ if (dataLength === 0) { ++ return offset; ++ } ++ const isOutOfBounds = index < 0 || index >= dataLength; ++ const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; ++ const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); ++ const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); ++ const trailingInset = getContentInsetEnd(ctx); ++ offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); ++ if (!isOutOfBounds && index === state.props.data.length - 1) { ++ const footerSize = peek$(ctx, "footerSize") || 0; ++ offset += footerSize; ++ } ++ } ++ return offset; ++} ++ ++// src/core/clampScrollOffset.ts ++function clampScrollOffset(ctx, offset, scrollTarget) { ++ const state = ctx.state; ++ const contentSize = getContentSize(ctx); ++ let clampedOffset = offset; ++ if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { ++ const baseMaxOffset = Math.max(0, 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); ++ return clampedOffset; + } + + // src/utils/checkThreshold.ts +@@ -796,626 +896,616 @@ function recalculateSettledScroll(ctx) { + } + checkThresholds(ctx); + } +-var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-function clearAdaptiveRenderExitTimeout(ctx) { +- ctx.state.scheduledWork.cancel("adaptiveRender"); +-} +-function scheduleAdaptiveRenderExit(ctx, exitDelay) { +- const state = ctx.state; +- clearAdaptiveRenderExitTimeout(ctx); +- if (exitDelay <= 0) { +- setAdaptiveRender(ctx, "normal", "scroll"); +- } else { +- state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); +- } +-} +-function setAdaptiveRender(ctx, mode, reason) { +- var _a3, _b; +- const previousMode = peek$(ctx, "adaptiveRender"); +- if (previousMode !== mode) { +- set$(ctx, "adaptiveRender", mode); +- (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); +- } +-} +-function resetAdaptiveRender(ctx) { ++ ++// src/core/finishScrollTo.ts ++function finishScrollTo(ctx) { + var _a3, _b; +- clearAdaptiveRenderExitTimeout(ctx); +- const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; +- if (peek$(ctx, "adaptiveRender") !== mode) { +- setAdaptiveRender(ctx, mode, "initial"); +- } +-} +-function updateAdaptiveRender(ctx, scrollVelocity, options) { +- var _a3, _b, _c; + const state = ctx.state; +- const adaptiveRender = state.props.adaptiveRender; +- const currentMode = peek$(ctx, "adaptiveRender"); +- if (peek$(ctx, "readyToRender")) { +- if (adaptiveRender) { +- const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; +- const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; +- const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; +- const threshold = currentMode === "light" ? exitVelocity : enterVelocity; +- const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; +- const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; +- if (nextMode !== previousMode) { +- if (nextMode === "light") { +- setAdaptiveRender(ctx, "light", "scroll"); +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } else if (currentMode === "light") { +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } +- } +- } else { +- resetAdaptiveRender(ctx); ++ if (state == null ? void 0 : state.scrollingTo) { ++ cancelScrollCompletionChecks(state); ++ const resolvePendingScroll = state.pendingScrollResolve; ++ state.pendingScrollResolve = void 0; ++ const scrollingTo = state.scrollingTo; ++ state.scrollHistory.length = 0; ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ if (state.pendingTotalSize !== void 0) { ++ addTotalSize(ctx, null, state.pendingTotalSize); ++ } ++ { ++ state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ } ++ if (scrollingTo.isInitialScroll || state.initialScroll) { ++ const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; ++ finishInitialScroll(ctx, { ++ onFinished: () => { ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ }, ++ preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, ++ recalculateItems: true, ++ schedulePreservedTargetClear: shouldPreserveResizeTarget, ++ syncObservedOffset: isOffsetSession, ++ waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame ++ }); ++ return; + } ++ recalculateSettledScroll(ctx); ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); + } + } + +-// src/utils/getEffectiveDrawDistance.ts +-var INITIAL_DRAW_DISTANCE = 50; +-function getEffectiveDrawDistance(ctx, mode) { +- var _a3; +- const drawDistance = ctx.state.props.drawDistance; +- const initialScroll = ctx.state.initialScroll; +- const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; +- const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; +- return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; +-} +-function scheduleFullDrawDistancePrewarm(ctx) { +- const { state } = ctx; +- if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++// src/core/doScrollTo.ts ++var SCROLL_END_IDLE_MS = 80; ++var SCROLL_END_MAX_MS = 1500; ++var SMOOTH_SCROLL_DURATION_MS = 320; ++var SCROLL_END_TARGET_EPSILON = 1; ++function doScrollTo(ctx, params) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { animated, horizontal, offset } = params; ++ state.scheduledWork.cancel("platformScrollCompletion"); ++ const scroller = state.refScroller.current; ++ const node = scroller == null ? void 0 : scroller.getScrollableNode(); ++ if (!scroller || !node) { + return; + } +- state.scheduledWork.frame(() => { +- var _a3; +- return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); +- }, "fullDrawDistancePrewarm"); +-} +- +-// src/utils/setInitialRenderState.ts +-function resetInitialRenderState(ctx, { +- resetLayout, +- resetInitialScroll +-}) { +- const { state } = ctx; +- if (resetLayout) { +- state.didContainersLayout = false; +- state.queuedInitialLayout = false; +- } +- if (resetInitialScroll) { +- state.didFinishInitialScroll = false; ++ const isAnimated = !!animated; ++ const isHorizontal = !!horizontal; ++ const contentSize = isHorizontal ? getContentSize(ctx) : void 0; ++ const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; ++ const top = isHorizontal ? 0 : offset; ++ scroller.scrollTo({ animated: isAnimated, x: left, y: top }); ++ if (isAnimated) { ++ const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; ++ listenForScrollEnd(ctx, { ++ readOffset: () => scroller.getCurrentScrollOffset(), ++ target, ++ targetOffset: offset ++ }); ++ } else { ++ state.scroll = offset; ++ const targetToken = state.scrollingTo; ++ state.scheduledWork.timeout( ++ () => { ++ if (targetToken === state.scrollingTo) { ++ finishScrollTo(ctx); ++ } ++ }, ++ 100, ++ "platformScrollCompletion" ++ ); + } +- set$(ctx, "readyToRender", false); +- resetAdaptiveRender(ctx); + } +-function setInitialRenderState(ctx, { +- didLayout, +- didInitialScroll +-}) { +- const { state } = ctx; +- const { +- loadStartTime, +- props: { onLoad } +- } = state; +- if (didLayout) { +- state.didContainersLayout = true; +- } +- if (didInitialScroll) { +- state.didFinishInitialScroll = true; ++function listenForScrollEnd(ctx, params) { ++ const { readOffset, target, targetOffset } = params; ++ if (!target) { ++ finishScrollTo(ctx); ++ return; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); +- if (isReadyToRender && !peek$(ctx, "readyToRender")) { +- set$(ctx, "readyToRender", true); +- setAdaptiveRender(ctx, "normal", "ready"); +- if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { +- scheduleFullDrawDistancePrewarm(ctx); ++ const supportsScrollEnd = "onscrollend" in target; ++ let idleTimeout; ++ let settled = false; ++ const { scheduledWork, scrollingTo: targetToken } = ctx.state; ++ const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); ++ const cleanup = () => { ++ target.removeEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.removeEventListener("scrollend", onScrollEnd); + } +- if (!state.didLoad) { +- state.didLoad = true; +- if (onLoad) { +- onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); +- } ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); + } +- } +-} +- +-// src/core/finishInitialScroll.ts +-var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; +-function syncInitialScrollOffset(state, offset) { +- state.scroll = offset; +- state.scrollPending = offset; +- state.scrollPrev = offset; +-} +-function clearPreservedInitialScrollTargetTimeout(state) { +- state.scheduledWork.cancel("preservedInitialScroll"); +-} +-function clearPreservedInitialScrollTarget(state) { +- clearPreservedInitialScrollTargetTimeout(state); +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- state.initialScroll = void 0; +- setInitialScrollSession(state); +-} +-function supersedeInitialScroll(ctx) { +- var _a3, _b, _c; +- const state = ctx.state; +- const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; +- if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { +- if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { +- cancelAnimationFrame(bootstrapInitialScroll.frameHandle); ++ clearTimeout(maxTimeout); ++ }; ++ const cancel = () => { ++ if (!settled) { ++ settled = true; ++ cleanup(); + } +- if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { +- cancelScrollCompletionChecks(state); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; ++ }; ++ const finish = (reason) => { ++ if (settled) return; ++ if (targetToken !== ctx.state.scrollingTo) { ++ scheduledWork.cancel("platformScrollCompletion"); ++ return; + } +- initialScrollCompletion.resetFlags(state); +- setInitialScrollSession(state, { bootstrap: null }); +- finishInitialScroll(ctx); ++ const currentOffset = readOffset(); ++ const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; ++ if (reason === "scrollend" && !isNearTarget) { ++ return; ++ } ++ scheduledWork.cancel("platformScrollCompletion"); ++ finishScrollTo(ctx); ++ }; ++ const onScroll2 = () => { ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); ++ } ++ idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ }; ++ const onScrollEnd = () => finish("scrollend"); ++ target.addEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.addEventListener("scrollend", onScrollEnd); ++ } else { ++ idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); + } ++ scheduledWork.register("platformScrollCompletion", cancel); + } +-function finishInitialScroll(ctx, options) { +- var _a3, _b, _c; ++ ++// src/utils/requestAdjust.ts ++function requestAdjust(ctx, positionDiff, source) { + const state = ctx.state; +- if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { +- syncInitialScrollOffset(state, options.resolvedOffset); +- } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { +- const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); +- if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { +- syncInitialScrollOffset(state, observedOffset); +- } +- } +- const complete = () => { +- var _a4, _b2, _c2, _d, _e; +- const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; +- const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; +- initialScrollWatchdog.clear(state); +- if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- setInitialScrollSession(state); +- clearPreservedInitialScrollTargetTimeout(state); +- if (options == null ? void 0 : options.schedulePreservedTargetClear) { +- state.scheduledWork.timeout( +- () => { +- var _a5; +- if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { +- return; +- } +- clearPreservedInitialScrollTarget(state); +- }, +- PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, +- "preservedInitialScroll" +- ); ++ if (Math.abs(positionDiff) > 0.1) { ++ const doit = () => { ++ { ++ state.scrollAdjustHandler.requestAdjust(positionDiff); ++ if (state.adjustingFromInitialMount) { ++ state.adjustingFromInitialMount--; ++ } + } ++ }; ++ state.scroll += positionDiff; ++ state.scrollForNextCalculateItemsInView = void 0; ++ const readyToRender = peek$(ctx, "readyToRender"); ++ if (readyToRender) { ++ doit(); + } else { +- clearPreservedInitialScrollTarget(state); +- } +- if (options == null ? void 0 : options.recalculateItems) { +- recalculateSettledScroll(ctx); +- } +- setInitialRenderState(ctx, { didInitialScroll: true }); +- if (shouldReleaseDeferredPublicOnScroll) { +- releaseDeferredPublicOnScroll(ctx, finalScrollOffset); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ requestAnimationFrame(doit); + } +- (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); +- }; +- if (options == null ? void 0 : options.waitForCompletionFrame) { +- requestAnimationFrame(complete); +- return; + } +- complete(); + } + +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 0.5; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_TTL_MS = 500; ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++} ++function beginScrollTargetSettle(ctx, params) { + const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; ++ const { index, viewOffset, viewPosition } = params; ++ const { data } = state.props; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ state.scrollTargetSettle = { ++ expiresAt: Date.now() + SETTLE_TTL_MS, ++ id: getId(state, index), ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++} ++function settleScrollTarget(ctx) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const index = state.indexByKey.get(settle.id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const now = Date.now(); ++ if (now > settle.expiresAt) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ const diff = targetOffset - state.scroll; ++ if (Math.abs(diff) <= SETTLE_POSITION_EPSILON) { ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ settle.expiresAt = now + SETTLE_TTL_MS; ++ if (((_a3 = state.scrollingTo) == null ? void 0 : _a3.index) === index) { ++ state.scrollingTo.offset = position; ++ state.scrollingTo.targetOffset = targetOffset; ++ } ++ requestAdjust(ctx, diff); ++ return true; + } + +-// src/core/getStartOffsetAdjustment.ts +-function getStartOffsetAdjustment(ctx) { +- const { state } = ctx; +- const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; +- return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); ++// src/core/cancelImperativeScroll.ts ++function cancelScrollCompletionChecks({ scheduledWork }) { ++ scheduledWork.cancel("checkFinishedScrollFrame"); ++ scheduledWork.cancel("checkFinishedScrollRetryFrame"); ++ scheduledWork.cancel("checkFinishedScrollFallback"); ++ scheduledWork.cancel("platformScrollCompletion"); ++} ++function settlePendingImperativeScroll(state) { ++ var _a3, _b; ++ const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; ++ state.pendingScrollResolve = void 0; ++ state.pendingScrollToEnd = void 0; ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++} ++function cancelImperativeScroll(state) { ++ cancelScrollCompletionChecks(state); ++ state.scheduledWork.cancel("imperativeScrollReady"); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ clearScrollTargetSettle(state); ++ settlePendingImperativeScroll(state); + } + +-// src/utils/getId.ts +-function getId(state, index) { +- const { data, keyExtractor } = state.props; +- if (!data) { +- return ""; ++// src/core/deferredPublicOnScroll.ts ++function withResolvedContentOffset(state, event, resolvedOffset) { ++ return { ++ ...event, ++ nativeEvent: { ++ ...event.nativeEvent, ++ contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++ } ++ }; ++} ++function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { ++ var _a3, _b, _c, _d; ++ const state = ctx.state; ++ const deferredEvent = state.deferredPublicOnScrollEvent; ++ state.deferredPublicOnScrollEvent = void 0; ++ if (deferredEvent) { ++ (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( ++ _c, ++ withResolvedContentOffset( ++ state, ++ deferredEvent, ++ (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 ++ ) ++ ); + } +- const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; +- const id = ret; +- state.idCache[index] = id; +- return id; + } + +-// src/core/updateContentMetricsState.ts +-function getRawContentLength(ctx) { +- var _a3, _b, _c; +- const { state, values } = ctx; +- return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++// src/core/initialScrollSession.ts ++var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; ++function hasInitialScrollSessionCompletion(completion) { ++ return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); + } +-function getAlignItemsAtEndPadding(ctx) { +- const { state } = ctx; +- const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++function clearInitialScrollSession(state) { ++ state.initialScrollSession = void 0; ++ return void 0; + } +-function updateContentMetricsState(ctx) { +- var _a3; +- const { state } = ctx; +- const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +- const nextPadding = getAlignItemsAtEndPadding(ctx); +- if (previousPadding !== nextPadding) { +- const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; +- const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); +- if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { +- state.scheduledWork.microtask(() => { +- if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { +- set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); +- } +- }, "alignItemsAtEndPaddingFallback"); +- } else if (!isMaintainExpected) { +- set$(ctx, "alignItemsAtEndPadding", nextPadding); +- } ++function createInitialScrollSession(options) { ++ const { bootstrap, completion, kind, previousDataLength } = options; ++ return kind === "offset" ? { ++ completion, ++ kind, ++ previousDataLength ++ } : { ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }; ++} ++function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { ++ var _a4, _b2; ++ if (!state.initialScrollSession) { ++ state.initialScrollSession = createInitialScrollSession({ ++ completion: {}, ++ kind, ++ previousDataLength: 0 ++ }); ++ } else if (state.initialScrollSession.kind !== kind) { ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, ++ completion: state.initialScrollSession.completion, ++ kind, ++ previousDataLength: state.initialScrollSession.previousDataLength ++ }); + } ++ (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; ++ return state.initialScrollSession.completion; + } +- +-// src/core/addTotalSize.ts +-function addTotalSize(ctx, key, add, notifyTotalSize = true) { +- const state = ctx.state; +- const prevTotalSize = state.totalSize; +- let totalSize = state.totalSize; +- if (key === null) { +- totalSize = add; +- if (state.timeoutSetPaddingTop) { +- clearTimeout(state.timeoutSetPaddingTop); +- state.timeoutSetPaddingTop = void 0; ++var initialScrollCompletion = { ++ didDispatchNativeScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); ++ }, ++ didRetrySilentInitialScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); ++ }, ++ markInitialScrollNativeDispatch(state) { ++ ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; ++ }, ++ markSilentInitialScrollRetry(state) { ++ ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; ++ }, ++ resetFlags(state) { ++ if (!state.initialScrollSession) { ++ return; + } +- } else { +- totalSize += add; ++ const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); ++ completion.didDispatchNativeScroll = void 0; ++ completion.didRetrySilentInitialScroll = void 0; + } +- if (prevTotalSize !== totalSize) { +- { +- state.pendingTotalSize = void 0; +- state.totalSize = totalSize; +- if (notifyTotalSize) { +- set$(ctx, "totalSize", totalSize); +- } +- updateContentMetricsState(ctx); ++}; ++var initialScrollWatchdog = { ++ clear(state) { ++ initialScrollWatchdog.set(state, void 0); ++ }, ++ didReachTarget(newScroll, watchdog) { ++ const nextDistance = Math.abs(newScroll - watchdog.targetOffset); ++ return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ get(state) { ++ var _a3, _b; ++ return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; ++ }, ++ hasNonZeroTargetOffset(targetOffset) { ++ return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ isAtZeroTargetOffset(targetOffset) { ++ return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ set(state, watchdog) { ++ var _a3, _b; ++ if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { ++ return; + } +- } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { +- set$(ctx, "totalSize", totalSize); ++ const completion = ensureInitialScrollSessionCompletion(state); ++ completion.watchdog = watchdog ? { ++ startScroll: watchdog.startScroll, ++ targetOffset: watchdog.targetOffset ++ } : void 0; ++ } ++}; ++function setInitialScrollSession(state, options = {}) { ++ var _a3, _b, _c, _d; ++ const existingSession = state.initialScrollSession; ++ const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; ++ const completion = existingSession == null ? void 0 : existingSession.completion; ++ const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; ++ const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; ++ if (!kind) { ++ return clearInitialScrollSession(state); + } +-} +- +-// src/core/setSize.ts +-function setSize(ctx, itemKey, size, notifyTotalSize = true) { +- const state = ctx.state; +- const { sizes } = state; +- const previousSize = sizes.get(itemKey); +- const diff = previousSize !== void 0 ? size - previousSize : size; +- if (diff !== 0) { +- addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); + } +- sizes.set(itemKey, size); +-} +- +-// src/utils/helpers.ts +-function isFunction(obj) { +- return typeof obj === "function"; ++ const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }); ++ return state.initialScrollSession; + } +-function isArray(obj) { +- return Array.isArray(obj); ++var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++function clearAdaptiveRenderExitTimeout(ctx) { ++ ctx.state.scheduledWork.cancel("adaptiveRender"); + } +-var warned = /* @__PURE__ */ new Set(); +-function warnDevOnce(id, text) { +- if (IS_DEV && !warned.has(id)) { +- warned.add(id); +- console.warn(`[legend-list] ${text}`); ++function scheduleAdaptiveRenderExit(ctx, exitDelay) { ++ const state = ctx.state; ++ clearAdaptiveRenderExitTimeout(ctx); ++ if (exitDelay <= 0) { ++ setAdaptiveRender(ctx, "normal", "scroll"); ++ } else { ++ state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); + } + } +-function roundSize(size) { +- return Math.floor(size * 8) / 8; +-} +-function isNullOrUndefined(value) { +- return value === null || value === void 0; +-} +-function getPadding(s, type) { +- var _a3, _b, _c; +- const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; +- return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; +-} +-function extractPadding(style, contentContainerStyle, type) { +- return getPadding(style, type) + getPadding(contentContainerStyle, type); +-} +-function findContainerId(ctx, key) { ++function setAdaptiveRender(ctx, mode, reason) { + var _a3, _b; +- const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); +- if (directMatch !== void 0) { +- return directMatch; +- } +- const numContainers = peek$(ctx, "numContainers"); +- for (let i = 0; i < numContainers; i++) { +- const itemKey = peek$(ctx, `containerItemKey${i}`); +- if (itemKey === key) { +- return i; +- } ++ const previousMode = peek$(ctx, "adaptiveRender"); ++ if (previousMode !== mode) { ++ set$(ctx, "adaptiveRender", mode); ++ (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); + } +- return -1; + } +- +-// src/utils/getItemSize.ts +-function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++function resetAdaptiveRender(ctx) { + var _a3, _b; ++ clearAdaptiveRenderExitTimeout(ctx); ++ const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; ++ if (peek$(ctx, "adaptiveRender") !== mode) { ++ setAdaptiveRender(ctx, mode, "initial"); ++ } ++} ++function updateAdaptiveRender(ctx, scrollVelocity, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { getFixedItemSize, getItemType } = state.props; +- let size = key ? state.sizesKnown.get(key) : void 0; +- if (size === void 0 && key && getFixedItemSize) { +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); +- if (fixedSize !== void 0) { +- size = fixedSize + ctx.scrollAxisGap; +- state.sizesKnown.set(key, size); ++ const adaptiveRender = state.props.adaptiveRender; ++ const currentMode = peek$(ctx, "adaptiveRender"); ++ if (peek$(ctx, "readyToRender")) { ++ if (adaptiveRender) { ++ const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; ++ const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; ++ const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; ++ const threshold = currentMode === "light" ? exitVelocity : enterVelocity; ++ const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; ++ const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; ++ if (nextMode !== previousMode) { ++ if (nextMode === "light") { ++ setAdaptiveRender(ctx, "light", "scroll"); ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } else if (currentMode === "light") { ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } ++ } ++ } else { ++ resetAdaptiveRender(ctx); + } + } +- return size; + } +-function getKnownOrFixedItemSize(ctx, index) { +- const key = getId(ctx.state, index); +- return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++ ++// src/utils/getEffectiveDrawDistance.ts ++var INITIAL_DRAW_DISTANCE = 50; ++function getEffectiveDrawDistance(ctx, mode) { ++ var _a3; ++ const drawDistance = ctx.state.props.drawDistance; ++ const initialScroll = ctx.state.initialScroll; ++ const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; ++ const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; ++ return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; + } +-function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { +- for (let index = startIndex; index <= endIndex; index++) { +- if (getKnownOrFixedItemSize(ctx, index) === void 0) { +- return false; +- } ++function scheduleFullDrawDistancePrewarm(ctx) { ++ const { state } = ctx; ++ if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++ return; + } +- return true; ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); + } +-function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { +- var _a3, _b, _c, _d; +- const state = ctx.state; +- const { +- sizes, +- averageSizes, +- props: { estimatedItemSize, getItemType }, +- scrollingTo +- } = state; +- const sizeKnown = state.sizesKnown.get(key); +- if (sizeKnown !== void 0) { +- return sizeKnown; +- } +- let size; +- const renderedSize = sizes.get(key); +- if (preferCachedSize) { +- if (renderedSize !== void 0) { +- return renderedSize; +- } +- } +- size = getKnownOrFixedSize(ctx, key, index, data, resolved); +- if (size !== void 0) { +- setSize(ctx, key, size, notifyTotalSize); +- return size; +- } +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { +- const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0 && renderedSize !== void 0) { +- return renderedSize; +- } +- if (size === void 0 && useAverageSize && scrollingTo) { +- const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0) { +- size = estimatedItemSize + ctx.scrollAxisGap; ++ ++// src/utils/setInitialRenderState.ts ++function resetInitialRenderState(ctx, { ++ resetLayout, ++ resetInitialScroll ++}) { ++ const { state } = ctx; ++ if (resetLayout) { ++ state.didContainersLayout = false; ++ state.queuedInitialLayout = false; + } +- setSize(ctx, key, size, notifyTotalSize); +- return size; +-} +-function getItemSizeAtIndex(ctx, index) { +- if (index === void 0 || index < 0) { +- return void 0; ++ if (resetInitialScroll) { ++ state.didFinishInitialScroll = false; + } +- const targetId = getId(ctx.state, index); +- return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++ set$(ctx, "readyToRender", false); ++ resetAdaptiveRender(ctx); + } +- +-// src/core/calculateOffsetWithOffsetPosition.ts +-function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +- var _a3; +- const state = ctx.state; +- const { index, viewOffset, viewPosition } = params; +- let offset = offsetParam; +- if (viewOffset) { +- offset -= viewOffset; ++function setInitialRenderState(ctx, { ++ didLayout, ++ didInitialScroll ++}) { ++ const { state } = ctx; ++ const { ++ loadStartTime, ++ props: { onLoad } ++ } = state; ++ if (didLayout) { ++ state.didContainersLayout = true; + } +- if (index !== void 0) { +- const startOffsetAdjustment = getStartOffsetAdjustment(ctx); +- if (startOffsetAdjustment) { +- offset += startOffsetAdjustment; +- } ++ if (didInitialScroll) { ++ state.didFinishInitialScroll = true; + } +- if (viewPosition !== void 0 && index !== void 0) { +- const dataLength = state.props.data.length; +- if (dataLength === 0) { +- return offset; +- } +- const isOutOfBounds = index < 0 || index >= dataLength; +- const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; +- const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); +- const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); +- const trailingInset = getContentInsetEnd(ctx); +- offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); +- if (!isOutOfBounds && index === state.props.data.length - 1) { +- const footerSize = peek$(ctx, "footerSize") || 0; +- offset += footerSize; ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ if (isReadyToRender && !peek$(ctx, "readyToRender")) { ++ set$(ctx, "readyToRender", true); ++ setAdaptiveRender(ctx, "normal", "ready"); ++ if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { ++ scheduleFullDrawDistancePrewarm(ctx); ++ } ++ if (!state.didLoad) { ++ state.didLoad = true; ++ if (onLoad) { ++ onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); ++ } + } + } +- return offset; + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { +- const state = ctx.state; +- const contentSize = getContentSize(ctx); +- let clampedOffset = offset; +- if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { +- const baseMaxOffset = Math.max(0, 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); +- return clampedOffset; ++// src/core/finishInitialScroll.ts ++var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; ++function syncInitialScrollOffset(state, offset) { ++ state.scroll = offset; ++ state.scrollPending = offset; ++ state.scrollPrev = offset; + } +- +-// src/core/finishScrollTo.ts +-function finishScrollTo(ctx) { +- var _a3, _b; ++function clearPreservedInitialScrollTargetTimeout(state) { ++ state.scheduledWork.cancel("preservedInitialScroll"); ++} ++function clearPreservedInitialScrollTarget(state) { ++ clearPreservedInitialScrollTargetTimeout(state); ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ state.initialScroll = void 0; ++ setInitialScrollSession(state); ++} ++function supersedeInitialScroll(ctx) { ++ var _a3, _b, _c; + const state = ctx.state; +- if (state == null ? void 0 : state.scrollingTo) { +- cancelScrollCompletionChecks(state); +- const resolvePendingScroll = state.pendingScrollResolve; +- state.pendingScrollResolve = void 0; +- const scrollingTo = state.scrollingTo; +- state.scrollHistory.length = 0; +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- if (state.pendingTotalSize !== void 0) { +- addTotalSize(ctx, null, state.pendingTotalSize); +- } +- { +- state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; ++ if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { ++ if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { ++ cancelAnimationFrame(bootstrapInitialScroll.frameHandle); + } +- if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; +- finishInitialScroll(ctx, { +- onFinished: () => { +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); +- }, +- preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +- recalculateItems: true, +- schedulePreservedTargetClear: shouldPreserveResizeTarget, +- syncObservedOffset: isOffsetSession, +- waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame +- }); +- return; ++ if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { ++ cancelScrollCompletionChecks(state); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; + } +- recalculateSettledScroll(ctx); +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ initialScrollCompletion.resetFlags(state); ++ setInitialScrollSession(state, { bootstrap: null }); ++ finishInitialScroll(ctx); + } + } +- +-// src/core/doScrollTo.ts +-var SCROLL_END_IDLE_MS = 80; +-var SCROLL_END_MAX_MS = 1500; +-var SMOOTH_SCROLL_DURATION_MS = 320; +-var SCROLL_END_TARGET_EPSILON = 1; +-function doScrollTo(ctx, params) { +- var _a3, _b; ++function finishInitialScroll(ctx, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { animated, horizontal, offset } = params; +- state.scheduledWork.cancel("platformScrollCompletion"); +- const scroller = state.refScroller.current; +- const node = scroller == null ? void 0 : scroller.getScrollableNode(); +- if (!scroller || !node) { +- return; +- } +- const isAnimated = !!animated; +- const isHorizontal = !!horizontal; +- const contentSize = isHorizontal ? getContentSize(ctx) : void 0; +- const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; +- const top = isHorizontal ? 0 : offset; +- scroller.scrollTo({ animated: isAnimated, x: left, y: top }); +- if (isAnimated) { +- const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; +- listenForScrollEnd(ctx, { +- readOffset: () => scroller.getCurrentScrollOffset(), +- target, +- targetOffset: offset +- }); +- } else { +- state.scroll = offset; +- const targetToken = state.scrollingTo; +- state.scheduledWork.timeout( +- () => { +- if (targetToken === state.scrollingTo) { +- finishScrollTo(ctx); +- } +- }, +- 100, +- "platformScrollCompletion" +- ); +- } +-} +-function listenForScrollEnd(ctx, params) { +- const { readOffset, target, targetOffset } = params; +- if (!target) { +- finishScrollTo(ctx); +- return; +- } +- const supportsScrollEnd = "onscrollend" in target; +- let idleTimeout; +- let settled = false; +- const { scheduledWork, scrollingTo: targetToken } = ctx.state; +- const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); +- const cleanup = () => { +- target.removeEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.removeEventListener("scrollend", onScrollEnd); +- } +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); +- } +- clearTimeout(maxTimeout); +- }; +- const cancel = () => { +- if (!settled) { +- settled = true; +- cleanup(); ++ if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { ++ syncInitialScrollOffset(state, options.resolvedOffset); ++ } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { ++ const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); ++ if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { ++ syncInitialScrollOffset(state, observedOffset); + } +- }; +- const finish = (reason) => { +- if (settled) return; +- if (targetToken !== ctx.state.scrollingTo) { +- scheduledWork.cancel("platformScrollCompletion"); +- return; ++ } ++ const complete = () => { ++ var _a4, _b2, _c2, _d, _e; ++ const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; ++ const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; ++ initialScrollWatchdog.clear(state); ++ if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ setInitialScrollSession(state); ++ clearPreservedInitialScrollTargetTimeout(state); ++ if (options == null ? void 0 : options.schedulePreservedTargetClear) { ++ state.scheduledWork.timeout( ++ () => { ++ var _a5; ++ if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { ++ return; ++ } ++ clearPreservedInitialScrollTarget(state); ++ }, ++ PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, ++ "preservedInitialScroll" ++ ); ++ } ++ } else { ++ clearPreservedInitialScrollTarget(state); + } +- const currentOffset = readOffset(); +- const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; +- if (reason === "scrollend" && !isNearTarget) { +- return; ++ if (options == null ? void 0 : options.recalculateItems) { ++ recalculateSettledScroll(ctx); + } +- scheduledWork.cancel("platformScrollCompletion"); +- finishScrollTo(ctx); +- }; +- const onScroll2 = () => { +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); ++ setInitialRenderState(ctx, { didInitialScroll: true }); ++ if (shouldReleaseDeferredPublicOnScroll) { ++ releaseDeferredPublicOnScroll(ctx, finalScrollOffset); + } +- idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); + }; +- const onScrollEnd = () => finish("scrollend"); +- target.addEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.addEventListener("scrollend", onScrollEnd); +- } else { +- idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); ++ if (options == null ? void 0 : options.waitForCompletionFrame) { ++ requestAnimationFrame(complete); ++ return; + } +- scheduledWork.register("platformScrollCompletion", cancel); ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; + } + + // src/core/scrollRequestTracker.ts +@@ -1462,30 +1552,6 @@ function getScrollRequestTracker(ctx) { + return ctx.scrollRequestTracker; + } + +-// src/utils/requestAdjust.ts +-function requestAdjust(ctx, positionDiff, source) { +- const state = ctx.state; +- if (Math.abs(positionDiff) > 0.1) { +- const doit = () => { +- { +- state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } +- } +- }; +- state.scroll += positionDiff; +- state.scrollForNextCalculateItemsInView = void 0; +- const readyToRender = peek$(ctx, "readyToRender"); +- if (readyToRender) { +- doit(); +- } else { +- state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); +- } +- } +-} +- + // src/core/doMaintainScrollAtEnd.ts + function finishMaintainScrollAtEnd(ctx) { + var _a3; +@@ -2120,7 +2186,7 @@ function pinScrollTargetRenderRange(ctx, targetOffset, targetIndex) { + } + } + function scrollTo(ctx, params) { +- var _a3, _b; ++ var _a3, _b, _c; + const state = ctx.state; + const { noScrollingTo, forceScroll, ...scrollTarget } = params; + const { +@@ -2152,6 +2218,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (scrollTarget.index !== void 0 && scrollTarget.viewPosition !== void 0) { ++ beginScrollTargetSettle(ctx, { ++ index: scrollTarget.index, ++ viewOffset: (_b = scrollTarget.viewOffset) != null ? _b : 0, ++ viewPosition: scrollTarget.viewPosition ++ }); ++ } else { ++ clearScrollTargetSettle(state); ++ } + } + } + state.scrollPending = targetOffset; +@@ -2159,7 +2234,7 @@ function scrollTo(ctx, params) { + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state); ++ (_c = state.triggerCalculateItemsInView) == null ? void 0 : _c.call(state); + } + } else { + updateScroll(ctx, targetOffset, true, { markHasScrolled: false }); +@@ -4423,7 +4498,8 @@ function calculateItemsInView(ctx, params = {}) { + const scrollBeforeMVCP = state.scroll; + const scrollAdjustPendingBeforeMVCP = (_f = peek$(ctx, "scrollAdjustPending")) != null ? _f : 0; + checkMVCP == null ? void 0 : checkMVCP(); +- const didMVCPAdjustScroll = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); ++ const didSettleScrollTarget = suppressInitialScrollSideEffects ? false : settleScrollTarget(ctx); ++ const didMVCPAdjustScroll = didSettleScrollTarget || !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); + if (didMVCPAdjustScroll) { + updateScroll2(state.scroll); + updateScrollRange(); +@@ -6586,6 +6662,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { ++ var _a3; + const { state } = ctx; + const previousHeaderSize = peek$(ctx, "headerSize") || 0; + const didChange = previousHeaderSize !== size; +@@ -6595,6 +6672,9 @@ function setHeaderSize(ctx, size) { + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { + requestAdjust(ctx, size - previousHeaderSize); ++ } else if ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.onHeaderLayout) { ++ handleBootstrapInitialScrollLayoutChange(ctx); ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7658,13 +7738,14 @@ function getRenderedItem(ctx, key, containerId) { + + // src/utils/normalizeMaintainScrollAtEnd.ts + function normalizeMaintainScrollAtEndOn(on, hasExplicitOn) { +- var _a3, _b, _c, _d; ++ var _a3, _b, _c, _d, _e; + return { + animated: false, + onDataChange: hasExplicitOn ? (_a3 = on == null ? void 0 : on.dataChange) != null ? _a3 : false : true, + onFooterLayout: hasExplicitOn ? (_b = on == null ? void 0 : on.footerLayout) != null ? _b : false : true, +- onItemLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.itemLayout) != null ? _c : false : true, +- onLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.layout) != null ? _d : false : true ++ onHeaderLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.headerLayout) != null ? _c : false : true, ++ onItemLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.itemLayout) != null ? _d : false : true, ++ onLayout: hasExplicitOn ? (_e = on == null ? void 0 : on.layout) != null ? _e : false : true + }; + } + function normalizeMaintainScrollAtEnd(value) { +diff --git a/node_modules/@legendapp/list/react.d.ts b/node_modules/@legendapp/list/react.d.ts +index b6c7481..ace847e 100644 +--- a/node_modules/@legendapp/list/react.d.ts ++++ b/node_modules/@legendapp/list/react.d.ts +@@ -511,6 +511,12 @@ interface AlwaysRenderConfig { + interface MaintainScrollAtEndOnOptions { + dataChange?: boolean; + footerLayout?: boolean; ++ /** ++ * Keep the list pinned to the end when the header changes size. A header that measures larger ++ * than its estimate pushes every item down, which leaves a list that had already scrolled to ++ * its end short of it by the difference. ++ */ ++ headerLayout?: boolean; + itemLayout?: boolean; + layout?: boolean; + } +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index b2240b5..cbff6eb 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -430,171 +430,271 @@ var EDGE_POSITION_EPSILON = 1; + var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + +-// src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { +- scheduledWork.cancel("checkFinishedScrollFrame"); +- scheduledWork.cancel("checkFinishedScrollRetryFrame"); +- scheduledWork.cancel("checkFinishedScrollFallback"); +- scheduledWork.cancel("platformScrollCompletion"); +-} +-function settlePendingImperativeScroll(state) { +- var _a3, _b; +- const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; +- state.pendingScrollResolve = void 0; +- state.pendingScrollToEnd = void 0; +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++// src/core/getStartOffsetAdjustment.ts ++function getStartOffsetAdjustment(ctx) { ++ const { state } = ctx; ++ const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; ++ return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); + } +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- settlePendingImperativeScroll(state); ++ ++// src/utils/getId.ts ++function getId(state, index) { ++ const { data, keyExtractor } = state.props; ++ if (!data) { ++ return ""; ++ } ++ const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; ++ const id = ret; ++ state.idCache[index] = id; ++ return id; + } + +-// src/core/deferredPublicOnScroll.ts +-function withResolvedContentOffset(state, event, resolvedOffset) { +- return { +- ...event, +- nativeEvent: { +- ...event.nativeEvent, +- contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++// src/core/updateContentMetricsState.ts ++function getRawContentLength(ctx) { ++ var _a3, _b, _c; ++ const { state, values } = ctx; ++ return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++} ++function getAlignItemsAtEndPadding(ctx) { ++ const { state } = ctx; ++ const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++} ++function updateContentMetricsState(ctx) { ++ var _a3; ++ const { state } = ctx; ++ const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; ++ const nextPadding = getAlignItemsAtEndPadding(ctx); ++ if (previousPadding !== nextPadding) { ++ const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; ++ const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); ++ if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { ++ state.scheduledWork.microtask(() => { ++ if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { ++ set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); ++ } ++ }, "alignItemsAtEndPaddingFallback"); ++ } else if (!isMaintainExpected) { ++ set$(ctx, "alignItemsAtEndPadding", nextPadding); + } +- }; ++ } + } +-function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { +- var _a3, _b, _c, _d; ++ ++// src/core/addTotalSize.ts ++function addTotalSize(ctx, key, add, notifyTotalSize = true) { + const state = ctx.state; +- const deferredEvent = state.deferredPublicOnScrollEvent; +- state.deferredPublicOnScrollEvent = void 0; +- if (deferredEvent) { +- (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( +- _c, +- withResolvedContentOffset( +- state, +- deferredEvent, +- (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 +- ) +- ); ++ const prevTotalSize = state.totalSize; ++ let totalSize = state.totalSize; ++ if (key === null) { ++ totalSize = add; ++ if (state.timeoutSetPaddingTop) { ++ clearTimeout(state.timeoutSetPaddingTop); ++ state.timeoutSetPaddingTop = void 0; ++ } ++ } else { ++ totalSize += add; ++ } ++ if (prevTotalSize !== totalSize) { ++ { ++ state.pendingTotalSize = void 0; ++ state.totalSize = totalSize; ++ if (notifyTotalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } ++ updateContentMetricsState(ctx); ++ } ++ } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { ++ set$(ctx, "totalSize", totalSize); + } + } + +-// src/core/initialScrollSession.ts +-var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; +-function hasInitialScrollSessionCompletion(completion) { +- return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); ++// src/core/setSize.ts ++function setSize(ctx, itemKey, size, notifyTotalSize = true) { ++ const state = ctx.state; ++ const { sizes } = state; ++ const previousSize = sizes.get(itemKey); ++ const diff = previousSize !== void 0 ? size - previousSize : size; ++ if (diff !== 0) { ++ addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ } ++ sizes.set(itemKey, size); + } +-function clearInitialScrollSession(state) { +- state.initialScrollSession = void 0; +- return void 0; ++ ++// src/utils/helpers.ts ++function isFunction(obj) { ++ return typeof obj === "function"; + } +-function createInitialScrollSession(options) { +- const { bootstrap, completion, kind, previousDataLength } = options; +- return kind === "offset" ? { +- completion, +- kind, +- previousDataLength +- } : { +- bootstrap, +- completion, +- kind, +- previousDataLength +- }; ++function isArray(obj) { ++ return Array.isArray(obj); + } +-function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { +- var _a4, _b2; +- if (!state.initialScrollSession) { +- state.initialScrollSession = createInitialScrollSession({ +- completion: {}, +- kind, +- previousDataLength: 0 +- }); +- } else if (state.initialScrollSession.kind !== kind) { +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, +- completion: state.initialScrollSession.completion, +- kind, +- previousDataLength: state.initialScrollSession.previousDataLength +- }); ++var warned = /* @__PURE__ */ new Set(); ++function warnDevOnce(id, text) { ++ if (IS_DEV && !warned.has(id)) { ++ warned.add(id); ++ console.warn(`[legend-list] ${text}`); + } +- (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; +- return state.initialScrollSession.completion; + } +-var initialScrollCompletion = { +- didDispatchNativeScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); +- }, +- didRetrySilentInitialScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); +- }, +- markInitialScrollNativeDispatch(state) { +- ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; +- }, +- markSilentInitialScrollRetry(state) { +- ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; +- }, +- resetFlags(state) { +- if (!state.initialScrollSession) { +- return; ++function roundSize(size) { ++ return Math.floor(size * 8) / 8; ++} ++function isNullOrUndefined(value) { ++ return value === null || value === void 0; ++} ++function getPadding(s, type) { ++ var _a3, _b, _c; ++ const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; ++ return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; ++} ++function extractPadding(style, contentContainerStyle, type) { ++ return getPadding(style, type) + getPadding(contentContainerStyle, type); ++} ++function findContainerId(ctx, key) { ++ var _a3, _b; ++ const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); ++ if (directMatch !== void 0) { ++ return directMatch; ++ } ++ const numContainers = peek$(ctx, "numContainers"); ++ for (let i = 0; i < numContainers; i++) { ++ const itemKey = peek$(ctx, `containerItemKey${i}`); ++ if (itemKey === key) { ++ return i; + } +- const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); +- completion.didDispatchNativeScroll = void 0; +- completion.didRetrySilentInitialScroll = void 0; + } +-}; +-var initialScrollWatchdog = { +- clear(state) { +- initialScrollWatchdog.set(state, void 0); +- }, +- didReachTarget(newScroll, watchdog) { +- const nextDistance = Math.abs(newScroll - watchdog.targetOffset); +- return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- get(state) { +- var _a3, _b; +- return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; +- }, +- hasNonZeroTargetOffset(targetOffset) { +- return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- isAtZeroTargetOffset(targetOffset) { +- return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- set(state, watchdog) { +- var _a3, _b; +- if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { +- return; ++ return -1; ++} ++ ++// src/utils/getItemSize.ts ++function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { getFixedItemSize, getItemType } = state.props; ++ let size = key ? state.sizesKnown.get(key) : void 0; ++ if (size === void 0 && key && getFixedItemSize) { ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); ++ if (fixedSize !== void 0) { ++ size = fixedSize + ctx.scrollAxisGap; ++ state.sizesKnown.set(key, size); + } +- const completion = ensureInitialScrollSessionCompletion(state); +- completion.watchdog = watchdog ? { +- startScroll: watchdog.startScroll, +- targetOffset: watchdog.targetOffset +- } : void 0; + } +-}; +-function setInitialScrollSession(state, options = {}) { ++ return size; ++} ++function getKnownOrFixedItemSize(ctx, index) { ++ const key = getId(ctx.state, index); ++ return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++} ++function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { ++ for (let index = startIndex; index <= endIndex; index++) { ++ if (getKnownOrFixedItemSize(ctx, index) === void 0) { ++ return false; ++ } ++ } ++ return true; ++} ++function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { + var _a3, _b, _c, _d; +- const existingSession = state.initialScrollSession; +- const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; +- const completion = existingSession == null ? void 0 : existingSession.completion; +- const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; +- const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; +- if (!kind) { +- return clearInitialScrollSession(state); ++ const state = ctx.state; ++ const { ++ sizes, ++ averageSizes, ++ props: { estimatedItemSize, getItemType }, ++ scrollingTo ++ } = state; ++ const sizeKnown = state.sizesKnown.get(key); ++ if (sizeKnown !== void 0) { ++ return sizeKnown; + } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); ++ let size; ++ const renderedSize = sizes.get(key); ++ if (preferCachedSize) { ++ if (renderedSize !== void 0) { ++ return renderedSize; ++ } + } +- const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap, +- completion, +- kind, +- previousDataLength +- }); +- return state.initialScrollSession; ++ size = getKnownOrFixedSize(ctx, key, index, data, resolved); ++ if (size !== void 0) { ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++ } ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ if (useAverageSize && !scrollingTo) { ++ const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0 && renderedSize !== void 0) { ++ return renderedSize; ++ } ++ if (size === void 0 && useAverageSize && scrollingTo) { ++ const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0) { ++ size = estimatedItemSize + ctx.scrollAxisGap; ++ } ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++} ++function getItemSizeAtIndex(ctx, index) { ++ if (index === void 0 || index < 0) { ++ return void 0; ++ } ++ const targetId = getId(ctx.state, index); ++ return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++} ++ ++// src/core/calculateOffsetWithOffsetPosition.ts ++function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++ var _a3; ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ let offset = offsetParam; ++ if (viewOffset) { ++ offset -= viewOffset; ++ } ++ if (index !== void 0) { ++ const startOffsetAdjustment = getStartOffsetAdjustment(ctx); ++ if (startOffsetAdjustment) { ++ offset += startOffsetAdjustment; ++ } ++ } ++ if (viewPosition !== void 0 && index !== void 0) { ++ const dataLength = state.props.data.length; ++ if (dataLength === 0) { ++ return offset; ++ } ++ const isOutOfBounds = index < 0 || index >= dataLength; ++ const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; ++ const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); ++ const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); ++ const trailingInset = getContentInsetEnd(ctx); ++ offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); ++ if (!isOutOfBounds && index === state.props.data.length - 1) { ++ const footerSize = peek$(ctx, "footerSize") || 0; ++ offset += footerSize; ++ } ++ } ++ return offset; ++} ++ ++// src/core/clampScrollOffset.ts ++function clampScrollOffset(ctx, offset, scrollTarget) { ++ const state = ctx.state; ++ const contentSize = getContentSize(ctx); ++ let clampedOffset = offset; ++ if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { ++ const baseMaxOffset = Math.max(0, 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); ++ return clampedOffset; + } + + // src/utils/checkThreshold.ts +@@ -817,626 +917,616 @@ function recalculateSettledScroll(ctx) { + } + checkThresholds(ctx); + } +-var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-function clearAdaptiveRenderExitTimeout(ctx) { +- ctx.state.scheduledWork.cancel("adaptiveRender"); +-} +-function scheduleAdaptiveRenderExit(ctx, exitDelay) { +- const state = ctx.state; +- clearAdaptiveRenderExitTimeout(ctx); +- if (exitDelay <= 0) { +- setAdaptiveRender(ctx, "normal", "scroll"); +- } else { +- state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); +- } +-} +-function setAdaptiveRender(ctx, mode, reason) { +- var _a3, _b; +- const previousMode = peek$(ctx, "adaptiveRender"); +- if (previousMode !== mode) { +- set$(ctx, "adaptiveRender", mode); +- (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); +- } +-} +-function resetAdaptiveRender(ctx) { ++ ++// src/core/finishScrollTo.ts ++function finishScrollTo(ctx) { + var _a3, _b; +- clearAdaptiveRenderExitTimeout(ctx); +- const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; +- if (peek$(ctx, "adaptiveRender") !== mode) { +- setAdaptiveRender(ctx, mode, "initial"); +- } +-} +-function updateAdaptiveRender(ctx, scrollVelocity, options) { +- var _a3, _b, _c; + const state = ctx.state; +- const adaptiveRender = state.props.adaptiveRender; +- const currentMode = peek$(ctx, "adaptiveRender"); +- if (peek$(ctx, "readyToRender")) { +- if (adaptiveRender) { +- const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; +- const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; +- const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; +- const threshold = currentMode === "light" ? exitVelocity : enterVelocity; +- const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; +- const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; +- if (nextMode !== previousMode) { +- if (nextMode === "light") { +- setAdaptiveRender(ctx, "light", "scroll"); +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } else if (currentMode === "light") { +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } +- } +- } else { +- resetAdaptiveRender(ctx); ++ if (state == null ? void 0 : state.scrollingTo) { ++ cancelScrollCompletionChecks(state); ++ const resolvePendingScroll = state.pendingScrollResolve; ++ state.pendingScrollResolve = void 0; ++ const scrollingTo = state.scrollingTo; ++ state.scrollHistory.length = 0; ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ if (state.pendingTotalSize !== void 0) { ++ addTotalSize(ctx, null, state.pendingTotalSize); ++ } ++ { ++ state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ } ++ if (scrollingTo.isInitialScroll || state.initialScroll) { ++ const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; ++ finishInitialScroll(ctx, { ++ onFinished: () => { ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ }, ++ preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, ++ recalculateItems: true, ++ schedulePreservedTargetClear: shouldPreserveResizeTarget, ++ syncObservedOffset: isOffsetSession, ++ waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame ++ }); ++ return; + } ++ recalculateSettledScroll(ctx); ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); + } + } + +-// src/utils/getEffectiveDrawDistance.ts +-var INITIAL_DRAW_DISTANCE = 50; +-function getEffectiveDrawDistance(ctx, mode) { +- var _a3; +- const drawDistance = ctx.state.props.drawDistance; +- const initialScroll = ctx.state.initialScroll; +- const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; +- const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; +- return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; +-} +-function scheduleFullDrawDistancePrewarm(ctx) { +- const { state } = ctx; +- if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++// src/core/doScrollTo.ts ++var SCROLL_END_IDLE_MS = 80; ++var SCROLL_END_MAX_MS = 1500; ++var SMOOTH_SCROLL_DURATION_MS = 320; ++var SCROLL_END_TARGET_EPSILON = 1; ++function doScrollTo(ctx, params) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { animated, horizontal, offset } = params; ++ state.scheduledWork.cancel("platformScrollCompletion"); ++ const scroller = state.refScroller.current; ++ const node = scroller == null ? void 0 : scroller.getScrollableNode(); ++ if (!scroller || !node) { + return; + } +- state.scheduledWork.frame(() => { +- var _a3; +- return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); +- }, "fullDrawDistancePrewarm"); +-} +- +-// src/utils/setInitialRenderState.ts +-function resetInitialRenderState(ctx, { +- resetLayout, +- resetInitialScroll +-}) { +- const { state } = ctx; +- if (resetLayout) { +- state.didContainersLayout = false; +- state.queuedInitialLayout = false; +- } +- if (resetInitialScroll) { +- state.didFinishInitialScroll = false; ++ const isAnimated = !!animated; ++ const isHorizontal = !!horizontal; ++ const contentSize = isHorizontal ? getContentSize(ctx) : void 0; ++ const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; ++ const top = isHorizontal ? 0 : offset; ++ scroller.scrollTo({ animated: isAnimated, x: left, y: top }); ++ if (isAnimated) { ++ const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; ++ listenForScrollEnd(ctx, { ++ readOffset: () => scroller.getCurrentScrollOffset(), ++ target, ++ targetOffset: offset ++ }); ++ } else { ++ state.scroll = offset; ++ const targetToken = state.scrollingTo; ++ state.scheduledWork.timeout( ++ () => { ++ if (targetToken === state.scrollingTo) { ++ finishScrollTo(ctx); ++ } ++ }, ++ 100, ++ "platformScrollCompletion" ++ ); + } +- set$(ctx, "readyToRender", false); +- resetAdaptiveRender(ctx); + } +-function setInitialRenderState(ctx, { +- didLayout, +- didInitialScroll +-}) { +- const { state } = ctx; +- const { +- loadStartTime, +- props: { onLoad } +- } = state; +- if (didLayout) { +- state.didContainersLayout = true; +- } +- if (didInitialScroll) { +- state.didFinishInitialScroll = true; ++function listenForScrollEnd(ctx, params) { ++ const { readOffset, target, targetOffset } = params; ++ if (!target) { ++ finishScrollTo(ctx); ++ return; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); +- if (isReadyToRender && !peek$(ctx, "readyToRender")) { +- set$(ctx, "readyToRender", true); +- setAdaptiveRender(ctx, "normal", "ready"); +- if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { +- scheduleFullDrawDistancePrewarm(ctx); ++ const supportsScrollEnd = "onscrollend" in target; ++ let idleTimeout; ++ let settled = false; ++ const { scheduledWork, scrollingTo: targetToken } = ctx.state; ++ const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); ++ const cleanup = () => { ++ target.removeEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.removeEventListener("scrollend", onScrollEnd); + } +- if (!state.didLoad) { +- state.didLoad = true; +- if (onLoad) { +- onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); +- } ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); + } +- } +-} +- +-// src/core/finishInitialScroll.ts +-var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; +-function syncInitialScrollOffset(state, offset) { +- state.scroll = offset; +- state.scrollPending = offset; +- state.scrollPrev = offset; +-} +-function clearPreservedInitialScrollTargetTimeout(state) { +- state.scheduledWork.cancel("preservedInitialScroll"); +-} +-function clearPreservedInitialScrollTarget(state) { +- clearPreservedInitialScrollTargetTimeout(state); +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- state.initialScroll = void 0; +- setInitialScrollSession(state); +-} +-function supersedeInitialScroll(ctx) { +- var _a3, _b, _c; +- const state = ctx.state; +- const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; +- if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { +- if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { +- cancelAnimationFrame(bootstrapInitialScroll.frameHandle); ++ clearTimeout(maxTimeout); ++ }; ++ const cancel = () => { ++ if (!settled) { ++ settled = true; ++ cleanup(); + } +- if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { +- cancelScrollCompletionChecks(state); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; ++ }; ++ const finish = (reason) => { ++ if (settled) return; ++ if (targetToken !== ctx.state.scrollingTo) { ++ scheduledWork.cancel("platformScrollCompletion"); ++ return; + } +- initialScrollCompletion.resetFlags(state); +- setInitialScrollSession(state, { bootstrap: null }); +- finishInitialScroll(ctx); ++ const currentOffset = readOffset(); ++ const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; ++ if (reason === "scrollend" && !isNearTarget) { ++ return; ++ } ++ scheduledWork.cancel("platformScrollCompletion"); ++ finishScrollTo(ctx); ++ }; ++ const onScroll2 = () => { ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); ++ } ++ idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ }; ++ const onScrollEnd = () => finish("scrollend"); ++ target.addEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.addEventListener("scrollend", onScrollEnd); ++ } else { ++ idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); + } ++ scheduledWork.register("platformScrollCompletion", cancel); + } +-function finishInitialScroll(ctx, options) { +- var _a3, _b, _c; ++ ++// src/utils/requestAdjust.ts ++function requestAdjust(ctx, positionDiff, source) { + const state = ctx.state; +- if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { +- syncInitialScrollOffset(state, options.resolvedOffset); +- } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { +- const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); +- if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { +- syncInitialScrollOffset(state, observedOffset); +- } +- } +- const complete = () => { +- var _a4, _b2, _c2, _d, _e; +- const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; +- const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; +- initialScrollWatchdog.clear(state); +- if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- setInitialScrollSession(state); +- clearPreservedInitialScrollTargetTimeout(state); +- if (options == null ? void 0 : options.schedulePreservedTargetClear) { +- state.scheduledWork.timeout( +- () => { +- var _a5; +- if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { +- return; +- } +- clearPreservedInitialScrollTarget(state); +- }, +- PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, +- "preservedInitialScroll" +- ); ++ if (Math.abs(positionDiff) > 0.1) { ++ const doit = () => { ++ { ++ state.scrollAdjustHandler.requestAdjust(positionDiff); ++ if (state.adjustingFromInitialMount) { ++ state.adjustingFromInitialMount--; ++ } + } ++ }; ++ state.scroll += positionDiff; ++ state.scrollForNextCalculateItemsInView = void 0; ++ const readyToRender = peek$(ctx, "readyToRender"); ++ if (readyToRender) { ++ doit(); + } else { +- clearPreservedInitialScrollTarget(state); +- } +- if (options == null ? void 0 : options.recalculateItems) { +- recalculateSettledScroll(ctx); +- } +- setInitialRenderState(ctx, { didInitialScroll: true }); +- if (shouldReleaseDeferredPublicOnScroll) { +- releaseDeferredPublicOnScroll(ctx, finalScrollOffset); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ requestAnimationFrame(doit); + } +- (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); +- }; +- if (options == null ? void 0 : options.waitForCompletionFrame) { +- requestAnimationFrame(complete); +- return; + } +- complete(); + } + +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 0.5; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_TTL_MS = 500; ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++} ++function beginScrollTargetSettle(ctx, params) { + const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; ++ const { index, viewOffset, viewPosition } = params; ++ const { data } = state.props; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ state.scrollTargetSettle = { ++ expiresAt: Date.now() + SETTLE_TTL_MS, ++ id: getId(state, index), ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++} ++function settleScrollTarget(ctx) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const index = state.indexByKey.get(settle.id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const now = Date.now(); ++ if (now > settle.expiresAt) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ const diff = targetOffset - state.scroll; ++ if (Math.abs(diff) <= SETTLE_POSITION_EPSILON) { ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ settle.expiresAt = now + SETTLE_TTL_MS; ++ if (((_a3 = state.scrollingTo) == null ? void 0 : _a3.index) === index) { ++ state.scrollingTo.offset = position; ++ state.scrollingTo.targetOffset = targetOffset; ++ } ++ requestAdjust(ctx, diff); ++ return true; + } + +-// src/core/getStartOffsetAdjustment.ts +-function getStartOffsetAdjustment(ctx) { +- const { state } = ctx; +- const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; +- return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); ++// src/core/cancelImperativeScroll.ts ++function cancelScrollCompletionChecks({ scheduledWork }) { ++ scheduledWork.cancel("checkFinishedScrollFrame"); ++ scheduledWork.cancel("checkFinishedScrollRetryFrame"); ++ scheduledWork.cancel("checkFinishedScrollFallback"); ++ scheduledWork.cancel("platformScrollCompletion"); ++} ++function settlePendingImperativeScroll(state) { ++ var _a3, _b; ++ const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; ++ state.pendingScrollResolve = void 0; ++ state.pendingScrollToEnd = void 0; ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++} ++function cancelImperativeScroll(state) { ++ cancelScrollCompletionChecks(state); ++ state.scheduledWork.cancel("imperativeScrollReady"); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ clearScrollTargetSettle(state); ++ settlePendingImperativeScroll(state); + } + +-// src/utils/getId.ts +-function getId(state, index) { +- const { data, keyExtractor } = state.props; +- if (!data) { +- return ""; ++// src/core/deferredPublicOnScroll.ts ++function withResolvedContentOffset(state, event, resolvedOffset) { ++ return { ++ ...event, ++ nativeEvent: { ++ ...event.nativeEvent, ++ contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++ } ++ }; ++} ++function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { ++ var _a3, _b, _c, _d; ++ const state = ctx.state; ++ const deferredEvent = state.deferredPublicOnScrollEvent; ++ state.deferredPublicOnScrollEvent = void 0; ++ if (deferredEvent) { ++ (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( ++ _c, ++ withResolvedContentOffset( ++ state, ++ deferredEvent, ++ (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 ++ ) ++ ); + } +- const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; +- const id = ret; +- state.idCache[index] = id; +- return id; + } + +-// src/core/updateContentMetricsState.ts +-function getRawContentLength(ctx) { +- var _a3, _b, _c; +- const { state, values } = ctx; +- return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++// src/core/initialScrollSession.ts ++var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; ++function hasInitialScrollSessionCompletion(completion) { ++ return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); + } +-function getAlignItemsAtEndPadding(ctx) { +- const { state } = ctx; +- const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++function clearInitialScrollSession(state) { ++ state.initialScrollSession = void 0; ++ return void 0; + } +-function updateContentMetricsState(ctx) { +- var _a3; +- const { state } = ctx; +- const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +- const nextPadding = getAlignItemsAtEndPadding(ctx); +- if (previousPadding !== nextPadding) { +- const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; +- const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); +- if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { +- state.scheduledWork.microtask(() => { +- if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { +- set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); +- } +- }, "alignItemsAtEndPaddingFallback"); +- } else if (!isMaintainExpected) { +- set$(ctx, "alignItemsAtEndPadding", nextPadding); +- } ++function createInitialScrollSession(options) { ++ const { bootstrap, completion, kind, previousDataLength } = options; ++ return kind === "offset" ? { ++ completion, ++ kind, ++ previousDataLength ++ } : { ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }; ++} ++function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { ++ var _a4, _b2; ++ if (!state.initialScrollSession) { ++ state.initialScrollSession = createInitialScrollSession({ ++ completion: {}, ++ kind, ++ previousDataLength: 0 ++ }); ++ } else if (state.initialScrollSession.kind !== kind) { ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, ++ completion: state.initialScrollSession.completion, ++ kind, ++ previousDataLength: state.initialScrollSession.previousDataLength ++ }); + } ++ (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; ++ return state.initialScrollSession.completion; + } +- +-// src/core/addTotalSize.ts +-function addTotalSize(ctx, key, add, notifyTotalSize = true) { +- const state = ctx.state; +- const prevTotalSize = state.totalSize; +- let totalSize = state.totalSize; +- if (key === null) { +- totalSize = add; +- if (state.timeoutSetPaddingTop) { +- clearTimeout(state.timeoutSetPaddingTop); +- state.timeoutSetPaddingTop = void 0; ++var initialScrollCompletion = { ++ didDispatchNativeScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); ++ }, ++ didRetrySilentInitialScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); ++ }, ++ markInitialScrollNativeDispatch(state) { ++ ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; ++ }, ++ markSilentInitialScrollRetry(state) { ++ ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; ++ }, ++ resetFlags(state) { ++ if (!state.initialScrollSession) { ++ return; + } +- } else { +- totalSize += add; ++ const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); ++ completion.didDispatchNativeScroll = void 0; ++ completion.didRetrySilentInitialScroll = void 0; + } +- if (prevTotalSize !== totalSize) { +- { +- state.pendingTotalSize = void 0; +- state.totalSize = totalSize; +- if (notifyTotalSize) { +- set$(ctx, "totalSize", totalSize); +- } +- updateContentMetricsState(ctx); ++}; ++var initialScrollWatchdog = { ++ clear(state) { ++ initialScrollWatchdog.set(state, void 0); ++ }, ++ didReachTarget(newScroll, watchdog) { ++ const nextDistance = Math.abs(newScroll - watchdog.targetOffset); ++ return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ get(state) { ++ var _a3, _b; ++ return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; ++ }, ++ hasNonZeroTargetOffset(targetOffset) { ++ return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ isAtZeroTargetOffset(targetOffset) { ++ return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ set(state, watchdog) { ++ var _a3, _b; ++ if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { ++ return; + } +- } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { +- set$(ctx, "totalSize", totalSize); ++ const completion = ensureInitialScrollSessionCompletion(state); ++ completion.watchdog = watchdog ? { ++ startScroll: watchdog.startScroll, ++ targetOffset: watchdog.targetOffset ++ } : void 0; ++ } ++}; ++function setInitialScrollSession(state, options = {}) { ++ var _a3, _b, _c, _d; ++ const existingSession = state.initialScrollSession; ++ const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; ++ const completion = existingSession == null ? void 0 : existingSession.completion; ++ const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; ++ const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; ++ if (!kind) { ++ return clearInitialScrollSession(state); + } +-} +- +-// src/core/setSize.ts +-function setSize(ctx, itemKey, size, notifyTotalSize = true) { +- const state = ctx.state; +- const { sizes } = state; +- const previousSize = sizes.get(itemKey); +- const diff = previousSize !== void 0 ? size - previousSize : size; +- if (diff !== 0) { +- addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); + } +- sizes.set(itemKey, size); +-} +- +-// src/utils/helpers.ts +-function isFunction(obj) { +- return typeof obj === "function"; ++ const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }); ++ return state.initialScrollSession; + } +-function isArray(obj) { +- return Array.isArray(obj); ++var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++function clearAdaptiveRenderExitTimeout(ctx) { ++ ctx.state.scheduledWork.cancel("adaptiveRender"); + } +-var warned = /* @__PURE__ */ new Set(); +-function warnDevOnce(id, text) { +- if (IS_DEV && !warned.has(id)) { +- warned.add(id); +- console.warn(`[legend-list] ${text}`); ++function scheduleAdaptiveRenderExit(ctx, exitDelay) { ++ const state = ctx.state; ++ clearAdaptiveRenderExitTimeout(ctx); ++ if (exitDelay <= 0) { ++ setAdaptiveRender(ctx, "normal", "scroll"); ++ } else { ++ state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); + } + } +-function roundSize(size) { +- return Math.floor(size * 8) / 8; +-} +-function isNullOrUndefined(value) { +- return value === null || value === void 0; +-} +-function getPadding(s, type) { +- var _a3, _b, _c; +- const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; +- return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; +-} +-function extractPadding(style, contentContainerStyle, type) { +- return getPadding(style, type) + getPadding(contentContainerStyle, type); +-} +-function findContainerId(ctx, key) { ++function setAdaptiveRender(ctx, mode, reason) { + var _a3, _b; +- const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); +- if (directMatch !== void 0) { +- return directMatch; +- } +- const numContainers = peek$(ctx, "numContainers"); +- for (let i = 0; i < numContainers; i++) { +- const itemKey = peek$(ctx, `containerItemKey${i}`); +- if (itemKey === key) { +- return i; +- } ++ const previousMode = peek$(ctx, "adaptiveRender"); ++ if (previousMode !== mode) { ++ set$(ctx, "adaptiveRender", mode); ++ (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); + } +- return -1; + } +- +-// src/utils/getItemSize.ts +-function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++function resetAdaptiveRender(ctx) { + var _a3, _b; ++ clearAdaptiveRenderExitTimeout(ctx); ++ const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; ++ if (peek$(ctx, "adaptiveRender") !== mode) { ++ setAdaptiveRender(ctx, mode, "initial"); ++ } ++} ++function updateAdaptiveRender(ctx, scrollVelocity, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { getFixedItemSize, getItemType } = state.props; +- let size = key ? state.sizesKnown.get(key) : void 0; +- if (size === void 0 && key && getFixedItemSize) { +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); +- if (fixedSize !== void 0) { +- size = fixedSize + ctx.scrollAxisGap; +- state.sizesKnown.set(key, size); ++ const adaptiveRender = state.props.adaptiveRender; ++ const currentMode = peek$(ctx, "adaptiveRender"); ++ if (peek$(ctx, "readyToRender")) { ++ if (adaptiveRender) { ++ const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; ++ const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; ++ const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; ++ const threshold = currentMode === "light" ? exitVelocity : enterVelocity; ++ const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; ++ const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; ++ if (nextMode !== previousMode) { ++ if (nextMode === "light") { ++ setAdaptiveRender(ctx, "light", "scroll"); ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } else if (currentMode === "light") { ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } ++ } ++ } else { ++ resetAdaptiveRender(ctx); + } + } +- return size; + } +-function getKnownOrFixedItemSize(ctx, index) { +- const key = getId(ctx.state, index); +- return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++ ++// src/utils/getEffectiveDrawDistance.ts ++var INITIAL_DRAW_DISTANCE = 50; ++function getEffectiveDrawDistance(ctx, mode) { ++ var _a3; ++ const drawDistance = ctx.state.props.drawDistance; ++ const initialScroll = ctx.state.initialScroll; ++ const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; ++ const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; ++ return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; + } +-function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { +- for (let index = startIndex; index <= endIndex; index++) { +- if (getKnownOrFixedItemSize(ctx, index) === void 0) { +- return false; +- } ++function scheduleFullDrawDistancePrewarm(ctx) { ++ const { state } = ctx; ++ if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++ return; + } +- return true; ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); + } +-function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { +- var _a3, _b, _c, _d; +- const state = ctx.state; +- const { +- sizes, +- averageSizes, +- props: { estimatedItemSize, getItemType }, +- scrollingTo +- } = state; +- const sizeKnown = state.sizesKnown.get(key); +- if (sizeKnown !== void 0) { +- return sizeKnown; +- } +- let size; +- const renderedSize = sizes.get(key); +- if (preferCachedSize) { +- if (renderedSize !== void 0) { +- return renderedSize; +- } +- } +- size = getKnownOrFixedSize(ctx, key, index, data, resolved); +- if (size !== void 0) { +- setSize(ctx, key, size, notifyTotalSize); +- return size; +- } +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { +- const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0 && renderedSize !== void 0) { +- return renderedSize; +- } +- if (size === void 0 && useAverageSize && scrollingTo) { +- const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0) { +- size = estimatedItemSize + ctx.scrollAxisGap; ++ ++// src/utils/setInitialRenderState.ts ++function resetInitialRenderState(ctx, { ++ resetLayout, ++ resetInitialScroll ++}) { ++ const { state } = ctx; ++ if (resetLayout) { ++ state.didContainersLayout = false; ++ state.queuedInitialLayout = false; + } +- setSize(ctx, key, size, notifyTotalSize); +- return size; +-} +-function getItemSizeAtIndex(ctx, index) { +- if (index === void 0 || index < 0) { +- return void 0; ++ if (resetInitialScroll) { ++ state.didFinishInitialScroll = false; + } +- const targetId = getId(ctx.state, index); +- return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++ set$(ctx, "readyToRender", false); ++ resetAdaptiveRender(ctx); + } +- +-// src/core/calculateOffsetWithOffsetPosition.ts +-function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +- var _a3; +- const state = ctx.state; +- const { index, viewOffset, viewPosition } = params; +- let offset = offsetParam; +- if (viewOffset) { +- offset -= viewOffset; ++function setInitialRenderState(ctx, { ++ didLayout, ++ didInitialScroll ++}) { ++ const { state } = ctx; ++ const { ++ loadStartTime, ++ props: { onLoad } ++ } = state; ++ if (didLayout) { ++ state.didContainersLayout = true; + } +- if (index !== void 0) { +- const startOffsetAdjustment = getStartOffsetAdjustment(ctx); +- if (startOffsetAdjustment) { +- offset += startOffsetAdjustment; +- } ++ if (didInitialScroll) { ++ state.didFinishInitialScroll = true; + } +- if (viewPosition !== void 0 && index !== void 0) { +- const dataLength = state.props.data.length; +- if (dataLength === 0) { +- return offset; +- } +- const isOutOfBounds = index < 0 || index >= dataLength; +- const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; +- const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); +- const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); +- const trailingInset = getContentInsetEnd(ctx); +- offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); +- if (!isOutOfBounds && index === state.props.data.length - 1) { +- const footerSize = peek$(ctx, "footerSize") || 0; +- offset += footerSize; ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ if (isReadyToRender && !peek$(ctx, "readyToRender")) { ++ set$(ctx, "readyToRender", true); ++ setAdaptiveRender(ctx, "normal", "ready"); ++ if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { ++ scheduleFullDrawDistancePrewarm(ctx); ++ } ++ if (!state.didLoad) { ++ state.didLoad = true; ++ if (onLoad) { ++ onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); ++ } + } + } +- return offset; + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { +- const state = ctx.state; +- const contentSize = getContentSize(ctx); +- let clampedOffset = offset; +- if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { +- const baseMaxOffset = Math.max(0, 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); +- return clampedOffset; ++// src/core/finishInitialScroll.ts ++var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; ++function syncInitialScrollOffset(state, offset) { ++ state.scroll = offset; ++ state.scrollPending = offset; ++ state.scrollPrev = offset; + } +- +-// src/core/finishScrollTo.ts +-function finishScrollTo(ctx) { +- var _a3, _b; ++function clearPreservedInitialScrollTargetTimeout(state) { ++ state.scheduledWork.cancel("preservedInitialScroll"); ++} ++function clearPreservedInitialScrollTarget(state) { ++ clearPreservedInitialScrollTargetTimeout(state); ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ state.initialScroll = void 0; ++ setInitialScrollSession(state); ++} ++function supersedeInitialScroll(ctx) { ++ var _a3, _b, _c; + const state = ctx.state; +- if (state == null ? void 0 : state.scrollingTo) { +- cancelScrollCompletionChecks(state); +- const resolvePendingScroll = state.pendingScrollResolve; +- state.pendingScrollResolve = void 0; +- const scrollingTo = state.scrollingTo; +- state.scrollHistory.length = 0; +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- if (state.pendingTotalSize !== void 0) { +- addTotalSize(ctx, null, state.pendingTotalSize); +- } +- { +- state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; ++ if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { ++ if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { ++ cancelAnimationFrame(bootstrapInitialScroll.frameHandle); + } +- if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; +- finishInitialScroll(ctx, { +- onFinished: () => { +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); +- }, +- preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +- recalculateItems: true, +- schedulePreservedTargetClear: shouldPreserveResizeTarget, +- syncObservedOffset: isOffsetSession, +- waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame +- }); +- return; ++ if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { ++ cancelScrollCompletionChecks(state); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; + } +- recalculateSettledScroll(ctx); +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ initialScrollCompletion.resetFlags(state); ++ setInitialScrollSession(state, { bootstrap: null }); ++ finishInitialScroll(ctx); + } + } +- +-// src/core/doScrollTo.ts +-var SCROLL_END_IDLE_MS = 80; +-var SCROLL_END_MAX_MS = 1500; +-var SMOOTH_SCROLL_DURATION_MS = 320; +-var SCROLL_END_TARGET_EPSILON = 1; +-function doScrollTo(ctx, params) { +- var _a3, _b; ++function finishInitialScroll(ctx, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { animated, horizontal, offset } = params; +- state.scheduledWork.cancel("platformScrollCompletion"); +- const scroller = state.refScroller.current; +- const node = scroller == null ? void 0 : scroller.getScrollableNode(); +- if (!scroller || !node) { +- return; +- } +- const isAnimated = !!animated; +- const isHorizontal = !!horizontal; +- const contentSize = isHorizontal ? getContentSize(ctx) : void 0; +- const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; +- const top = isHorizontal ? 0 : offset; +- scroller.scrollTo({ animated: isAnimated, x: left, y: top }); +- if (isAnimated) { +- const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; +- listenForScrollEnd(ctx, { +- readOffset: () => scroller.getCurrentScrollOffset(), +- target, +- targetOffset: offset +- }); +- } else { +- state.scroll = offset; +- const targetToken = state.scrollingTo; +- state.scheduledWork.timeout( +- () => { +- if (targetToken === state.scrollingTo) { +- finishScrollTo(ctx); +- } +- }, +- 100, +- "platformScrollCompletion" +- ); +- } +-} +-function listenForScrollEnd(ctx, params) { +- const { readOffset, target, targetOffset } = params; +- if (!target) { +- finishScrollTo(ctx); +- return; +- } +- const supportsScrollEnd = "onscrollend" in target; +- let idleTimeout; +- let settled = false; +- const { scheduledWork, scrollingTo: targetToken } = ctx.state; +- const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); +- const cleanup = () => { +- target.removeEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.removeEventListener("scrollend", onScrollEnd); +- } +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); +- } +- clearTimeout(maxTimeout); +- }; +- const cancel = () => { +- if (!settled) { +- settled = true; +- cleanup(); ++ if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { ++ syncInitialScrollOffset(state, options.resolvedOffset); ++ } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { ++ const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); ++ if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { ++ syncInitialScrollOffset(state, observedOffset); + } +- }; +- const finish = (reason) => { +- if (settled) return; +- if (targetToken !== ctx.state.scrollingTo) { +- scheduledWork.cancel("platformScrollCompletion"); +- return; ++ } ++ const complete = () => { ++ var _a4, _b2, _c2, _d, _e; ++ const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; ++ const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; ++ initialScrollWatchdog.clear(state); ++ if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ setInitialScrollSession(state); ++ clearPreservedInitialScrollTargetTimeout(state); ++ if (options == null ? void 0 : options.schedulePreservedTargetClear) { ++ state.scheduledWork.timeout( ++ () => { ++ var _a5; ++ if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { ++ return; ++ } ++ clearPreservedInitialScrollTarget(state); ++ }, ++ PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, ++ "preservedInitialScroll" ++ ); ++ } ++ } else { ++ clearPreservedInitialScrollTarget(state); + } +- const currentOffset = readOffset(); +- const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; +- if (reason === "scrollend" && !isNearTarget) { +- return; ++ if (options == null ? void 0 : options.recalculateItems) { ++ recalculateSettledScroll(ctx); + } +- scheduledWork.cancel("platformScrollCompletion"); +- finishScrollTo(ctx); +- }; +- const onScroll2 = () => { +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); ++ setInitialRenderState(ctx, { didInitialScroll: true }); ++ if (shouldReleaseDeferredPublicOnScroll) { ++ releaseDeferredPublicOnScroll(ctx, finalScrollOffset); + } +- idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); + }; +- const onScrollEnd = () => finish("scrollend"); +- target.addEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.addEventListener("scrollend", onScrollEnd); +- } else { +- idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); ++ if (options == null ? void 0 : options.waitForCompletionFrame) { ++ requestAnimationFrame(complete); ++ return; + } +- scheduledWork.register("platformScrollCompletion", cancel); ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; + } + + // src/core/scrollRequestTracker.ts +@@ -1483,30 +1573,6 @@ function getScrollRequestTracker(ctx) { + return ctx.scrollRequestTracker; + } + +-// src/utils/requestAdjust.ts +-function requestAdjust(ctx, positionDiff, source) { +- const state = ctx.state; +- if (Math.abs(positionDiff) > 0.1) { +- const doit = () => { +- { +- state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } +- } +- }; +- state.scroll += positionDiff; +- state.scrollForNextCalculateItemsInView = void 0; +- const readyToRender = peek$(ctx, "readyToRender"); +- if (readyToRender) { +- doit(); +- } else { +- state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); +- } +- } +-} +- + // src/core/doMaintainScrollAtEnd.ts + function finishMaintainScrollAtEnd(ctx) { + var _a3; +@@ -2141,7 +2207,7 @@ function pinScrollTargetRenderRange(ctx, targetOffset, targetIndex) { + } + } + function scrollTo(ctx, params) { +- var _a3, _b; ++ var _a3, _b, _c; + const state = ctx.state; + const { noScrollingTo, forceScroll, ...scrollTarget } = params; + const { +@@ -2173,6 +2239,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (scrollTarget.index !== void 0 && scrollTarget.viewPosition !== void 0) { ++ beginScrollTargetSettle(ctx, { ++ index: scrollTarget.index, ++ viewOffset: (_b = scrollTarget.viewOffset) != null ? _b : 0, ++ viewPosition: scrollTarget.viewPosition ++ }); ++ } else { ++ clearScrollTargetSettle(state); ++ } + } + } + state.scrollPending = targetOffset; +@@ -2180,7 +2255,7 @@ function scrollTo(ctx, params) { + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state); ++ (_c = state.triggerCalculateItemsInView) == null ? void 0 : _c.call(state); + } + } else { + updateScroll(ctx, targetOffset, true, { markHasScrolled: false }); +@@ -4444,7 +4519,8 @@ function calculateItemsInView(ctx, params = {}) { + const scrollBeforeMVCP = state.scroll; + const scrollAdjustPendingBeforeMVCP = (_f = peek$(ctx, "scrollAdjustPending")) != null ? _f : 0; + checkMVCP == null ? void 0 : checkMVCP(); +- const didMVCPAdjustScroll = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); ++ const didSettleScrollTarget = suppressInitialScrollSideEffects ? false : settleScrollTarget(ctx); ++ const didMVCPAdjustScroll = didSettleScrollTarget || !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); + if (didMVCPAdjustScroll) { + updateScroll2(state.scroll); + updateScrollRange(); +@@ -6607,6 +6683,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { ++ var _a3; + const { state } = ctx; + const previousHeaderSize = peek$(ctx, "headerSize") || 0; + const didChange = previousHeaderSize !== size; +@@ -6616,6 +6693,9 @@ function setHeaderSize(ctx, size) { + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { + requestAdjust(ctx, size - previousHeaderSize); ++ } else if ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.onHeaderLayout) { ++ handleBootstrapInitialScrollLayoutChange(ctx); ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7679,13 +7759,14 @@ function getRenderedItem(ctx, key, containerId) { + + // src/utils/normalizeMaintainScrollAtEnd.ts + function normalizeMaintainScrollAtEndOn(on, hasExplicitOn) { +- var _a3, _b, _c, _d; ++ var _a3, _b, _c, _d, _e; + return { + animated: false, + onDataChange: hasExplicitOn ? (_a3 = on == null ? void 0 : on.dataChange) != null ? _a3 : false : true, + onFooterLayout: hasExplicitOn ? (_b = on == null ? void 0 : on.footerLayout) != null ? _b : false : true, +- onItemLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.itemLayout) != null ? _c : false : true, +- onLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.layout) != null ? _d : false : true ++ onHeaderLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.headerLayout) != null ? _c : false : true, ++ onItemLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.itemLayout) != null ? _d : false : true, ++ onLayout: hasExplicitOn ? (_e = on == null ? void 0 : on.layout) != null ? _e : false : true + }; + } + function normalizeMaintainScrollAtEnd(value) { +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index a97be05..83225ee 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -409,171 +409,271 @@ var EDGE_POSITION_EPSILON = 1; + var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + +-// src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { +- scheduledWork.cancel("checkFinishedScrollFrame"); +- scheduledWork.cancel("checkFinishedScrollRetryFrame"); +- scheduledWork.cancel("checkFinishedScrollFallback"); +- scheduledWork.cancel("platformScrollCompletion"); +-} +-function settlePendingImperativeScroll(state) { +- var _a3, _b; +- const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; +- state.pendingScrollResolve = void 0; +- state.pendingScrollToEnd = void 0; +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++// src/core/getStartOffsetAdjustment.ts ++function getStartOffsetAdjustment(ctx) { ++ const { state } = ctx; ++ const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; ++ return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); + } +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- settlePendingImperativeScroll(state); ++ ++// src/utils/getId.ts ++function getId(state, index) { ++ const { data, keyExtractor } = state.props; ++ if (!data) { ++ return ""; ++ } ++ const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; ++ const id = ret; ++ state.idCache[index] = id; ++ return id; + } + +-// src/core/deferredPublicOnScroll.ts +-function withResolvedContentOffset(state, event, resolvedOffset) { +- return { +- ...event, +- nativeEvent: { +- ...event.nativeEvent, +- contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++// src/core/updateContentMetricsState.ts ++function getRawContentLength(ctx) { ++ var _a3, _b, _c; ++ const { state, values } = ctx; ++ return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++} ++function getAlignItemsAtEndPadding(ctx) { ++ const { state } = ctx; ++ const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++} ++function updateContentMetricsState(ctx) { ++ var _a3; ++ const { state } = ctx; ++ const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; ++ const nextPadding = getAlignItemsAtEndPadding(ctx); ++ if (previousPadding !== nextPadding) { ++ const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; ++ const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); ++ if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { ++ state.scheduledWork.microtask(() => { ++ if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { ++ set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); ++ } ++ }, "alignItemsAtEndPaddingFallback"); ++ } else if (!isMaintainExpected) { ++ set$(ctx, "alignItemsAtEndPadding", nextPadding); + } +- }; ++ } + } +-function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { +- var _a3, _b, _c, _d; ++ ++// src/core/addTotalSize.ts ++function addTotalSize(ctx, key, add, notifyTotalSize = true) { + const state = ctx.state; +- const deferredEvent = state.deferredPublicOnScrollEvent; +- state.deferredPublicOnScrollEvent = void 0; +- if (deferredEvent) { +- (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( +- _c, +- withResolvedContentOffset( +- state, +- deferredEvent, +- (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 +- ) +- ); ++ const prevTotalSize = state.totalSize; ++ let totalSize = state.totalSize; ++ if (key === null) { ++ totalSize = add; ++ if (state.timeoutSetPaddingTop) { ++ clearTimeout(state.timeoutSetPaddingTop); ++ state.timeoutSetPaddingTop = void 0; ++ } ++ } else { ++ totalSize += add; ++ } ++ if (prevTotalSize !== totalSize) { ++ { ++ state.pendingTotalSize = void 0; ++ state.totalSize = totalSize; ++ if (notifyTotalSize) { ++ set$(ctx, "totalSize", totalSize); ++ } ++ updateContentMetricsState(ctx); ++ } ++ } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { ++ set$(ctx, "totalSize", totalSize); + } + } + +-// src/core/initialScrollSession.ts +-var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; +-function hasInitialScrollSessionCompletion(completion) { +- return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); ++// src/core/setSize.ts ++function setSize(ctx, itemKey, size, notifyTotalSize = true) { ++ const state = ctx.state; ++ const { sizes } = state; ++ const previousSize = sizes.get(itemKey); ++ const diff = previousSize !== void 0 ? size - previousSize : size; ++ if (diff !== 0) { ++ addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ } ++ sizes.set(itemKey, size); + } +-function clearInitialScrollSession(state) { +- state.initialScrollSession = void 0; +- return void 0; ++ ++// src/utils/helpers.ts ++function isFunction(obj) { ++ return typeof obj === "function"; + } +-function createInitialScrollSession(options) { +- const { bootstrap, completion, kind, previousDataLength } = options; +- return kind === "offset" ? { +- completion, +- kind, +- previousDataLength +- } : { +- bootstrap, +- completion, +- kind, +- previousDataLength +- }; ++function isArray(obj) { ++ return Array.isArray(obj); + } +-function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { +- var _a4, _b2; +- if (!state.initialScrollSession) { +- state.initialScrollSession = createInitialScrollSession({ +- completion: {}, +- kind, +- previousDataLength: 0 +- }); +- } else if (state.initialScrollSession.kind !== kind) { +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, +- completion: state.initialScrollSession.completion, +- kind, +- previousDataLength: state.initialScrollSession.previousDataLength +- }); ++var warned = /* @__PURE__ */ new Set(); ++function warnDevOnce(id, text) { ++ if (IS_DEV && !warned.has(id)) { ++ warned.add(id); ++ console.warn(`[legend-list] ${text}`); + } +- (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; +- return state.initialScrollSession.completion; + } +-var initialScrollCompletion = { +- didDispatchNativeScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); +- }, +- didRetrySilentInitialScroll(state) { +- var _a3, _b; +- return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); +- }, +- markInitialScrollNativeDispatch(state) { +- ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; +- }, +- markSilentInitialScrollRetry(state) { +- ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; +- }, +- resetFlags(state) { +- if (!state.initialScrollSession) { +- return; ++function roundSize(size) { ++ return Math.floor(size * 8) / 8; ++} ++function isNullOrUndefined(value) { ++ return value === null || value === void 0; ++} ++function getPadding(s, type) { ++ var _a3, _b, _c; ++ const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; ++ return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; ++} ++function extractPadding(style, contentContainerStyle, type) { ++ return getPadding(style, type) + getPadding(contentContainerStyle, type); ++} ++function findContainerId(ctx, key) { ++ var _a3, _b; ++ const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); ++ if (directMatch !== void 0) { ++ return directMatch; ++ } ++ const numContainers = peek$(ctx, "numContainers"); ++ for (let i = 0; i < numContainers; i++) { ++ const itemKey = peek$(ctx, `containerItemKey${i}`); ++ if (itemKey === key) { ++ return i; + } +- const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); +- completion.didDispatchNativeScroll = void 0; +- completion.didRetrySilentInitialScroll = void 0; + } +-}; +-var initialScrollWatchdog = { +- clear(state) { +- initialScrollWatchdog.set(state, void 0); +- }, +- didReachTarget(newScroll, watchdog) { +- const nextDistance = Math.abs(newScroll - watchdog.targetOffset); +- return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- get(state) { +- var _a3, _b; +- return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; +- }, +- hasNonZeroTargetOffset(targetOffset) { +- return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- isAtZeroTargetOffset(targetOffset) { +- return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; +- }, +- set(state, watchdog) { +- var _a3, _b; +- if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { +- return; ++ return -1; ++} ++ ++// src/utils/getItemSize.ts ++function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { getFixedItemSize, getItemType } = state.props; ++ let size = key ? state.sizesKnown.get(key) : void 0; ++ if (size === void 0 && key && getFixedItemSize) { ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); ++ if (fixedSize !== void 0) { ++ size = fixedSize + ctx.scrollAxisGap; ++ state.sizesKnown.set(key, size); + } +- const completion = ensureInitialScrollSessionCompletion(state); +- completion.watchdog = watchdog ? { +- startScroll: watchdog.startScroll, +- targetOffset: watchdog.targetOffset +- } : void 0; + } +-}; +-function setInitialScrollSession(state, options = {}) { ++ return size; ++} ++function getKnownOrFixedItemSize(ctx, index) { ++ const key = getId(ctx.state, index); ++ return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++} ++function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { ++ for (let index = startIndex; index <= endIndex; index++) { ++ if (getKnownOrFixedItemSize(ctx, index) === void 0) { ++ return false; ++ } ++ } ++ return true; ++} ++function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { + var _a3, _b, _c, _d; +- const existingSession = state.initialScrollSession; +- const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; +- const completion = existingSession == null ? void 0 : existingSession.completion; +- const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; +- const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; +- if (!kind) { +- return clearInitialScrollSession(state); ++ const state = ctx.state; ++ const { ++ sizes, ++ averageSizes, ++ props: { estimatedItemSize, getItemType }, ++ scrollingTo ++ } = state; ++ const sizeKnown = state.sizesKnown.get(key); ++ if (sizeKnown !== void 0) { ++ return sizeKnown; + } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); ++ let size; ++ const renderedSize = sizes.get(key); ++ if (preferCachedSize) { ++ if (renderedSize !== void 0) { ++ return renderedSize; ++ } + } +- const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; +- state.initialScrollSession = createInitialScrollSession({ +- bootstrap, +- completion, +- kind, +- previousDataLength +- }); +- return state.initialScrollSession; ++ size = getKnownOrFixedSize(ctx, key, index, data, resolved); ++ if (size !== void 0) { ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++ } ++ const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; ++ if (useAverageSize && !scrollingTo) { ++ const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0 && renderedSize !== void 0) { ++ return renderedSize; ++ } ++ if (size === void 0 && useAverageSize && scrollingTo) { ++ const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; ++ if (averageSizeForType !== void 0) { ++ size = roundSize(averageSizeForType); ++ } ++ } ++ if (size === void 0) { ++ size = estimatedItemSize + ctx.scrollAxisGap; ++ } ++ setSize(ctx, key, size, notifyTotalSize); ++ return size; ++} ++function getItemSizeAtIndex(ctx, index) { ++ if (index === void 0 || index < 0) { ++ return void 0; ++ } ++ const targetId = getId(ctx.state, index); ++ return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++} ++ ++// src/core/calculateOffsetWithOffsetPosition.ts ++function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { ++ var _a3; ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ let offset = offsetParam; ++ if (viewOffset) { ++ offset -= viewOffset; ++ } ++ if (index !== void 0) { ++ const startOffsetAdjustment = getStartOffsetAdjustment(ctx); ++ if (startOffsetAdjustment) { ++ offset += startOffsetAdjustment; ++ } ++ } ++ if (viewPosition !== void 0 && index !== void 0) { ++ const dataLength = state.props.data.length; ++ if (dataLength === 0) { ++ return offset; ++ } ++ const isOutOfBounds = index < 0 || index >= dataLength; ++ const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; ++ const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); ++ const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); ++ const trailingInset = getContentInsetEnd(ctx); ++ offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); ++ if (!isOutOfBounds && index === state.props.data.length - 1) { ++ const footerSize = peek$(ctx, "footerSize") || 0; ++ offset += footerSize; ++ } ++ } ++ return offset; ++} ++ ++// src/core/clampScrollOffset.ts ++function clampScrollOffset(ctx, offset, scrollTarget) { ++ const state = ctx.state; ++ const contentSize = getContentSize(ctx); ++ let clampedOffset = offset; ++ if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { ++ const baseMaxOffset = Math.max(0, 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); ++ return clampedOffset; + } + + // src/utils/checkThreshold.ts +@@ -796,626 +896,616 @@ function recalculateSettledScroll(ctx) { + } + checkThresholds(ctx); + } +-var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; +-var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; +-function clearAdaptiveRenderExitTimeout(ctx) { +- ctx.state.scheduledWork.cancel("adaptiveRender"); +-} +-function scheduleAdaptiveRenderExit(ctx, exitDelay) { +- const state = ctx.state; +- clearAdaptiveRenderExitTimeout(ctx); +- if (exitDelay <= 0) { +- setAdaptiveRender(ctx, "normal", "scroll"); +- } else { +- state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); +- } +-} +-function setAdaptiveRender(ctx, mode, reason) { +- var _a3, _b; +- const previousMode = peek$(ctx, "adaptiveRender"); +- if (previousMode !== mode) { +- set$(ctx, "adaptiveRender", mode); +- (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); +- } +-} +-function resetAdaptiveRender(ctx) { ++ ++// src/core/finishScrollTo.ts ++function finishScrollTo(ctx) { + var _a3, _b; +- clearAdaptiveRenderExitTimeout(ctx); +- const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; +- if (peek$(ctx, "adaptiveRender") !== mode) { +- setAdaptiveRender(ctx, mode, "initial"); +- } +-} +-function updateAdaptiveRender(ctx, scrollVelocity, options) { +- var _a3, _b, _c; + const state = ctx.state; +- const adaptiveRender = state.props.adaptiveRender; +- const currentMode = peek$(ctx, "adaptiveRender"); +- if (peek$(ctx, "readyToRender")) { +- if (adaptiveRender) { +- const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; +- const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; +- const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; +- const threshold = currentMode === "light" ? exitVelocity : enterVelocity; +- const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; +- const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; +- if (nextMode !== previousMode) { +- if (nextMode === "light") { +- setAdaptiveRender(ctx, "light", "scroll"); +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } else if (currentMode === "light") { +- scheduleAdaptiveRenderExit(ctx, exitDelay); +- } +- } +- } else { +- resetAdaptiveRender(ctx); ++ if (state == null ? void 0 : state.scrollingTo) { ++ cancelScrollCompletionChecks(state); ++ const resolvePendingScroll = state.pendingScrollResolve; ++ state.pendingScrollResolve = void 0; ++ const scrollingTo = state.scrollingTo; ++ state.scrollHistory.length = 0; ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ if (state.pendingTotalSize !== void 0) { ++ addTotalSize(ctx, null, state.pendingTotalSize); ++ } ++ { ++ state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ } ++ if (scrollingTo.isInitialScroll || state.initialScroll) { ++ const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; ++ finishInitialScroll(ctx, { ++ onFinished: () => { ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ }, ++ preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, ++ recalculateItems: true, ++ schedulePreservedTargetClear: shouldPreserveResizeTarget, ++ syncObservedOffset: isOffsetSession, ++ waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame ++ }); ++ return; + } ++ recalculateSettledScroll(ctx); ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); + } + } + +-// src/utils/getEffectiveDrawDistance.ts +-var INITIAL_DRAW_DISTANCE = 50; +-function getEffectiveDrawDistance(ctx, mode) { +- var _a3; +- const drawDistance = ctx.state.props.drawDistance; +- const initialScroll = ctx.state.initialScroll; +- const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; +- const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; +- return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; +-} +-function scheduleFullDrawDistancePrewarm(ctx) { +- const { state } = ctx; +- if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++// src/core/doScrollTo.ts ++var SCROLL_END_IDLE_MS = 80; ++var SCROLL_END_MAX_MS = 1500; ++var SMOOTH_SCROLL_DURATION_MS = 320; ++var SCROLL_END_TARGET_EPSILON = 1; ++function doScrollTo(ctx, params) { ++ var _a3, _b; ++ const state = ctx.state; ++ const { animated, horizontal, offset } = params; ++ state.scheduledWork.cancel("platformScrollCompletion"); ++ const scroller = state.refScroller.current; ++ const node = scroller == null ? void 0 : scroller.getScrollableNode(); ++ if (!scroller || !node) { + return; + } +- state.scheduledWork.frame(() => { +- var _a3; +- return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); +- }, "fullDrawDistancePrewarm"); +-} +- +-// src/utils/setInitialRenderState.ts +-function resetInitialRenderState(ctx, { +- resetLayout, +- resetInitialScroll +-}) { +- const { state } = ctx; +- if (resetLayout) { +- state.didContainersLayout = false; +- state.queuedInitialLayout = false; +- } +- if (resetInitialScroll) { +- state.didFinishInitialScroll = false; ++ const isAnimated = !!animated; ++ const isHorizontal = !!horizontal; ++ const contentSize = isHorizontal ? getContentSize(ctx) : void 0; ++ const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; ++ const top = isHorizontal ? 0 : offset; ++ scroller.scrollTo({ animated: isAnimated, x: left, y: top }); ++ if (isAnimated) { ++ const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; ++ listenForScrollEnd(ctx, { ++ readOffset: () => scroller.getCurrentScrollOffset(), ++ target, ++ targetOffset: offset ++ }); ++ } else { ++ state.scroll = offset; ++ const targetToken = state.scrollingTo; ++ state.scheduledWork.timeout( ++ () => { ++ if (targetToken === state.scrollingTo) { ++ finishScrollTo(ctx); ++ } ++ }, ++ 100, ++ "platformScrollCompletion" ++ ); + } +- set$(ctx, "readyToRender", false); +- resetAdaptiveRender(ctx); + } +-function setInitialRenderState(ctx, { +- didLayout, +- didInitialScroll +-}) { +- const { state } = ctx; +- const { +- loadStartTime, +- props: { onLoad } +- } = state; +- if (didLayout) { +- state.didContainersLayout = true; +- } +- if (didInitialScroll) { +- state.didFinishInitialScroll = true; ++function listenForScrollEnd(ctx, params) { ++ const { readOffset, target, targetOffset } = params; ++ if (!target) { ++ finishScrollTo(ctx); ++ return; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); +- if (isReadyToRender && !peek$(ctx, "readyToRender")) { +- set$(ctx, "readyToRender", true); +- setAdaptiveRender(ctx, "normal", "ready"); +- if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { +- scheduleFullDrawDistancePrewarm(ctx); ++ const supportsScrollEnd = "onscrollend" in target; ++ let idleTimeout; ++ let settled = false; ++ const { scheduledWork, scrollingTo: targetToken } = ctx.state; ++ const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); ++ const cleanup = () => { ++ target.removeEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.removeEventListener("scrollend", onScrollEnd); + } +- if (!state.didLoad) { +- state.didLoad = true; +- if (onLoad) { +- onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); +- } ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); + } +- } +-} +- +-// src/core/finishInitialScroll.ts +-var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; +-function syncInitialScrollOffset(state, offset) { +- state.scroll = offset; +- state.scrollPending = offset; +- state.scrollPrev = offset; +-} +-function clearPreservedInitialScrollTargetTimeout(state) { +- state.scheduledWork.cancel("preservedInitialScroll"); +-} +-function clearPreservedInitialScrollTarget(state) { +- clearPreservedInitialScrollTargetTimeout(state); +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- state.initialScroll = void 0; +- setInitialScrollSession(state); +-} +-function supersedeInitialScroll(ctx) { +- var _a3, _b, _c; +- const state = ctx.state; +- const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; +- if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { +- if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { +- cancelAnimationFrame(bootstrapInitialScroll.frameHandle); ++ clearTimeout(maxTimeout); ++ }; ++ const cancel = () => { ++ if (!settled) { ++ settled = true; ++ cleanup(); + } +- if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { +- cancelScrollCompletionChecks(state); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; ++ }; ++ const finish = (reason) => { ++ if (settled) return; ++ if (targetToken !== ctx.state.scrollingTo) { ++ scheduledWork.cancel("platformScrollCompletion"); ++ return; + } +- initialScrollCompletion.resetFlags(state); +- setInitialScrollSession(state, { bootstrap: null }); +- finishInitialScroll(ctx); ++ const currentOffset = readOffset(); ++ const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; ++ if (reason === "scrollend" && !isNearTarget) { ++ return; ++ } ++ scheduledWork.cancel("platformScrollCompletion"); ++ finishScrollTo(ctx); ++ }; ++ const onScroll2 = () => { ++ if (idleTimeout !== void 0) { ++ clearTimeout(idleTimeout); ++ } ++ idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ }; ++ const onScrollEnd = () => finish("scrollend"); ++ target.addEventListener("scroll", onScroll2); ++ if (supportsScrollEnd) { ++ target.addEventListener("scrollend", onScrollEnd); ++ } else { ++ idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); + } ++ scheduledWork.register("platformScrollCompletion", cancel); + } +-function finishInitialScroll(ctx, options) { +- var _a3, _b, _c; ++ ++// src/utils/requestAdjust.ts ++function requestAdjust(ctx, positionDiff, source) { + const state = ctx.state; +- if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { +- syncInitialScrollOffset(state, options.resolvedOffset); +- } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { +- const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); +- if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { +- syncInitialScrollOffset(state, observedOffset); +- } +- } +- const complete = () => { +- var _a4, _b2, _c2, _d, _e; +- const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; +- const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; +- initialScrollWatchdog.clear(state); +- if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { +- state.clearPreservedInitialScrollOnNextFinish = void 0; +- setInitialScrollSession(state); +- clearPreservedInitialScrollTargetTimeout(state); +- if (options == null ? void 0 : options.schedulePreservedTargetClear) { +- state.scheduledWork.timeout( +- () => { +- var _a5; +- if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { +- return; +- } +- clearPreservedInitialScrollTarget(state); +- }, +- PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, +- "preservedInitialScroll" +- ); ++ if (Math.abs(positionDiff) > 0.1) { ++ const doit = () => { ++ { ++ state.scrollAdjustHandler.requestAdjust(positionDiff); ++ if (state.adjustingFromInitialMount) { ++ state.adjustingFromInitialMount--; ++ } + } ++ }; ++ state.scroll += positionDiff; ++ state.scrollForNextCalculateItemsInView = void 0; ++ const readyToRender = peek$(ctx, "readyToRender"); ++ if (readyToRender) { ++ doit(); + } else { +- clearPreservedInitialScrollTarget(state); +- } +- if (options == null ? void 0 : options.recalculateItems) { +- recalculateSettledScroll(ctx); +- } +- setInitialRenderState(ctx, { didInitialScroll: true }); +- if (shouldReleaseDeferredPublicOnScroll) { +- releaseDeferredPublicOnScroll(ctx, finalScrollOffset); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; ++ requestAnimationFrame(doit); + } +- (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); +- }; +- if (options == null ? void 0 : options.waitForCompletionFrame) { +- requestAnimationFrame(complete); +- return; + } +- complete(); + } + +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 0.5; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_TTL_MS = 500; ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++} ++function beginScrollTargetSettle(ctx, params) { + const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; ++ const { index, viewOffset, viewPosition } = params; ++ const { data } = state.props; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ state.scrollTargetSettle = { ++ expiresAt: Date.now() + SETTLE_TTL_MS, ++ id: getId(state, index), ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++} ++function settleScrollTarget(ctx) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const index = state.indexByKey.get(settle.id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const now = Date.now(); ++ if (now > settle.expiresAt) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ const diff = targetOffset - state.scroll; ++ if (Math.abs(diff) <= SETTLE_POSITION_EPSILON) { ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ settle.expiresAt = now + SETTLE_TTL_MS; ++ if (((_a3 = state.scrollingTo) == null ? void 0 : _a3.index) === index) { ++ state.scrollingTo.offset = position; ++ state.scrollingTo.targetOffset = targetOffset; ++ } ++ requestAdjust(ctx, diff); ++ return true; + } + +-// src/core/getStartOffsetAdjustment.ts +-function getStartOffsetAdjustment(ctx) { +- const { state } = ctx; +- const stylePaddingStart = state.props.horizontal ? (isHorizontalRTL(state) ? state.props.stylePaddingRight : state.props.stylePaddingLeft) || 0 : peek$(ctx, "stylePaddingTop") || 0; +- return stylePaddingStart + (peek$(ctx, "alignItemsAtEndPadding") || 0) + (peek$(ctx, "headerSize") || 0); ++// src/core/cancelImperativeScroll.ts ++function cancelScrollCompletionChecks({ scheduledWork }) { ++ scheduledWork.cancel("checkFinishedScrollFrame"); ++ scheduledWork.cancel("checkFinishedScrollRetryFrame"); ++ scheduledWork.cancel("checkFinishedScrollFallback"); ++ scheduledWork.cancel("platformScrollCompletion"); ++} ++function settlePendingImperativeScroll(state) { ++ var _a3, _b; ++ const resolvePendingScroll = (_b = state.pendingScrollResolve) != null ? _b : (_a3 = state.pendingScrollToEnd) == null ? void 0 : _a3.resolve; ++ state.pendingScrollResolve = void 0; ++ state.pendingScrollToEnd = void 0; ++ resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++} ++function cancelImperativeScroll(state) { ++ cancelScrollCompletionChecks(state); ++ state.scheduledWork.cancel("imperativeScrollReady"); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; ++ clearScrollTargetSettle(state); ++ settlePendingImperativeScroll(state); + } + +-// src/utils/getId.ts +-function getId(state, index) { +- const { data, keyExtractor } = state.props; +- if (!data) { +- return ""; ++// src/core/deferredPublicOnScroll.ts ++function withResolvedContentOffset(state, event, resolvedOffset) { ++ return { ++ ...event, ++ nativeEvent: { ++ ...event.nativeEvent, ++ contentOffset: state.props.horizontal ? { x: resolvedOffset, y: 0 } : { x: 0, y: resolvedOffset } ++ } ++ }; ++} ++function releaseDeferredPublicOnScroll(ctx, resolvedOffset) { ++ var _a3, _b, _c, _d; ++ const state = ctx.state; ++ const deferredEvent = state.deferredPublicOnScrollEvent; ++ state.deferredPublicOnScrollEvent = void 0; ++ if (deferredEvent) { ++ (_d = (_c = state.props).onScroll) == null ? void 0 : _d.call( ++ _c, ++ withResolvedContentOffset( ++ state, ++ deferredEvent, ++ (_b = (_a3 = resolvedOffset != null ? resolvedOffset : state.scrollPending) != null ? _a3 : state.scroll) != null ? _b : 0 ++ ) ++ ); + } +- const ret = index < data.length ? keyExtractor ? keyExtractor(data[index], index) : index : null; +- const id = ret; +- state.idCache[index] = id; +- return id; + } + +-// src/core/updateContentMetricsState.ts +-function getRawContentLength(ctx) { +- var _a3, _b, _c; +- const { state, values } = ctx; +- return (values.get("headerSize") || 0) + (values.get("footerSize") || 0) + ((_c = (_b = (_a3 = state.pendingTotalSize) != null ? _a3 : state.totalSize) != null ? _b : values.get("totalSize")) != null ? _c : 0) + (state.props.stylePaddingTop || 0) + (state.props.stylePaddingBottom || 0); ++// src/core/initialScrollSession.ts ++var INITIAL_SCROLL_MIN_TARGET_OFFSET = 1; ++function hasInitialScrollSessionCompletion(completion) { ++ return !!((completion == null ? void 0 : completion.didDispatchNativeScroll) || (completion == null ? void 0 : completion.didRetrySilentInitialScroll) || (completion == null ? void 0 : completion.watchdog)); + } +-function getAlignItemsAtEndPadding(ctx) { +- const { state } = ctx; +- const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++function clearInitialScrollSession(state) { ++ state.initialScrollSession = void 0; ++ return void 0; + } +-function updateContentMetricsState(ctx) { +- var _a3; +- const { state } = ctx; +- const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +- const nextPadding = getAlignItemsAtEndPadding(ctx); +- if (previousPadding !== nextPadding) { +- const isAnimatedMaintainActive = state.maintainingScrollAtEnd === "pending-animated" || state.maintainingScrollAtEnd === "animated"; +- const isMaintainExpected = state.didContainersLayout && ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.animated) && (isAnimatedMaintainActive || previousPadding > nextPadding && peek$(ctx, "isWithinMaintainScrollAtEndThreshold")); +- if (isMaintainExpected && !isAnimatedMaintainActive && !state.pendingMaintainScrollAtEnd) { +- state.scheduledWork.microtask(() => { +- if (!state.maintainingScrollAtEnd && !state.pendingMaintainScrollAtEnd) { +- set$(ctx, "alignItemsAtEndPadding", getAlignItemsAtEndPadding(ctx)); +- } +- }, "alignItemsAtEndPaddingFallback"); +- } else if (!isMaintainExpected) { +- set$(ctx, "alignItemsAtEndPadding", nextPadding); +- } ++function createInitialScrollSession(options) { ++ const { bootstrap, completion, kind, previousDataLength } = options; ++ return kind === "offset" ? { ++ completion, ++ kind, ++ previousDataLength ++ } : { ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }; ++} ++function ensureInitialScrollSessionCompletion(state, kind = ((_b) => (_b = ((_a3) => (_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind)()) != null ? _b : "bootstrap")()) { ++ var _a4, _b2; ++ if (!state.initialScrollSession) { ++ state.initialScrollSession = createInitialScrollSession({ ++ completion: {}, ++ kind, ++ previousDataLength: 0 ++ }); ++ } else if (state.initialScrollSession.kind !== kind) { ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap: state.initialScrollSession.kind === "bootstrap" ? state.initialScrollSession.bootstrap : void 0, ++ completion: state.initialScrollSession.completion, ++ kind, ++ previousDataLength: state.initialScrollSession.previousDataLength ++ }); + } ++ (_b2 = (_a4 = state.initialScrollSession).completion) != null ? _b2 : _a4.completion = {}; ++ return state.initialScrollSession.completion; + } +- +-// src/core/addTotalSize.ts +-function addTotalSize(ctx, key, add, notifyTotalSize = true) { +- const state = ctx.state; +- const prevTotalSize = state.totalSize; +- let totalSize = state.totalSize; +- if (key === null) { +- totalSize = add; +- if (state.timeoutSetPaddingTop) { +- clearTimeout(state.timeoutSetPaddingTop); +- state.timeoutSetPaddingTop = void 0; ++var initialScrollCompletion = { ++ didDispatchNativeScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didDispatchNativeScroll); ++ }, ++ didRetrySilentInitialScroll(state) { ++ var _a3, _b; ++ return !!((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.didRetrySilentInitialScroll); ++ }, ++ markInitialScrollNativeDispatch(state) { ++ ensureInitialScrollSessionCompletion(state).didDispatchNativeScroll = true; ++ }, ++ markSilentInitialScrollRetry(state) { ++ ensureInitialScrollSessionCompletion(state).didRetrySilentInitialScroll = true; ++ }, ++ resetFlags(state) { ++ if (!state.initialScrollSession) { ++ return; + } +- } else { +- totalSize += add; ++ const completion = ensureInitialScrollSessionCompletion(state, state.initialScrollSession.kind); ++ completion.didDispatchNativeScroll = void 0; ++ completion.didRetrySilentInitialScroll = void 0; + } +- if (prevTotalSize !== totalSize) { +- { +- state.pendingTotalSize = void 0; +- state.totalSize = totalSize; +- if (notifyTotalSize) { +- set$(ctx, "totalSize", totalSize); +- } +- updateContentMetricsState(ctx); ++}; ++var initialScrollWatchdog = { ++ clear(state) { ++ initialScrollWatchdog.set(state, void 0); ++ }, ++ didReachTarget(newScroll, watchdog) { ++ const nextDistance = Math.abs(newScroll - watchdog.targetOffset); ++ return nextDistance <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ get(state) { ++ var _a3, _b; ++ return (_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog; ++ }, ++ hasNonZeroTargetOffset(targetOffset) { ++ return targetOffset !== void 0 && targetOffset > INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ isAtZeroTargetOffset(targetOffset) { ++ return targetOffset <= INITIAL_SCROLL_MIN_TARGET_OFFSET; ++ }, ++ set(state, watchdog) { ++ var _a3, _b; ++ if (!watchdog && !((_b = (_a3 = state.initialScrollSession) == null ? void 0 : _a3.completion) == null ? void 0 : _b.watchdog)) { ++ return; + } +- } else if (notifyTotalSize && ctx.values.get("totalSize") !== totalSize) { +- set$(ctx, "totalSize", totalSize); ++ const completion = ensureInitialScrollSessionCompletion(state); ++ completion.watchdog = watchdog ? { ++ startScroll: watchdog.startScroll, ++ targetOffset: watchdog.targetOffset ++ } : void 0; ++ } ++}; ++function setInitialScrollSession(state, options = {}) { ++ var _a3, _b, _c, _d; ++ const existingSession = state.initialScrollSession; ++ const kind = (_a3 = options.kind) != null ? _a3 : existingSession == null ? void 0 : existingSession.kind; ++ const completion = existingSession == null ? void 0 : existingSession.completion; ++ const existingBootstrap = (existingSession == null ? void 0 : existingSession.kind) === "bootstrap" ? existingSession.bootstrap : void 0; ++ const bootstrap = kind === "bootstrap" ? options.bootstrap === null ? void 0 : (_b = options.bootstrap) != null ? _b : existingBootstrap : void 0; ++ if (!kind) { ++ return clearInitialScrollSession(state); + } +-} +- +-// src/core/setSize.ts +-function setSize(ctx, itemKey, size, notifyTotalSize = true) { +- const state = ctx.state; +- const { sizes } = state; +- const previousSize = sizes.get(itemKey); +- const diff = previousSize !== void 0 ? size - previousSize : size; +- if (diff !== 0) { +- addTotalSize(ctx, itemKey, diff, notifyTotalSize); ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); + } +- sizes.set(itemKey, size); +-} +- +-// src/utils/helpers.ts +-function isFunction(obj) { +- return typeof obj === "function"; ++ const previousDataLength = (_d = (_c = options.previousDataLength) != null ? _c : existingSession == null ? void 0 : existingSession.previousDataLength) != null ? _d : 0; ++ state.initialScrollSession = createInitialScrollSession({ ++ bootstrap, ++ completion, ++ kind, ++ previousDataLength ++ }); ++ return state.initialScrollSession; + } +-function isArray(obj) { +- return Array.isArray(obj); ++var DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY = 6; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY = 3; ++var DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY = 250; ++function clearAdaptiveRenderExitTimeout(ctx) { ++ ctx.state.scheduledWork.cancel("adaptiveRender"); + } +-var warned = /* @__PURE__ */ new Set(); +-function warnDevOnce(id, text) { +- if (IS_DEV && !warned.has(id)) { +- warned.add(id); +- console.warn(`[legend-list] ${text}`); ++function scheduleAdaptiveRenderExit(ctx, exitDelay) { ++ const state = ctx.state; ++ clearAdaptiveRenderExitTimeout(ctx); ++ if (exitDelay <= 0) { ++ setAdaptiveRender(ctx, "normal", "scroll"); ++ } else { ++ state.scheduledWork.timeout(() => setAdaptiveRender(ctx, "normal", "scroll"), exitDelay, "adaptiveRender"); + } + } +-function roundSize(size) { +- return Math.floor(size * 8) / 8; +-} +-function isNullOrUndefined(value) { +- return value === null || value === void 0; +-} +-function getPadding(s, type) { +- var _a3, _b, _c; +- const axisPadding = type === "Left" || type === "Right" ? s.paddingHorizontal : s.paddingVertical; +- return (_c = (_b = (_a3 = s[`padding${type}`]) != null ? _a3 : axisPadding) != null ? _b : s.padding) != null ? _c : 0; +-} +-function extractPadding(style, contentContainerStyle, type) { +- return getPadding(style, type) + getPadding(contentContainerStyle, type); +-} +-function findContainerId(ctx, key) { ++function setAdaptiveRender(ctx, mode, reason) { + var _a3, _b; +- const directMatch = (_b = (_a3 = ctx.state) == null ? void 0 : _a3.containerItemKeys) == null ? void 0 : _b.get(key); +- if (directMatch !== void 0) { +- return directMatch; +- } +- const numContainers = peek$(ctx, "numContainers"); +- for (let i = 0; i < numContainers; i++) { +- const itemKey = peek$(ctx, `containerItemKey${i}`); +- if (itemKey === key) { +- return i; +- } ++ const previousMode = peek$(ctx, "adaptiveRender"); ++ if (previousMode !== mode) { ++ set$(ctx, "adaptiveRender", mode); ++ (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.onChange) == null ? void 0 : _b.call(_a3, mode, reason); + } +- return -1; + } +- +-// src/utils/getItemSize.ts +-function getKnownOrFixedSize(ctx, key, index, data, resolved) { ++function resetAdaptiveRender(ctx) { + var _a3, _b; ++ clearAdaptiveRenderExitTimeout(ctx); ++ const mode = (_b = (_a3 = ctx.state.props.adaptiveRender) == null ? void 0 : _a3.initialMode) != null ? _b : "normal"; ++ if (peek$(ctx, "adaptiveRender") !== mode) { ++ setAdaptiveRender(ctx, mode, "initial"); ++ } ++} ++function updateAdaptiveRender(ctx, scrollVelocity, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { getFixedItemSize, getItemType } = state.props; +- let size = key ? state.sizesKnown.get(key) : void 0; +- if (size === void 0 && key && getFixedItemSize) { +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- const fixedSize = (resolved == null ? void 0 : resolved.didResolveFixedItemSize) ? resolved.fixedItemSize : getFixedItemSize(data, index, itemType); +- if (fixedSize !== void 0) { +- size = fixedSize + ctx.scrollAxisGap; +- state.sizesKnown.set(key, size); ++ const adaptiveRender = state.props.adaptiveRender; ++ const currentMode = peek$(ctx, "adaptiveRender"); ++ if (peek$(ctx, "readyToRender")) { ++ if (adaptiveRender) { ++ const enterVelocity = (_a3 = adaptiveRender.enterVelocity) != null ? _a3 : DEFAULT_WEB_ADAPTIVE_RENDER_ENTER_VELOCITY ; ++ const exitVelocity = (_b = adaptiveRender.exitVelocity) != null ? _b : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_VELOCITY ; ++ const exitDelay = (_c = adaptiveRender.exitDelay) != null ? _c : DEFAULT_WEB_ADAPTIVE_RENDER_EXIT_DELAY ; ++ const threshold = currentMode === "light" ? exitVelocity : enterVelocity; ++ const nextMode = (options == null ? void 0 : options.forceLight) || Math.abs(scrollVelocity) > threshold ? "light" : "normal"; ++ const previousMode = state.scheduledWork.has("adaptiveRender") ? "normal" : currentMode; ++ if (nextMode !== previousMode) { ++ if (nextMode === "light") { ++ setAdaptiveRender(ctx, "light", "scroll"); ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } else if (currentMode === "light") { ++ scheduleAdaptiveRenderExit(ctx, exitDelay); ++ } ++ } ++ } else { ++ resetAdaptiveRender(ctx); + } + } +- return size; + } +-function getKnownOrFixedItemSize(ctx, index) { +- const key = getId(ctx.state, index); +- return getKnownOrFixedSize(ctx, key, index, ctx.state.props.data[index]); ++ ++// src/utils/getEffectiveDrawDistance.ts ++var INITIAL_DRAW_DISTANCE = 50; ++function getEffectiveDrawDistance(ctx, mode) { ++ var _a3; ++ const drawDistance = ctx.state.props.drawDistance; ++ const initialScroll = ctx.state.initialScroll; ++ const needsFullInitialDrawDistance = initialScroll !== void 0 && ((_a3 = initialScroll.viewPosition) != null ? _a3 : 0) > 0; ++ const shouldCapDrawDistance = mode === "visible-first" || mode !== "full" && !peek$(ctx, "readyToRender") && !needsFullInitialDrawDistance; ++ return shouldCapDrawDistance ? Math.min(drawDistance, INITIAL_DRAW_DISTANCE) : drawDistance; + } +-function areKnownOrFixedItemSizesAvailable(ctx, startIndex, endIndex) { +- for (let index = startIndex; index <= endIndex; index++) { +- if (getKnownOrFixedItemSize(ctx, index) === void 0) { +- return false; +- } ++function scheduleFullDrawDistancePrewarm(ctx) { ++ const { state } = ctx; ++ if (state.props.drawDistance <= INITIAL_DRAW_DISTANCE || state.scheduledWork.has("fullDrawDistancePrewarm")) { ++ return; + } +- return true; ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); + } +-function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, notifyTotalSize, resolved) { +- var _a3, _b, _c, _d; +- const state = ctx.state; +- const { +- sizes, +- averageSizes, +- props: { estimatedItemSize, getItemType }, +- scrollingTo +- } = state; +- const sizeKnown = state.sizesKnown.get(key); +- if (sizeKnown !== void 0) { +- return sizeKnown; +- } +- let size; +- const renderedSize = sizes.get(key); +- if (preferCachedSize) { +- if (renderedSize !== void 0) { +- return renderedSize; +- } +- } +- size = getKnownOrFixedSize(ctx, key, index, data, resolved); +- if (size !== void 0) { +- setSize(ctx, key, size, notifyTotalSize); +- return size; +- } +- const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { +- const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0 && renderedSize !== void 0) { +- return renderedSize; +- } +- if (size === void 0 && useAverageSize && scrollingTo) { +- const averageSizeForType = (_d = scrollingTo.averageSizeSnapshot) == null ? void 0 : _d[itemType]; +- if (averageSizeForType !== void 0) { +- size = roundSize(averageSizeForType); +- } +- } +- if (size === void 0) { +- size = estimatedItemSize + ctx.scrollAxisGap; ++ ++// src/utils/setInitialRenderState.ts ++function resetInitialRenderState(ctx, { ++ resetLayout, ++ resetInitialScroll ++}) { ++ const { state } = ctx; ++ if (resetLayout) { ++ state.didContainersLayout = false; ++ state.queuedInitialLayout = false; + } +- setSize(ctx, key, size, notifyTotalSize); +- return size; +-} +-function getItemSizeAtIndex(ctx, index) { +- if (index === void 0 || index < 0) { +- return void 0; ++ if (resetInitialScroll) { ++ state.didFinishInitialScroll = false; + } +- const targetId = getId(ctx.state, index); +- return getItemSize(ctx, targetId, index, ctx.state.props.data[index]); ++ set$(ctx, "readyToRender", false); ++ resetAdaptiveRender(ctx); + } +- +-// src/core/calculateOffsetWithOffsetPosition.ts +-function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +- var _a3; +- const state = ctx.state; +- const { index, viewOffset, viewPosition } = params; +- let offset = offsetParam; +- if (viewOffset) { +- offset -= viewOffset; ++function setInitialRenderState(ctx, { ++ didLayout, ++ didInitialScroll ++}) { ++ const { state } = ctx; ++ const { ++ loadStartTime, ++ props: { onLoad } ++ } = state; ++ if (didLayout) { ++ state.didContainersLayout = true; + } +- if (index !== void 0) { +- const startOffsetAdjustment = getStartOffsetAdjustment(ctx); +- if (startOffsetAdjustment) { +- offset += startOffsetAdjustment; +- } ++ if (didInitialScroll) { ++ state.didFinishInitialScroll = true; + } +- if (viewPosition !== void 0 && index !== void 0) { +- const dataLength = state.props.data.length; +- if (dataLength === 0) { +- return offset; +- } +- const isOutOfBounds = index < 0 || index >= dataLength; +- const fallbackEstimatedSize = (_a3 = state.props.estimatedItemSize) != null ? _a3 : 0; +- const measuredItemSize = isOutOfBounds ? fallbackEstimatedSize : getItemSize(ctx, getId(state, index), index, state.props.data[index]); +- const itemSize = Math.max(0, measuredItemSize - (isOutOfBounds ? 0 : ctx.scrollAxisGap)); +- const trailingInset = getContentInsetEnd(ctx); +- offset -= viewPosition * (state.scrollLength - trailingInset - itemSize); +- if (!isOutOfBounds && index === state.props.data.length - 1) { +- const footerSize = peek$(ctx, "footerSize") || 0; +- offset += footerSize; ++ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ if (isReadyToRender && !peek$(ctx, "readyToRender")) { ++ set$(ctx, "readyToRender", true); ++ setAdaptiveRender(ctx, "normal", "ready"); ++ if (state.props.drawDistance > INITIAL_DRAW_DISTANCE) { ++ scheduleFullDrawDistancePrewarm(ctx); ++ } ++ if (!state.didLoad) { ++ state.didLoad = true; ++ if (onLoad) { ++ onLoad({ elapsedTimeInMs: Date.now() - loadStartTime }); ++ } + } + } +- return offset; + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { +- const state = ctx.state; +- const contentSize = getContentSize(ctx); +- let clampedOffset = offset; +- if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android")) { +- const baseMaxOffset = Math.max(0, 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); +- return clampedOffset; ++// src/core/finishInitialScroll.ts ++var PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS = 2e3; ++function syncInitialScrollOffset(state, offset) { ++ state.scroll = offset; ++ state.scrollPending = offset; ++ state.scrollPrev = offset; + } +- +-// src/core/finishScrollTo.ts +-function finishScrollTo(ctx) { +- var _a3, _b; ++function clearPreservedInitialScrollTargetTimeout(state) { ++ state.scheduledWork.cancel("preservedInitialScroll"); ++} ++function clearPreservedInitialScrollTarget(state) { ++ clearPreservedInitialScrollTargetTimeout(state); ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ state.initialScroll = void 0; ++ setInitialScrollSession(state); ++} ++function supersedeInitialScroll(ctx) { ++ var _a3, _b, _c; + const state = ctx.state; +- if (state == null ? void 0 : state.scrollingTo) { +- cancelScrollCompletionChecks(state); +- const resolvePendingScroll = state.pendingScrollResolve; +- state.pendingScrollResolve = void 0; +- const scrollingTo = state.scrollingTo; +- state.scrollHistory.length = 0; +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- if (state.pendingTotalSize !== void 0) { +- addTotalSize(ctx, null, state.pendingTotalSize); +- } +- { +- state.scrollAdjustHandler.commitPendingAdjust(scrollingTo); ++ const bootstrapInitialScroll = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; ++ if (state.initialScroll || bootstrapInitialScroll || ((_b = state.scrollingTo) == null ? void 0 : _b.isInitialScroll)) { ++ if ((bootstrapInitialScroll == null ? void 0 : bootstrapInitialScroll.frameHandle) !== void 0 && typeof cancelAnimationFrame === "function") { ++ cancelAnimationFrame(bootstrapInitialScroll.frameHandle); + } +- if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_b = state.initialScroll) == null ? void 0 : _b.viewPosition) === 1; +- finishInitialScroll(ctx, { +- onFinished: () => { +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); +- }, +- preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +- recalculateItems: true, +- schedulePreservedTargetClear: shouldPreserveResizeTarget, +- syncObservedOffset: isOffsetSession, +- waitForCompletionFrame: !!scrollingTo.waitForInitialScrollCompletionFrame +- }); +- return; ++ if ((_c = state.scrollingTo) == null ? void 0 : _c.isInitialScroll) { ++ cancelScrollCompletionChecks(state); ++ state.scrollingTo = void 0; ++ state.scrollTargetPinnedRange = void 0; + } +- recalculateSettledScroll(ctx); +- resolvePendingScroll == null ? void 0 : resolvePendingScroll(); ++ initialScrollCompletion.resetFlags(state); ++ setInitialScrollSession(state, { bootstrap: null }); ++ finishInitialScroll(ctx); + } + } +- +-// src/core/doScrollTo.ts +-var SCROLL_END_IDLE_MS = 80; +-var SCROLL_END_MAX_MS = 1500; +-var SMOOTH_SCROLL_DURATION_MS = 320; +-var SCROLL_END_TARGET_EPSILON = 1; +-function doScrollTo(ctx, params) { +- var _a3, _b; ++function finishInitialScroll(ctx, options) { ++ var _a3, _b, _c; + const state = ctx.state; +- const { animated, horizontal, offset } = params; +- state.scheduledWork.cancel("platformScrollCompletion"); +- const scroller = state.refScroller.current; +- const node = scroller == null ? void 0 : scroller.getScrollableNode(); +- if (!scroller || !node) { +- return; +- } +- const isAnimated = !!animated; +- const isHorizontal = !!horizontal; +- const contentSize = isHorizontal ? getContentSize(ctx) : void 0; +- const left = isHorizontal ? toNativeHorizontalOffset(state, offset, contentSize) : 0; +- const top = isHorizontal ? 0 : offset; +- scroller.scrollTo({ animated: isAnimated, x: left, y: top }); +- if (isAnimated) { +- const target = (_b = (_a3 = scroller.getScrollEventTarget) == null ? void 0 : _a3.call(scroller)) != null ? _b : null; +- listenForScrollEnd(ctx, { +- readOffset: () => scroller.getCurrentScrollOffset(), +- target, +- targetOffset: offset +- }); +- } else { +- state.scroll = offset; +- const targetToken = state.scrollingTo; +- state.scheduledWork.timeout( +- () => { +- if (targetToken === state.scrollingTo) { +- finishScrollTo(ctx); +- } +- }, +- 100, +- "platformScrollCompletion" +- ); +- } +-} +-function listenForScrollEnd(ctx, params) { +- const { readOffset, target, targetOffset } = params; +- if (!target) { +- finishScrollTo(ctx); +- return; +- } +- const supportsScrollEnd = "onscrollend" in target; +- let idleTimeout; +- let settled = false; +- const { scheduledWork, scrollingTo: targetToken } = ctx.state; +- const maxTimeout = setTimeout(() => finish("max"), SCROLL_END_MAX_MS); +- const cleanup = () => { +- target.removeEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.removeEventListener("scrollend", onScrollEnd); +- } +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); +- } +- clearTimeout(maxTimeout); +- }; +- const cancel = () => { +- if (!settled) { +- settled = true; +- cleanup(); ++ if ((options == null ? void 0 : options.resolvedOffset) !== void 0) { ++ syncInitialScrollOffset(state, options.resolvedOffset); ++ } else if ((options == null ? void 0 : options.syncObservedOffset) && ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "offset") { ++ const observedOffset = (_c = (_b = state.refScroller.current) == null ? void 0 : _b.getCurrentScrollOffset) == null ? void 0 : _c.call(_b); ++ if (typeof observedOffset === "number" && Number.isFinite(observedOffset)) { ++ syncInitialScrollOffset(state, observedOffset); + } +- }; +- const finish = (reason) => { +- if (settled) return; +- if (targetToken !== ctx.state.scrollingTo) { +- scheduledWork.cancel("platformScrollCompletion"); +- return; ++ } ++ const complete = () => { ++ var _a4, _b2, _c2, _d, _e; ++ const shouldReleaseDeferredPublicOnScroll = ((_a4 = state.initialScrollSession) == null ? void 0 : _a4.kind) === "bootstrap"; ++ const finalScrollOffset = (_d = (_c2 = (_b2 = options == null ? void 0 : options.resolvedOffset) != null ? _b2 : state.scrollPending) != null ? _c2 : state.scroll) != null ? _d : 0; ++ initialScrollWatchdog.clear(state); ++ if ((options == null ? void 0 : options.preserveTarget) && state.initialScroll) { ++ state.clearPreservedInitialScrollOnNextFinish = void 0; ++ setInitialScrollSession(state); ++ clearPreservedInitialScrollTargetTimeout(state); ++ if (options == null ? void 0 : options.schedulePreservedTargetClear) { ++ state.scheduledWork.timeout( ++ () => { ++ var _a5; ++ if (!state.didFinishInitialScroll || ((_a5 = state.scrollingTo) == null ? void 0 : _a5.isInitialScroll) || !state.initialScroll) { ++ return; ++ } ++ clearPreservedInitialScrollTarget(state); ++ }, ++ PRESERVED_INITIAL_SCROLL_FALLBACK_CLEAR_DELAY_MS, ++ "preservedInitialScroll" ++ ); ++ } ++ } else { ++ clearPreservedInitialScrollTarget(state); + } +- const currentOffset = readOffset(); +- const isNearTarget = Math.abs(currentOffset - targetOffset) <= SCROLL_END_TARGET_EPSILON; +- if (reason === "scrollend" && !isNearTarget) { +- return; ++ if (options == null ? void 0 : options.recalculateItems) { ++ recalculateSettledScroll(ctx); + } +- scheduledWork.cancel("platformScrollCompletion"); +- finishScrollTo(ctx); +- }; +- const onScroll2 = () => { +- if (idleTimeout !== void 0) { +- clearTimeout(idleTimeout); ++ setInitialRenderState(ctx, { didInitialScroll: true }); ++ if (shouldReleaseDeferredPublicOnScroll) { ++ releaseDeferredPublicOnScroll(ctx, finalScrollOffset); + } +- idleTimeout = setTimeout(() => finish("idle"), SCROLL_END_IDLE_MS); ++ (_e = options == null ? void 0 : options.onFinished) == null ? void 0 : _e.call(options); + }; +- const onScrollEnd = () => finish("scrollend"); +- target.addEventListener("scroll", onScroll2); +- if (supportsScrollEnd) { +- target.addEventListener("scrollend", onScrollEnd); +- } else { +- idleTimeout = setTimeout(() => finish("idle"), SMOOTH_SCROLL_DURATION_MS); ++ if (options == null ? void 0 : options.waitForCompletionFrame) { ++ requestAnimationFrame(complete); ++ return; + } +- scheduledWork.register("platformScrollCompletion", cancel); ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; + } + + // src/core/scrollRequestTracker.ts +@@ -1462,30 +1552,6 @@ function getScrollRequestTracker(ctx) { + return ctx.scrollRequestTracker; + } + +-// src/utils/requestAdjust.ts +-function requestAdjust(ctx, positionDiff, source) { +- const state = ctx.state; +- if (Math.abs(positionDiff) > 0.1) { +- const doit = () => { +- { +- state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } +- } +- }; +- state.scroll += positionDiff; +- state.scrollForNextCalculateItemsInView = void 0; +- const readyToRender = peek$(ctx, "readyToRender"); +- if (readyToRender) { +- doit(); +- } else { +- state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); +- } +- } +-} +- + // src/core/doMaintainScrollAtEnd.ts + function finishMaintainScrollAtEnd(ctx) { + var _a3; +@@ -2120,7 +2186,7 @@ function pinScrollTargetRenderRange(ctx, targetOffset, targetIndex) { + } + } + function scrollTo(ctx, params) { +- var _a3, _b; ++ var _a3, _b, _c; + const state = ctx.state; + const { noScrollingTo, forceScroll, ...scrollTarget } = params; + const { +@@ -2152,6 +2218,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (scrollTarget.index !== void 0 && scrollTarget.viewPosition !== void 0) { ++ beginScrollTargetSettle(ctx, { ++ index: scrollTarget.index, ++ viewOffset: (_b = scrollTarget.viewOffset) != null ? _b : 0, ++ viewPosition: scrollTarget.viewPosition ++ }); ++ } else { ++ clearScrollTargetSettle(state); ++ } + } + } + state.scrollPending = targetOffset; +@@ -2159,7 +2234,7 @@ function scrollTo(ctx, params) { + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +- (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state); ++ (_c = state.triggerCalculateItemsInView) == null ? void 0 : _c.call(state); + } + } else { + updateScroll(ctx, targetOffset, true, { markHasScrolled: false }); +@@ -4423,7 +4498,8 @@ function calculateItemsInView(ctx, params = {}) { + const scrollBeforeMVCP = state.scroll; + const scrollAdjustPendingBeforeMVCP = (_f = peek$(ctx, "scrollAdjustPending")) != null ? _f : 0; + checkMVCP == null ? void 0 : checkMVCP(); +- const didMVCPAdjustScroll = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); ++ const didSettleScrollTarget = suppressInitialScrollSideEffects ? false : settleScrollTarget(ctx); ++ const didMVCPAdjustScroll = didSettleScrollTarget || !!checkMVCP && (state.scroll !== scrollBeforeMVCP || ((_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0) !== scrollAdjustPendingBeforeMVCP); + if (didMVCPAdjustScroll) { + updateScroll2(state.scroll); + updateScrollRange(); +@@ -6586,6 +6662,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { ++ var _a3; + const { state } = ctx; + const previousHeaderSize = peek$(ctx, "headerSize") || 0; + const didChange = previousHeaderSize !== size; +@@ -6595,6 +6672,9 @@ function setHeaderSize(ctx, size) { + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { + requestAdjust(ctx, size - previousHeaderSize); ++ } else if ((_a3 = state.props.maintainScrollAtEnd) == null ? void 0 : _a3.onHeaderLayout) { ++ handleBootstrapInitialScrollLayoutChange(ctx); ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7658,13 +7738,14 @@ function getRenderedItem(ctx, key, containerId) { + + // src/utils/normalizeMaintainScrollAtEnd.ts + function normalizeMaintainScrollAtEndOn(on, hasExplicitOn) { +- var _a3, _b, _c, _d; ++ var _a3, _b, _c, _d, _e; + return { + animated: false, + onDataChange: hasExplicitOn ? (_a3 = on == null ? void 0 : on.dataChange) != null ? _a3 : false : true, + onFooterLayout: hasExplicitOn ? (_b = on == null ? void 0 : on.footerLayout) != null ? _b : false : true, +- onItemLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.itemLayout) != null ? _c : false : true, +- onLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.layout) != null ? _d : false : true ++ onHeaderLayout: hasExplicitOn ? (_c = on == null ? void 0 : on.headerLayout) != null ? _c : false : true, ++ onItemLayout: hasExplicitOn ? (_d = on == null ? void 0 : on.itemLayout) != null ? _d : false : true, ++ onLayout: hasExplicitOn ? (_e = on == null ? void 0 : on.layout) != null ? _e : false : true + }; + } + function normalizeMaintainScrollAtEnd(value) { +diff --git a/node_modules/@legendapp/list/reanimated.d.ts b/node_modules/@legendapp/list/reanimated.d.ts +index e504332..99337c1 100644 +--- a/node_modules/@legendapp/list/reanimated.d.ts ++++ b/node_modules/@legendapp/list/reanimated.d.ts +@@ -489,6 +489,12 @@ interface AlwaysRenderConfig { + interface MaintainScrollAtEndOnOptions { + dataChange?: boolean; + footerLayout?: boolean; ++ /** ++ * Keep the list pinned to the end when the header changes size. A header that measures larger ++ * than its estimate pushes every item down, which leaves a list that had already scrolled to ++ * its end short of it by the difference. ++ */ ++ headerLayout?: boolean; + itemLayout?: boolean; + layout?: boolean; + } +diff --git a/node_modules/@legendapp/list/section-list.d.ts b/node_modules/@legendapp/list/section-list.d.ts +index a790f7f..9a3c2ef 100644 +--- a/node_modules/@legendapp/list/section-list.d.ts ++++ b/node_modules/@legendapp/list/section-list.d.ts +@@ -545,6 +545,12 @@ interface AlwaysRenderConfig { + interface MaintainScrollAtEndOnOptions { + dataChange?: boolean; + footerLayout?: boolean; ++ /** ++ * Keep the list pinned to the end when the header changes size. A header that measures larger ++ * than its estimate pushes every item down, which leaves a list that had already scrolled to ++ * its end short of it by the difference. ++ */ ++ headerLayout?: boolean; + itemLayout?: boolean; + layout?: boolean; + } diff --git a/shared/tests/e2e/electron/flows/chat-link-jump.test.ts b/shared/tests/e2e/electron/flows/chat-link-jump.test.ts new file mode 100644 index 000000000000..3060a9483e1c --- /dev/null +++ b/shared/tests/e2e/electron/flows/chat-link-jump.test.ts @@ -0,0 +1,323 @@ +import type {Locator, Page} from '@playwright/test' +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToChat} from '@/tests/e2e/electron/helpers/navigate' +import * as T from '@/tests/e2e/shared/test-ids' + +// Opening a conversation directly onto one of its messages — the cold path. +// +// chat-search-hit covers the warm one: it opens a thread and then searches inside it, so the list +// has always rendered content by the time anything asks it to centre. The branch this covers is the +// other one — a thread mounting with a centred target already pending, which is what a permalink +// does. It is reached the way a reader reaches it, through the app's own affordances: copy a link to +// a message in conversation B, paste and send it in conversation A, click it. That routes through +// handleKeybaseLink -> previewConversation -> navigateToThread(..., highlightMessageID), which on +// desktop resets the chat root params and mounts a fresh thread. No OS-level deeplink needed. +// +// The link must point at a genuinely different conversation: navigateToThread takes a +// sameVisibleThread branch when the target is already on screen, and that branch does not remount, +// which is the warm path again. +// +// Sending a link message is real data in the smoke user's real chat history, so every run that +// sends one leaves it behind permanently. Only one such message is ever needed - the app doesn't +// care who authored the link it's clicking, or when. So this test looks for one an earlier run +// already left in conversation A before resorting to sending a new one, and only falls back to +// sending when no leftover link is usable. + +// A row has to be visible by more than a hairline to count as landed on, matching chat-search-hit +// and the iOS flow. +// No retries. The second attempt runs against a thread this test has already fetched once, so a +// pass on retry would not be a pass on the path the first attempt took - the same reason +// chat-thread-bottom turns them off. +test.describe.configure({retries: 0}) + +const MIN_VISIBLE_HEIGHT = 24 +// Centring is checked loosely on purpose. Sub-pixel precision is not the point — landing on the +// right message rather than at one end of the thread is. A third of the viewport either side of the +// middle still fails every way this can go wrong (top of the loaded window, bottom of the thread, +// off screen entirely). +const CENTRE_BAND_FRACTION = 1 / 3 +// The target has to sit well above the newest message, or a thread that simply stayed at its end +// would pass without anything having jumped. This also gates link reuse: an old link is only worth +// clicking if its target is still this far from B's current newest message, or a wrong landing +// (staying put) would go undetected. +const MIN_DISTANCE_FROM_END = 1_200 +// A conversation with less scrollable history than this cannot show the difference. +const MIN_SCROLLABLE_OVERFLOW = 1_500 + +// The scroller is the element LegendList renders inside the wrapper carrying the testID. Reached +// through the wrapper's first child, the same way chat-thread-bottom does it. +const distanceFromEnd = async (page: Page): Promise => + page.evaluate((testID: string) => { + type Scroller = {clientHeight: number; scrollHeight: number; scrollTop: number} + const doc = ( + globalThis as unknown as { + document?: {querySelector: (selector: string) => {firstElementChild?: Scroller | null} | null} + } + ).document + const scroller = doc?.querySelector(`[data-testid="${testID}"]`)?.firstElementChild + if (!scroller) return undefined + return Math.round(scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop) + }, T.CHAT_MESSAGE_LIST) + +const scrollableOverflow = async (page: Page): Promise => + page.evaluate((testID: string) => { + type Scroller = {clientHeight: number; scrollHeight: number} + const doc = ( + globalThis as unknown as { + document?: {querySelector: (selector: string) => {firstElementChild?: Scroller | null} | null} + } + ).document + const scroller = doc?.querySelector(`[data-testid="${testID}"]`)?.firstElementChild + if (!scroller) return undefined + return Math.round(scroller.scrollHeight - scroller.clientHeight) + }, T.CHAT_MESSAGE_LIST) + +// Inbox rows carry the conversation name on their first line. Matched exactly rather than with +// hasText: a row's second line is the latest message, and this flow sends a message that contains +// another conversation's name, so a substring match picks the wrong row from the second run on. +const rowName = async (row: Locator): Promise => + (await row.innerText()).split('\n')[0]?.trim() ?? '' + +// A row's displayed name isn't always the conversation's full label. A team's default "general" +// channel shows as a small-team row with just the team name; any other channel shows as its own +// row with just "# channelname" (the team name isn't repeated there). getConversationLabel, which +// is what a copied link encodes, always writes "team#channel" though - so a link's conv name needs +// both forms accepted, not just an exact match against what a row shows. +const rowMatchesConvName = async (row: Locator, convName: string): Promise => { + const text = await rowName(row) + if (text === convName) return true + const hashIndex = convName.indexOf('#') + if (hashIndex === -1) return false + const team = convName.slice(0, hashIndex) + const channel = convName.slice(hashIndex + 1) + if (channel === 'general' && text === team) return true + return text.replace(/^#\s*/, '') === channel +} + +const openConversationNamed = async (page: Page, rows: Locator, name: string): Promise => { + const count = await rows.count() + for (let i = 0; i < count; i++) { + const row = rows.nth(i) + if (!(await rowMatchesConvName(row, name))) continue + await row.click({force: true, timeout: 10_000}) + await page.waitForSelector(`[data-testid="${T.CHAT_MESSAGE_LIST}"]`, {timeout: 10_000}) + await page.waitForTimeout(2_500) + return + } + throw new Error(`no inbox row named "${name}"`) +} + +// What "Copy a link to this message" puts on the clipboard: constants/deeplinks.tsx's +// linkFromConvAndMessage(conv, messageID) - conv is the conversation's display label (what +// rowName also reads), messageID is what the app renders as data-ordinal once a message has sent. +const LINK_PATTERN = /^keybase:\/\/chat\/(.+)\/(\d+)$/ + +// Looks for a message already sitting in conversation A whose text is a link from an earlier run +// of this test. Recent messages are checked first without scrolling - every prior run that fell +// back to sending left one near the bottom, so the common case needs no scroll at all - and a +// bounded scroll back covers the case where other messages have since pushed all of them out of +// view. +const findExistingLink = async (page: Page): Promise<{convName: string; ordinal: string; text: string} | undefined> => { + for (let attempt = 0; attempt < 6; attempt++) { + const candidates = page.locator('[data-ordinal]') + const n = await candidates.count() + for (let i = n - 1; i >= 0; i--) { + const text = (await candidates.nth(i).innerText()).trim() + const match = LINK_PATTERN.exec(text) + if (match) return {convName: match[1]!, ordinal: match[2]!, text} + } + await page.mouse.wheel(0, -600) + await page.waitForTimeout(250) + } + return undefined +} + +// Scrolls conversation B upward looking for the reused link's target row. If it turns up wholly on +// screen at a point at least MIN_DISTANCE_FROM_END from B's current newest message, the reused link +// is still good. If the scroll runs out first - the target has scrolled closer to the end since the +// link was made, or is no longer reachable at all - the caller falls back to sending a fresh link +// instead: asserting a landing that could not distinguish "jumped" from "already there" would prove +// nothing. +const findReusedTargetRow = async ( + page: Page, + listBox: {x: number; y: number; width: number; height: number}, + ordinal: string +): Promise => { + for (let attempt = 0; attempt < 30; attempt++) { + await page.mouse.wheel(0, -600) + await page.waitForTimeout(250) + if (((await distanceFromEnd(page)) ?? 0) < MIN_DISTANCE_FROM_END) continue + await page.waitForTimeout(500) + const candidate = page.locator(`[data-ordinal="${ordinal}"]`).first() + if ((await candidate.count()) === 0) continue + const box = await candidate.boundingBox() + if (!box) continue + const wholly = box.y >= listBox.y + 20 && box.y + box.height <= listBox.y + listBox.height - 20 + if (wholly) return candidate + } + return undefined +} + +test('opens a conversation onto the message a link points at', async ({page}) => { + test.setTimeout(180_000) + const smokeUser = process.env['KB_SMOKE_USER'] + expect(smokeUser, 'KB_SMOKE_USER is not set').toBeTruthy() + + await navigateToChat(page) + const rows = page.locator( + `[data-testid="${T.CHAT_INBOX_ROW}"], [data-testid="${T.CHAT_INBOX_CHANNEL_ROW}"]` + ) + + let convB = '' + let targetOrdinal = '' + let link = '' + + // ---- Try to reuse a link an earlier run already left in conversation A. ---- + await openConversationNamed(page, rows, smokeUser!) + const existing = await findExistingLink(page) + if (existing) { + try { + await openConversationNamed(page, rows, existing.convName) + const overflow = await scrollableOverflow(page) + const listBox = + overflow !== undefined && overflow >= MIN_SCROLLABLE_OVERFLOW + ? await page.getByTestId(T.CHAT_MESSAGE_LIST).first().boundingBox() + : null + if (listBox) { + await page.mouse.move(listBox.x + listBox.width / 2, listBox.y + listBox.height / 2) + const row = await findReusedTargetRow(page, listBox, existing.ordinal) + if (row) { + convB = existing.convName + targetOrdinal = existing.ordinal + link = existing.text + } + } + } catch { + // existing.convName no longer names an inbox row (conversation renamed or gone) - fall + // through to sending a fresh link below. + } + } + console.log( + link + ? `[chat-link-jump] reusing an existing link: ${link}` + : '[chat-link-jump] no reusable link found; sending a new one' + ) + + // ---- Fall back to the original flow: discover a conversation and send a fresh link. ---- + if (!link) { + // B is discovered rather than hard-coded — a real username in a committed test would pin the + // suite to one machine — but it is pinned by the name it turns out to have for the rest of the + // run, so the second visit is provably the same conversation as the first. + const rowCount = Math.min(await rows.count(), 12) + for (let i = 0; i < rowCount && !convB; i++) { + const name = await rowName(rows.nth(i)) + // A is the smoke account's own conversation: the one message this flow sends goes there. + if (!name || name === smokeUser) continue + await openConversationNamed(page, rows, name) + const overflow = await scrollableOverflow(page) + if (overflow !== undefined && overflow >= MIN_SCROLLABLE_OVERFLOW) convB = name + } + expect( + convB, + 'no conversation had enough history to jump within, so nothing was checked' + ).toBeTruthy() + + // Scroll back through B until the newest message is far enough away, then take a row that is + // wholly on screen and small enough to measure. `data-ordinal` is the app's own per-message + // attribute; there is no testID for "a message row", and the ordinal is what the copied link + // encodes, so it is also how the landing is checked at the other end. + const listBox = await page.getByTestId(T.CHAT_MESSAGE_LIST).first().boundingBox() + expect(listBox, 'the message list has no box').not.toBeNull() + await page.mouse.move(listBox!.x + listBox!.width / 2, listBox!.y + listBox!.height / 2) + + let targetRow: Locator | undefined + for (let attempt = 0; attempt < 30 && !targetRow; attempt++) { + await page.mouse.wheel(0, -600) + await page.waitForTimeout(250) + if (((await distanceFromEnd(page)) ?? 0) < MIN_DISTANCE_FROM_END) continue + await page.waitForTimeout(500) + const candidates = page.locator('[data-ordinal]') + const n = await candidates.count() + for (let i = 0; i < n; i++) { + const candidate = candidates.nth(i) + const box = await candidate.boundingBox() + if (!box) continue + const text = (await candidate.innerText()).trim() + const wholly = box.y >= listBox!.y + 20 && box.y + box.height <= listBox!.y + listBox!.height - 20 + // Media rows are taller than the viewport's usable band and often have no text to identify + // them by, so a short text row is the honest handle here. + if (box.height > 250 || !wholly || text.length < 6) continue + targetOrdinal = (await candidate.getAttribute('data-ordinal')) ?? '' + targetRow = candidate + break + } + } + expect( + targetRow, + `no message in "${convB}" was far enough above its newest one to link to` + ).toBeTruthy() + + await targetRow!.hover() + await targetRow!.getByTestId(T.CHAT_MESSAGE_MENU_BUTTON).first().click({force: true, timeout: 10_000}) + await page.getByText('Copy a link to this message').first().click({timeout: 10_000}) + await page.waitForTimeout(700) + + // Pasting rather than typing the link out: the copy above put it on the real clipboard and + // there is no way to read that back from the renderer (clipboard-read permission is denied in + // the app), so the paste both delivers it and reveals what was copied. A plain Meta+V is a + // synthetic key event the renderer ignores; the editing command has to be attached to it. + await openConversationNamed(page, rows, smokeUser!) + const input = page.getByTestId(T.CHAT_INPUT) + await input.click() + await input.fill('') + const cdp = await page.context().newCDPSession(page) + const key = {code: 'KeyV', key: 'v', modifiers: 4, nativeVirtualKeyCode: 86, windowsVirtualKeyCode: 86} + await cdp.send('Input.dispatchKeyEvent', {...key, commands: ['paste'], type: 'keyDown'}) + await cdp.send('Input.dispatchKeyEvent', {...key, type: 'keyUp'}) + await page.waitForTimeout(600) + link = await input.inputValue() + expect(link, 'the message menu did not put a keybase chat link on the clipboard').toMatch( + new RegExp(`^keybase://chat/.+/${targetOrdinal}$`) + ) + await input.press('Enter') + } + + await openConversationNamed(page, rows, smokeUser!) + const sentLink = page.locator(`[data-testid="${T.CHAT_MESSAGE_LIST}"]`).getByText(link, {exact: true}) + await expect(sentLink.last()).toBeVisible({timeout: 15_000}) + await sentLink.last().click() + + // The thread this lands in is a fresh mount with a centred target already pending. Poll rather + // than sleep once: the centred fetch clears and refetches the thread, so the row arrives well + // after the navigation does. + const hit = page.getByTestId(T.CHAT_SEARCH_HIT).first() + await expect( + hit, + `clicking the link to ${link} never landed on the message: the thread settled ${await distanceFromEnd(page)}px from its end without the target on screen` + ).toBeVisible({timeout: 20_000}) + // Let any late row measurement settle before reading positions, the same way the search flow does. + await page.waitForTimeout(2_000) + + // We really did leave A: the link message is A's newest and would still be rendered if we had not. + await expect(sentLink).toHaveCount(0) + + const hitBox = await hit.boundingBox({timeout: 5_000}) + const listAfter = await page.getByTestId(T.CHAT_MESSAGE_LIST).first().boundingBox() + expect(hitBox, 'the linked message was highlighted but had no box, so nothing was measured').not.toBeNull() + expect(listAfter, 'the message list has no box').not.toBeNull() + + const visibleHeight = + Math.min(hitBox!.y + hitBox!.height, listAfter!.y + listAfter!.height) - Math.max(hitBox!.y, listAfter!.y) + expect( + visibleHeight >= Math.min(hitBox!.height, MIN_VISIBLE_HEIGHT), + `the linked message is outside the list: hit y=${Math.round(hitBox!.y)} h=${Math.round(hitBox!.height)}, list y=${Math.round(listAfter!.y)} h=${Math.round(listAfter!.height)}` + ).toBe(true) + + const offCentre = Math.abs( + hitBox!.y + hitBox!.height / 2 - (listAfter!.y + listAfter!.height / 2) + ) + expect( + offCentre, + `the linked message landed ${Math.round(offCentre)}px from the middle of the list (viewport ${Math.round(listAfter!.height)}px)` + ).toBeLessThanOrEqual(listAfter!.height * CENTRE_BAND_FRACTION) +}) diff --git a/shared/tests/e2e/electron/flows/chat-search-hit.test.ts b/shared/tests/e2e/electron/flows/chat-search-hit.test.ts new file mode 100644 index 000000000000..f44c27d4846a --- /dev/null +++ b/shared/tests/e2e/electron/flows/chat-search-hit.test.ts @@ -0,0 +1,111 @@ +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToChat} from '@/tests/e2e/electron/helpers/navigate' +import * as T from '@/tests/e2e/shared/test-ids' + +// Searching a thread has to land on the hit, and then leave the thread alone. Both were manual +// checks until now: the list was landing with the hit far below the viewport, and after that was +// fixed a thread the reader had scrolled away from could still pull itself back. +// +// One test rather than two, because the second half depends on the state the first leaves: the +// search bar is open and a hit is selected, and re-entering that from scratch would just be the +// first half again. +// A row has to be visible by more than a hairline to count as landed on, matching the iOS flow. +const MIN_VISIBLE_HEIGHT = 24 + +test('lands on every search hit, then stays where the reader scrolls it', async ({page}) => { + test.setTimeout(120_000) + // Named, not "whichever row is first". The inbox is ordered by recency and the suites send + // messages of their own, so the first row is a different conversation from one run to the next — + // and with it the hit count and which words match at all. + const smokeUser = process.env['KB_SMOKE_USER'] + expect(smokeUser, 'KB_SMOKE_USER is not set').toBeTruthy() + await navigateToChat(page) + const row = page + .getByTestId(T.CHAT_INBOX_ROW) + .filter({hasText: smokeUser!}) + .first() + await row.click({timeout: 10_000}) + await page.waitForSelector(`[data-testid="${T.CHAT_MESSAGE_LIST}"]`, {timeout: 5_000}) + + // force: the conversation header sits in the window's WebkitAppRegion drag region, which makes + // playwright's actionability check wait forever on a control that is perfectly clickable. + await page.getByTestId(T.CHAT_HEADER_SEARCH_BUTTON).first().click({force: true}) + await page.waitForTimeout(1_000) + // A word common enough to hit repeatedly in any conversation with history. + await page.keyboard.type('the') + await page.waitForTimeout(4_000) + + // Enter steps to the next hit, wrapping around at the end — which is the case that used to land + // off screen, since wrapping jumps the furthest. + let checked = 0 + for (let i = 0; i < 12; i++) { + await page.keyboard.press('Enter') + await page.waitForTimeout(900) + const hit = await page + .getByTestId(T.CHAT_SEARCH_HIT) + .first() + .boundingBox({timeout: 3_000}) + .catch(() => null) + const list = await page.getByTestId(T.CHAT_MESSAGE_LIST).first().boundingBox() + if (!hit || !list) continue + checked++ + // By more than a hairline: a row overlapping the viewport by a pixel has not "landed on the + // hit", and the iOS flow holds the same line. + const visibleHeight = Math.min(hit.y + hit.height, list.y + list.height) - Math.max(hit.y, list.y) + const onScreen = visibleHeight >= Math.min(hit.height, MIN_VISIBLE_HEIGHT) + expect( + onScreen, + `step ${i}: hit at y=${Math.round(hit.y)} h=${Math.round(hit.height)} is outside the list (y=${Math.round(list.y)} h=${Math.round(list.height)})` + ).toBe(true) + } + // Without this the loop above passes by never measuring anything. + expect(checked, 'no hit was ever measurable, so nothing was checked').toBeGreaterThan(0) + + const listBox = await page.getByTestId(T.CHAT_MESSAGE_LIST).first().boundingBox() + expect(listBox).not.toBeNull() + await page.mouse.move(listBox!.x + listBox!.width / 2, listBox!.y + listBox!.height / 2) + for (let i = 0; i < 10; i++) { + await page.mouse.wheel(0, -600) + await page.waitForTimeout(150) + } + await page.waitForTimeout(1_500) + + const readHit = async () => + page + .getByTestId(T.CHAT_SEARCH_HIT) + .first() + .boundingBox({timeout: 3_000}) + .catch(() => null) + + // Watch rather than look once: a re-centre lands whenever the rows scrolled past finish + // measuring, which is after the gesture rather than during it. + // The row is often scrolled clean out of the render window, so its position is not always + // readable. The top of the thread is, in every state — without a second reading like it, both + // branches below can be skipped and this half of the test asserts nothing at all. + const readThreadTop = async () => + page + .getByTestId(T.CHAT_THREAD_TOP) + .first() + .boundingBox({timeout: 3_000}) + .then(b => (b ? b.y : null)) + .catch(() => null) + + const settled = await readHit() + const settledTop = await readThreadTop() + await page.waitForTimeout(3_000) + const after = await readHit() + const afterTop = await readThreadTop() + + if (settled && after) { + const moved = Math.abs(after.y - settled.y) + expect(moved, `the thread scrolled itself ${Math.round(moved)}px back toward the hit`).toBeLessThanOrEqual(8) + } else if (!settled && after) { + // Scrolled far enough to unmount the row, and then it came back — which only a scroll does. + throw new Error('the hit came back into the render window after being scrolled away from') + } else if (settledTop !== null && afterTop !== null) { + const moved = Math.abs(afterTop - settledTop) + expect(moved, `the thread moved ${Math.round(moved)}px on its own after the drag`).toBeLessThanOrEqual(8) + } else { + throw new Error('neither the hit nor the top of the thread could be measured, so nothing was checked') + } +}) diff --git a/shared/tests/e2e/electron/flows/chat-thread-bottom.test.ts b/shared/tests/e2e/electron/flows/chat-thread-bottom.test.ts new file mode 100644 index 000000000000..0e94c5f0d0f7 --- /dev/null +++ b/shared/tests/e2e/electron/flows/chat-thread-bottom.test.ts @@ -0,0 +1,108 @@ +import type {Page} from '@playwright/test' +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToChat} from '@/tests/e2e/electron/helpers/navigate' +import * as T from '@/tests/e2e/shared/test-ids' + +// Opening a conversation has to leave the reader on its newest message. It stopped doing that for +// threads whose rows grow a frame or two after the list has already landed - a link preview +// committing, an image measuring, the full thread response replacing the cached one. The list +// anchored to the end it could see, the content then grew past it, and the thread sat hundreds of +// pixels above the newest message. Measured in the app: it landed on an extent of 6891, the content +// committed 7224, and the thread stayed 432px short of the end. +// +// Image responses are held back so that growth lands after the initial scroll rather than before it, +// which is what makes this able to fail rather than passing on whatever the disk cache happened to +// have warm. +const IMAGE_DELAY_MS = 700 +// A couple of pixels of sub-pixel residue is fine; anything more is a reader looking at old +// messages with the newest one off screen. +const MAX_DISTANCE_FROM_END = 8 +// Threads shorter than their viewport cannot be short of their end, so they prove nothing. +const MIN_SCROLLABLE_OVERFLOW = 200 + +type ListMetrics = {clientHeight: number; distanceFromEnd: number; scrollHeight: number} | null + +// The scroller is the list element LegendList renders inside the wrapper that carries the testID. +// Reached through globalThis because this suite's tsconfig has no DOM lib, the same way the app's +// desktop-only code does it. +const readListMetrics = async (page: Page): Promise => + page.evaluate((testID: string) => { + type Scroller = {clientHeight: number; scrollHeight: number; scrollTop: number} + const doc = ( + globalThis as unknown as { + document?: {querySelector: (selector: string) => {firstElementChild?: Scroller | null} | null} + } + ).document + const scroller = doc?.querySelector(`[data-testid="${testID}"]`)?.firstElementChild + if (!scroller) return null + return { + clientHeight: Math.round(scroller.clientHeight), + distanceFromEnd: Math.round(scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop), + scrollHeight: Math.round(scroller.scrollHeight), + } + }, T.CHAT_MESSAGE_LIST) + +// No retries. The growth this depends on lands a frame or two after the list does, so it is timing +// dependent by nature - and a retry that passes hides the regression the flow exists for. Verified: +// with the library fix disabled the first attempt failed and the retry passed. +test.describe.configure({retries: 0}) + +test('opens every conversation on its newest message', async ({page}) => { + test.setTimeout(120_000) + await navigateToChat(page) + + // Routing every request so the handler can pick the images out; everything else continues + // untouched. Removed at the end because the page is shared with the rest of the suite. + await page.route('**/*', async route => { + if (route.request().resourceType() === 'image') { + await new Promise(resolve => setTimeout(resolve, IMAGE_DELAY_MS)) + } + await route.continue() + }) + + const checked: string[] = [] + try { + // Team channels as well as one-to-ones: the threads with enough history to grow after landing + // are mostly team channels, and the flow that found this bug was clicking through them. + const rows = page.locator( + `[data-testid="${T.CHAT_INBOX_CHANNEL_ROW}"], [data-testid="${T.CHAT_INBOX_ROW}"]` + ) + const rowCount = Math.min(await rows.count(), 25) + for (let i = 0; i < rowCount && checked.length < 5; i++) { + const row = rows.nth(i) + const name = (await row.innerText().catch(() => '')).split('\n')[0] ?? `row ${i}` + // Open a neighbour first so the click below is a real open. The inbox row of the conversation + // already on screen has no click handler at all (chat/inbox/row/small-team: onSelectConversation + // is undefined when isSelected), so clicking it changes nothing - and a thread left parked in + // its history by an earlier flow would be measured as if this test had just opened it. That is + // what made this fail only when a search flow ran first and left its conversation open. + if (rowCount > 1) { + await rows + .nth((i + 1) % rowCount) + .click({force: true, timeout: 10_000}) + .catch(() => {}) + await page.waitForSelector(`[data-testid="${T.CHAT_MESSAGE_LIST}"]`, {timeout: 10_000}) + await page.waitForTimeout(500) + } + await row.click({force: true, timeout: 10_000}).catch(() => {}) + await page.waitForSelector(`[data-testid="${T.CHAT_MESSAGE_LIST}"]`, {timeout: 10_000}) + // Long enough for the delayed images to commit and for anything following the end to react. + await page.waitForTimeout(IMAGE_DELAY_MS + 2_500) + + const metrics = await readListMetrics(page) + if (!metrics || metrics.scrollHeight - metrics.clientHeight < MIN_SCROLLABLE_OVERFLOW) continue + checked.push(name) + expect( + metrics.distanceFromEnd, + `${name}: thread settled ${metrics.distanceFromEnd}px above its newest message (content ${metrics.scrollHeight}, viewport ${metrics.clientHeight})` + ).toBeLessThanOrEqual(MAX_DISTANCE_FROM_END) + } + } finally { + await page.unroute('**/*') + } + + // Without this the loop above passes by never measuring a thread long enough to fail. + expect(checked.length, 'no conversation had more content than its viewport, so nothing was checked').toBeGreaterThan( + 0 + ) +}) diff --git a/shared/tests/e2e/ios-appium/all.test.ts b/shared/tests/e2e/ios-appium/all.test.ts index d5895c7e9be7..a6af9d957d56 100644 --- a/shared/tests/e2e/ios-appium/all.test.ts +++ b/shared/tests/e2e/ios-appium/all.test.ts @@ -4,6 +4,7 @@ // each) and the app stays warm between flows. import './flows/android-activity-restart.test' import './flows/chat-conversation.test' +import './flows/chat-search-hit.test' import './flows/chat-send-message.test' import './flows/crypto-outputs.test' import './flows/crypto-subtabs.test' diff --git a/shared/tests/e2e/ios-appium/flows/chat-search-hit.test.ts b/shared/tests/e2e/ios-appium/flows/chat-search-hit.test.ts new file mode 100644 index 000000000000..4bd01e683f19 --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/chat-search-hit.test.ts @@ -0,0 +1,385 @@ +import type {ChainablePromiseElement} from 'webdriverio' +import {expect} from '@wdio/globals' +import {requireSmokeUser} from '../helpers/app' +import {anyExist, byTextWithin, el, tab, waitForTestID, enterText} from '../helpers/elements' +import {dismissKeyboard, escapeToTabs} from '../helpers/navigate' +import * as T from '../../shared/test-ids' + +// A word common enough to match throughout the thread, so hits span messages of different heights. +const QUERY = 'one' +// A word whose hit sits among the messages already on screen: jumping a few rows is the case where +// the list has nothing to load and the scroll lands against a content size that has not caught up. +// Taken from the messages this suite itself sends ("e2e-test-"), so it is both present +// and recent in whatever conversation the run is pointed at. +const SAME_SCREEN_QUERY = 'test' +// Flings back through the thread until a page of older messages arrives. +const MAX_FLINGS = 20 +// Drags needed to move a hit clear of the viewport. One drag moves about a third of a screen, and +// the hit starts centred, so this is "enough to be sure" rather than a tuned number. +const MAX_DRAGS_AWAY = 6 +// A row has to be visible by more than a hairline to count as landed on. +const MIN_VISIBLE_HEIGHT = 24 +// How far the top of the thread has to jump away from the viewport to be a page of older messages +// arriving rather than the fling that provoked it. +const PREPEND_MIN_SHIFT = 600 +// The thread is watched for this long after the drag, in samples, rather than looked at once at the +// end: a jump back to the hit can be brief. +const SNAP_BACK_SAMPLES = 8 +const SNAP_BACK_SAMPLE_MS = 400 +// How far the hit row may travel back toward the viewport after the drag before that is the thread +// scrolling itself rather than settling. maintainVisibleContentPosition holds the visible content +// in place across the page-in, so a row that marches back by this much moved because something +// scrolled the list. +const SNAP_BACK_TOLERANCE = 150 + +// iOS puts the conversation's header actions in a native overflow menu, so there is no React view +// to carry a testID - the bar button and its menu item are addressed by the accessibility labels +// the platform exposes, scoped to the navigation bar and the menu so they cannot match the "More" +// tab or the inbox's own search field. Other platforms render the search control directly. +const openThreadSearch = async () => { + if (browser.isIOS) { + const moreButton = browser.$('-ios class chain:**/XCUIElementTypeNavigationBar/**/XCUIElementTypeButton[`name == "More"`]') + await moreButton.waitForExist({timeout: 5000, timeoutMsg: 'header overflow menu never appeared'}) + await moreButton.click() + const searchItem = browser.$( + '-ios predicate string:(type == "XCUIElementTypeMenuItem" OR type == "XCUIElementTypeButton") AND name == "Search"' + ) + await searchItem.waitForExist({timeout: 5000, timeoutMsg: 'search menu item never appeared'}) + await searchItem.click() + return + } + await waitForTestID(T.CHAT_HEADER_SEARCH_BUTTON, 5000) + await el(T.CHAT_HEADER_SEARCH_BUTTON).click() +} + +type Bounds = {height: number; width: number; x: number; y: number} + +const boundsOfElement = async (element: ChainablePromiseElement): Promise => { + const [location, size] = await Promise.all([element.getLocation(), element.getSize()]) + return {height: size.height, width: size.width, x: location.x, y: location.y} +} + +const boundsOf = async (id: string): Promise => boundsOfElement(el(id)) + +// A row outside the render window is not in the accessibility tree at all, and that is an answer +// ("not on screen") rather than an error. Anything else - a dead session, a driver fault - is not, +// so only a missing element is swallowed here. +const maybeBoundsOf = async (element: ChainablePromiseElement): Promise => { + if (!(await element.isExisting())) return undefined + try { + return await boundsOfElement(element) + } catch (error) { + if (/no such element|stale element|not found/i.test(String(error))) return undefined + throw error + } +} + +// What the reader can actually see of the thread: the list's frame less the search bar, which +// overlays the bottom of it rather than shrinking it. +const visibleThreadBounds = async (): Promise => { + const list = await boundsOf(T.CHAT_MESSAGE_LIST) + const bar = await maybeBoundsOf(el(T.CHAT_THREAD_SEARCH_CANCEL)) + if (!bar) return list + return {...list, height: Math.max(0, bar.y - list.y)} +} + +const visibleHeight = (thing: Bounds, viewport: Bounds): number => + Math.min(thing.y + thing.height, viewport.y + viewport.height) - Math.max(thing.y, viewport.y) + +// The row keeps its marker while it is the selected hit, but a virtualised list renders rows +// outside the viewport too - so the marker existing says nothing about whether it can be seen. +// Compare where the row is against where the thread is, and require more than a sliver. +const hitOnScreen = async (): Promise => { + const hit = await maybeBoundsOf(el(T.CHAT_SEARCH_HIT)) + if (!hit) return undefined + const viewport = await visibleThreadBounds() + return visibleHeight(hit, viewport) >= Math.min(hit.height, MIN_VISIBLE_HEIGHT) ? hit : undefined +} + +const describeHit = async (): Promise => { + const hit = await maybeBoundsOf(el(T.CHAT_SEARCH_HIT)) + const viewport = await visibleThreadBounds() + if (!hit) return `no highlighted row is rendered; thread ${viewport.y}..${viewport.y + viewport.height}` + return `row ${hit.y}..${hit.y + hit.height}, thread ${viewport.y}..${viewport.y + viewport.height}` +} + +// The header above the oldest loaded message. It stays mounted while off screen, so its position is +// readable throughout - and a page-in is visible in that position directly: a fling moves it toward +// the viewport, while a prepend pushes it a page's worth further away. +const topOfThreadPosition = async (): Promise => (await maybeBoundsOf(el(T.CHAT_THREAD_TOP)))?.y + +// "3 of 18" in the search bar. The count is what makes the wrap-around case honest: stepping a +// fixed number of times proves nothing if the thread happens to have more hits than that. Read +// through the bar's own testID — matching " of " across the screen finds message text first, and +// the thread behind the bar is full of it. +// +// undefined means the counter could not be read, which is a different thing from a search with no +// results, and the caller says so differently. +const readHitCount = async (): Promise => { + const wrapper = el(T.CHAT_THREAD_SEARCH_COUNT) + const inner = browser.isAndroid + ? wrapper.$('.//android.widget.TextView') + : wrapper.$('-ios class chain:**/XCUIElementTypeStaticText') + const label = (await inner.getText().catch(() => '')) || (await wrapper.getText().catch(() => '')) + if (/no results/i.test(label)) return 0 + const parsed = /of (\d+)/.exec(label) + if (!parsed) { + console.log(`readHitCount: could not read the counter, saw "${label}" at ${new Date().toISOString()}`) + return undefined + } + return Number(parsed[1]) +} + +// Results stream in, and the counter renders as soon as the first ones land - so a count read too +// early is a partial count, and stepping by it would quietly stop exercising the wrap-around. +// Settled means two reads in a row agree. +const readSettledHitCount = async (): Promise => { + let previous = await readHitCount() + for (let attempt = 0; attempt < 10; attempt++) { + await browser.pause(500) + const next = await readHitCount() + if (next !== undefined && next === previous) return next + previous = next + } + return previous +} + +const startSearch = async (query: string): Promise => { + await openThreadSearch() + await waitForTestID(T.CHAT_THREAD_SEARCH_INPUT, 5000) + // The field focuses itself a beat after mounting, so type into it rather than sending keys at + // whatever happens to be focused. enterText also pastes where per-key injection is unsafe. + await enterText(T.CHAT_THREAD_SEARCH_INPUT, query) + await browser.keys(['\n']) + + // Results stream in from the server; the first hit is selected once they arrive. + await waitForTestID(T.CHAT_SEARCH_HIT, 15000) + const landed = await browser + .waitUntil(async () => (await hitOnScreen()) !== undefined, {timeout: 5000}) + .then(() => true) + .catch(() => false) + if (!landed) throw new Error(`the first hit never came on screen: ${await describeHit()}`) + const hits = await readSettledHitCount() + if (hits === undefined) throw new Error(`could not read the hit counter while searching "${query}"`) + if (hits === 0) throw new Error(`"${query}" found no hits in this conversation`) + return hits +} + +// Walking the whole ring is what this used to do, and its cost grows with the conversation: once +// the thread has enough hits the walk runs past the suite's per-test budget, and the timeout is +// reported as "the hit drifted off screen" rather than as this having run out of time. The wrap is +// forced directly now, and the walk after it is bounded. +const MAX_HIT_STEPS = 12 + +const stepThroughHits = async (steps: number, control: string = T.CHAT_THREAD_SEARCH_PREV) => { + for (let step = 0; step < steps; step++) { + await el(control).click() + + // Give the jump, and the measurements that follow it, time to land. + const landed = await browser + .waitUntil(async () => (await hitOnScreen()) !== undefined, {timeout: 5000}) + .then(() => true) + .catch(() => false) + if (!landed) throw new Error(`hit ${step + 1} never came on screen: ${await describeHit()}`) + // A hit that lands and then drifts off screen is the other half of what this watches for. + await browser.pause(700) + if (!(await hitOnScreen())) { + throw new Error(`hit ${step + 1} landed and then drifted off screen: ${await describeHit()}`) + } + } +} + +const closeThreadSearch = async () => { + await el(T.CHAT_THREAD_SEARCH_CANCEL).click() + await browser.pause(500) +} + +// Gestures are measured from the thread itself: on a tablet the inbox sits beside it, so a fixed x +// would scroll the wrong list, and the search bar and keyboard cover part of the thread's frame. +const gesturePoints = async (distance: number) => { + const viewport = await visibleThreadBounds() + const x = Math.round(viewport.x + viewport.width / 2) + const middle = viewport.y + viewport.height / 2 + const half = Math.min(distance, viewport.height - 80) / 2 + return {from: Math.round(middle - half), to: Math.round(middle + half), x} +} + +// A fast flick that coasts - used only to travel back through the thread, never to establish the +// position an assertion depends on. +const flingThread = async () => { + const {from, to, x} = await gesturePoints(420) + await browser.action('pointer').move({x, y: from}).down().move({duration: 120, x, y: to}).up().perform() +} + +// A drag that ends where it is left rather than coasting. +const dragThread = async () => { + const {from, to, x} = await gesturePoints(200) + await browser + .action('pointer') + .move({x, y: from}) + .down() + .pause(100) + .move({duration: 400, x, y: to}) + .pause(100) + .up() + .perform() +} + +// Drag until the hit is off screen, which is the state the rest of the case depends on. Failing here +// is a real failure: with the thread re-centring itself on the hit, this is where that shows up. +const dragUntilHitLeaves = async () => { + for (let attempt = 0; attempt < MAX_DRAGS_AWAY; attempt++) { + await dragThread() + await browser.pause(400) + if (!(await hitOnScreen())) return + } + throw new Error( + `the hit never left the viewport after ${MAX_DRAGS_AWAY} drags: ${await describeHit()}` + ) +} + +// Each test starts from the tab root: the suite returns there between tests, so a flow cannot +// assume the conversation another one left open. The conversation needs enough history to page in +// and enough matches for both queries, which is the smoke account's own chat with itself. +// Named, not "whichever row is first". The inbox is ordered by recency and this suite sends +// messages of its own, so the first row is a different conversation from one run to the next — and +// then so are the hit count and which words match at all. That drift reads as this flow failing. +// +// The smoke user's own chat is the stable choice: it always exists, its name is the username the +// run was given, and it has the history these cases need. +const openSearchConversation = async (): Promise => { + const smokeUser = requireSmokeUser() + await escapeToTabs() + await tab('Teams').click() + await tab('Chat').click() + await waitForTestID(T.CHAT_INBOX_LIST, 5000) + + if (!(await anyExist(T.CHAT_INBOX_ROW))) return false + // Scoped to the inbox: the signed-in username is also on the header avatar above it, and tapping + // that opens the account switcher. + const row = byTextWithin(el(T.CHAT_INBOX_LIST), smokeUser) + if (!(await row.isExisting())) { + throw new Error(`no conversation named "${smokeUser}" in the inbox`) + } + await row.click() + await waitForTestID(T.CHAT_MESSAGE_LIST, 5000) + await dismissKeyboard() + return true +} + +describe('chat thread search', function () { + // Stated rather than inherited: a re-run cannot tell a slow sim from a thread that scrolled + // itself, so the failures this flow exists to catch are exactly the ones a retry would paper + // over. If the suite default ever goes back to retrying, this flow still must not. + this.retries(0) + + it('keeps every hit it lands on visible, including wrapping around', async () => { + requireSmokeUser() + if (!(await openSearchConversation())) throw new Error('no conversations in the inbox') + + const hits = await startSearch(QUERY) + // Next from the hit the search lands on wraps straight to the far end of the thread, which is + // the longest jump the list is ever asked to make - and the case that used to leave the hit off + // screen. Forced first rather than reached by walking the whole ring to it. + await stepThroughHits(1, T.CHAT_THREAD_SEARCH_NEXT) + const walk = Math.min(hits + 2, MAX_HIT_STEPS) + if (walk < hits + 2) { + // Said out loud: a bound nobody can see reads as "every hit was checked" when it was not. + console.log(`stepping ${walk} of ${hits + 2} hits — the rest costs more than the test budget`) + } + await stepThroughHits(walk) + await closeThreadSearch() + }) + + it('lands on a hit that is already on screen', async () => { + requireSmokeUser() + if (!(await openSearchConversation())) throw new Error('no conversations in the inbox') + + // No native mutation has been found that makes this case fail on its own - it is here because + // it is the case people report on desktop, where it does fail. Treat a green here as coverage + // of the flow, not proof of the fix. + await startSearch(SAME_SCREEN_QUERY) + await stepThroughHits(1) + await closeThreadSearch() + }) + + it('leaves the thread where the user drags it after a hit', async () => { + requireSmokeUser() + if (!(await openSearchConversation())) throw new Error('no conversations in the inbox') + await startSearch(SAME_SCREEN_QUERY) + + // A moment after landing is when the list is still measuring, and where anything holding the + // scroll target used to pull the thread back out from under the user. + await browser.pause(1000) + expect(await hitOnScreen()).toBeDefined() + + // The reader moves away from the hit. Dragged until the row is genuinely gone rather than a + // fixed number of times: how far one drag carries a hit depends on the row's height and the + // screen's, and a hit still clinging to the bottom edge is not the state this test is about. + await dragUntilHitLeaves() + + // ...and keeps reading back through older messages, which is what asks the thread for another + // page. The order matters: the prepend has to arrive *after* the reader has moved, because the + // reported failure is "search, wait, scroll, and it pops back". Every index shifts when the page + // lands, and an index-keyed re-centre reads that as a new target. + let pagedIn = false + let previousTop = await topOfThreadPosition() + for (let fling = 0; fling < MAX_FLINGS && !pagedIn; fling++) { + await flingThread() + await browser.pause(200) + // At no point during this should the thread take itself back to the hit. + const returned = await hitOnScreen() + if (returned) throw new Error(`the thread scrolled back to the hit while reading: ${await describeHit()}`) + + const top = await topOfThreadPosition() + // A fling moves the top of the thread toward the viewport; only a prepend moves it away, and + // by a page's worth rather than a gesture's. + if (top !== undefined && previousTop !== undefined && top < previousTop - PREPEND_MIN_SHIFT) { + console.log(`page-in: top of thread moved ${previousTop} -> ${top}`) + pagedIn = true + } + previousTop = top ?? previousTop + } + // Not provoking a page-in proves nothing about snapping back, so fail instead of passing. + if (!pagedIn) throw new Error('flinging never loaded another page of older messages') + + // Settle where the reader left it, and watch rather than look once: a re-centre lands whenever + // the page finishes measuring, and it does not necessarily stay. + await dragUntilHitLeaves() + // Undefined means the drag pushed the row clean out of the render window, which is the usual + // outcome — the drags keep going until it is off screen, and off screen far enough is unmounted. + // The samples below handle both cases rather than skipping the check in one of them. + const restingPosition = (await maybeBoundsOf(el(T.CHAT_SEARCH_HIT)))?.y + + let snappedBack: string | undefined + for (let sample = 0; sample < SNAP_BACK_SAMPLES && !snappedBack; sample++) { + await browser.pause(SNAP_BACK_SAMPLE_MS) + const visible = await hitOnScreen() + if (visible) { + snappedBack = `the hit is back on screen at ${visible.y}` + break + } + // Whether the hit is *visible* depends on the search bar and the keyboard; whether the thread + // travelled back toward it does not. maintainVisibleContentPosition holds the visible content + // in place across a page-in, so a row that marches back moved because something scrolled. + const position = (await maybeBoundsOf(el(T.CHAT_SEARCH_HIT)))?.y + if (restingPosition === undefined) { + // It was outside the render window when the reader stopped dragging. Coming back into it is + // itself the movement this is watching for: a prepend does not carry a row toward the + // viewport, so something scrolled to bring it back within drawDistance of it. + if (position !== undefined) { + snappedBack = `the hit came back into the render window at ${position} after being dragged out of it` + } + } else if (position !== undefined) { + const travelled = Math.abs(position - restingPosition) + if (travelled > SNAP_BACK_TOLERANCE) { + snappedBack = `the hit moved ${Math.round(travelled)} back toward the viewport (${restingPosition} -> ${position})` + } + } + } + if (snappedBack) console.log(`thread scrolled itself after the drag: ${snappedBack}`) + expect(snappedBack).toBeUndefined() + + await closeThreadSearch() + }) +}) diff --git a/shared/tests/e2e/ios-appium/flows/people-profile.test.ts b/shared/tests/e2e/ios-appium/flows/people-profile.test.ts index 59d0b9d1efc1..dc317b4a2673 100644 --- a/shared/tests/e2e/ios-appium/flows/people-profile.test.ts +++ b/shared/tests/e2e/ios-appium/flows/people-profile.test.ts @@ -1,7 +1,7 @@ import {expect} from '@wdio/globals' import {requireSmokeUser} from '../helpers/app' import {escapeToTabs, navigateToPeople} from '../helpers/navigate' -import {byText, el, waitForTestID} from '../helpers/elements' +import {byTextWithin, el, waitForTestID} from '../helpers/elements' import * as T from '../../shared/test-ids' describe('people profile', () => { @@ -13,8 +13,26 @@ describe('people profile', () => { // Your own username appearing in your own feed is genuinely conditional // (the feed surfaces others' activity), so guard rather than hard-wait. - const userEl = byText(smokeUser) - if (!(await userEl.isExisting())) return + // + // Scoped to the feed, not matched across the screen: the People header's avatar carries the + // username too, and tapping that opens the account switcher rather than a profile - which then + // fails here on a missing profile page, and leaves a modal up for whatever runs next. + // The feed container mounts empty and immediately, so waiting on it says nothing about whether + // the feed has arrived. Wait for the row itself instead - a bounded wait rather than the retries + // this flow used to carry, which re-ran the whole test to buy the same time. + // + // Rebuilt on every poll: a scoped element caches its parent's id, so a feed that re-renders + // makes the scoped lookup throw stale forever, and the swallowed error reads as "not there". + const findUser = () => byTextWithin(el(T.PEOPLE_FEED), smokeUser) + const present = await browser + .waitUntil(async () => findUser().isExisting().catch(() => false), {interval: 250, timeout: 10000}) + .then(() => true) + .catch(() => false) + if (!present) { + console.log(`people profile: ${smokeUser} is not in its own feed, skipping the profile open`) + return + } + const userEl = findUser() await userEl.click() await waitForTestID(T.PROFILE_PAGE, 10000) await expect(el(T.PROFILE_PAGE)).toExist() diff --git a/shared/tests/e2e/ios-appium/helpers/elements.ts b/shared/tests/e2e/ios-appium/helpers/elements.ts index 2dff41c035b0..39f3552e82de 100644 --- a/shared/tests/e2e/ios-appium/helpers/elements.ts +++ b/shared/tests/e2e/ios-appium/helpers/elements.ts @@ -146,6 +146,13 @@ export const anyExist = async (id: string, timeout = 3000): Promise => // Backslashes and double quotes would otherwise terminate/alter the quoted // predicate literal and make the selector invalid. +// An XPath string literal for arbitrary text. XPath 1.0 cannot escape a quote inside a literal, so +// text containing one has to be assembled with concat(). +const xpathLiteral = (s: string): string => { + if (!s.includes('"')) return `"${s}"` + return `concat(${s.split('"').map(part => `"${part}"`).join(`, '"', `)})` +} + const escapePredicate = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') // CONTAINS, not ==, on purpose: many tappable rows (More menu items, team tabs) @@ -164,6 +171,22 @@ export const byText = (text: string): ChainablePromiseElement => { return browser.$(`-ios predicate string:label CONTAINS "${t}" OR name CONTAINS "${t}"`) } +// byText, scoped to a subtree, with the same platform split. Use it when the text you mean also +// appears in the chrome around the content: the People header's avatar carries the signed-in +// username, and tapping that opens the account switcher rather than a profile. +export const byTextWithin = (root: ChainablePromiseElement, text: string): ChainablePromiseElement => { + if (browser.isAndroid) { + // XPath 1.0 has no escape for a quote, so a literal containing one has to be built with + // concat(). escapePredicate is for ObjC predicates and would emit a backslash XPath cannot read. + const literal = xpathLiteral(text) + return root.$( + `descendant-or-self::*[contains(@text, ${literal}) or contains(@content-desc, ${literal})]` + ) + } + const t = escapePredicate(text) + return root.$(`-ios predicate string:label CONTAINS "${t}" OR name CONTAINS "${t}"`) +} + // Tab-bar buttons. iOS exposes the native UITabBarItem by its title as the // accessibility id. Android's tab bar is a native Material BottomNavigationView: // target the ITEM view via its content-desc — the label, plus an optional badge diff --git a/shared/tests/e2e/ios-appium/helpers/navigate.ts b/shared/tests/e2e/ios-appium/helpers/navigate.ts index 2733c1b725e2..f1a267c7e368 100644 --- a/shared/tests/e2e/ios-appium/helpers/navigate.ts +++ b/shared/tests/e2e/ios-appium/helpers/navigate.ts @@ -85,10 +85,88 @@ async function atTabs(): Promise { export async function dismissKeyboard(): Promise { // isKeyboardShown is a direct Appium endpoint (fast) — avoid an // //XCUIElementTypeKeyboard xpath, which is a slow full-tree search per call. - if (await browser.isKeyboardShown().catch(() => false)) { - if (browser.isAndroid) await browser.hideKeyboard().catch(() => {}) - else await browser.execute('mobile: hideKeyboard').catch(() => {}) + if (!(await browser.isKeyboardShown().catch(() => false))) return + if (browser.isAndroid) { + await browser.hideKeyboard().catch(() => {}) + return + } + await browser.execute('mobile: hideKeyboard').catch(() => {}) + if (!(await browser.isKeyboardShown().catch(() => false))) return + + // Naming the keys to press gets WDA past "Did not know how to dismiss the keyboard" on the + // screens whose keyboard carries one of them (search fields show Search, the team wizard's + // shows Done). + await browser.execute('mobile: hideKeyboard', {keys: ['Done', 'Search', 'Go', 'Return']}).catch(() => {}) + if (!(await browser.isKeyboardShown().catch(() => false))) return + + // WDA answers "Did not know how to dismiss the keyboard" for the chat composer — it has no Done + // key and no accessory to press. This is not cosmetic: while the keyboard is up the screen's own + // controls stop reporting as hittable, so the back chevron is invisible to tapNavBack and the + // left-edge pop does not take, and escapeToTabs burns its whole budget on a conversation it + // cannot leave. Every flow after it then starts from the wrong screen. + // + // Dismiss it with a drag rather than a tap. The chat list sets keyboardDismissMode="on-drag", so + // a drag is the gesture it listens for; it also sets keyboardShouldPersistTaps="handled", which + // means a tap landing on a row is handled BY that row and does not dismiss the keyboard — while + // still doing whatever the row does (opening an attachment, following a link). A drag cannot + // activate a touchable, so it has no such side effect on any screen this runs from. + const {height, width} = await browser.getWindowRect() + const x = Math.round(width / 2) + const keyboardGone = async () => !(await browser.isKeyboardShown().catch(() => false)) + // Start well above the keyboard: on an iPad in landscape its top edge sits around 55% of the + // screen, and an accessory or autocorrect bar raises it further. + await browser + .action('pointer') + .move({x, y: Math.round(height * 0.35)}) + .down() + .pause(60) + .move({duration: 250, x, y: Math.round(height * 0.15)}) + .up() + .perform() + .catch(() => {}) + if ( + await browser + .waitUntil(keyboardGone, {interval: 100, timeout: 2000}) + .then(() => true) + .catch(() => false) + ) { + return } + + // The drag is what the CHAT list listens for - it is the one screen that sets + // keyboardDismissMode="on-drag", and the one where a tap is unsafe because + // keyboardShouldPersistTaps="handled" lets a row swallow it and act on it. Everywhere else + // (feedback, crypto) there is no dismiss-on-drag and the default persist-taps is "never", so a + // tap is both safe and the only thing that works. Try it when this is not the chat list. + const onChatList = await el(T.CHAT_MESSAGE_LIST) + .isExisting() + .catch(() => false) + if (!onChatList) { + // 30%, the same place the tap used to land before this became a drag: it is above the keyboard + // on every screen this runs from and below their headers and inputs. + await browser + .action('pointer') + .move({x, y: Math.round(height * 0.3)}) + .down() + .pause(60) + .up() + .perform() + .catch(() => {}) + if ( + await browser + .waitUntil(keyboardGone, {interval: 100, timeout: 2000}) + .then(() => true) + .catch(() => false) + ) { + return + } + } + + // Say so rather than leaving escapeToTabs to spend its whole budget on a screen whose controls + // are not hittable - a 50s stall with nothing in the log to explain it. + console.log( + `dismissKeyboard: keyboard still up after drag${onChatList ? '' : ' and tap'} at ${new Date().toISOString()}` + ) } // Tap the leading (leftmost) button of a native NavigationBar — the back @@ -130,6 +208,16 @@ async function tapNavBack(requireLeftEdge = false): Promise { // element type — cheaper than three separate searches. // visible == 1: hidden nav-stack screens and keyboard toolbars can carry their // own Done/Close/Cancel — clicking one is a silent no-op that loops forever. +// The pre-loop's own predicate: an EXACT name, unlike DISMISS_PRED's substring match. This one +// clicks unattended before every test, so it must never match a control that merely contains the +// word — a "Close team" or "Cancel invite" shipped later would otherwise become a destructive click +// in the reset. Buttons and menu items only: a StaticText matching by name would also match a chat +// message whose whole body is "Cancel", and the reset runs with the suite parked in a thread. The +// one StaticText that did need dismissing — the thread search bar's Cancel, a Kb.Text with an +// onClick — is closed by its own testID above instead. +const MODAL_DISMISS_PRED = + '-ios predicate string:(type == "XCUIElementTypeButton" OR type == "XCUIElementTypeMenuItem") AND (name == "Done" OR name == "Close" OR name == "Cancel" OR label == "Done" OR label == "Close" OR label == "Cancel") AND visible == 1' + const DISMISS_PRED = '-ios predicate string:(label CONTAINS "Done" OR name CONTAINS "Done" OR label CONTAINS "Close" OR name CONTAINS "Close" OR label CONTAINS "Cancel" OR name CONTAINS "Cancel") AND visible == 1' @@ -177,6 +265,43 @@ export async function escapeToTabs(): Promise { } throw new Error('escapeToTabs(android): root tab bar not reached after 12 attempts') } + // Dismiss anything presented over the tabs BEFORE asking whether we are at the root. A modal + // leaves the tab bar — and the whole screen behind it — in the accessibility tree, so atTabs reads + // "already home" while a New chat or account-switcher modal is still up; the reset then returns + // with it still there, the next flow taps rows belonging to the modal, and every test after it + // fails somewhere unrelated. It outlives the run too: the app restores its last screen, so a + // leaked modal wedges the NEXT run from its first test. Bounded, and only ever clicks a control + // that is on screen — at a real root there is nothing to click and this costs one query. + // The thread search bar first, by its own testID. It is a Kb.Text with an onClick rather than a + // button, so nothing in MODAL_DISMISS_PRED reaches it, and on iPad it is the only thing the reset + // has to close — atTabs is already true inside the Chat tab, so the loop below never runs. + const searchCancel = els(T.CHAT_THREAD_SEARCH_CANCEL) + if ((await searchCancel.length) > 0) { + const ctrl = searchCancel[0]! + await ctrl.click().catch(() => {}) + await settleAfter(ctrl) + } + for (let i = 0; i < 3; i++) { + const controls = await browser.$$(MODAL_DISMISS_PRED).getElements() + if (controls.length === 0) break + // The LAST match, not the first: a modal that only partly covers the screen leaves the + // background's controls in the tree, and those come first — its view controller is appended + // after. Clicking the first can click straight through the sheet. + const ctrl = controls[controls.length - 1]! + await ctrl.click().catch(() => {}) + // Waiting on atTabs here would be circular: that is the predicate this loop exists because it + // lies while a modal is up. Wait for THIS control to go - isDisplayed goes through the element + // id, where isExisting re-runs the selector and would answer "still there" for any other + // Done/Close/Cancel on screen (the layer behind, a second sheet, the keyboard's own toolbar). + // A stale element throws, which is the clearest "it is gone" there is. + const gone = await browser + .waitUntil(async () => !(await ctrl.isDisplayed().catch(() => false)), {interval: 100, timeout: 3000}) + .then(() => true) + .catch(() => false) + // A control that survives its own click is not a modal dismiss — leave it to the loop below + // rather than clicking it forever. + if (!gone) break + } for (let i = 0; i < 10; i++) { if (await atTabs()) return // Past the first few hops something is off — narrate each step so a stall @@ -187,7 +312,6 @@ export async function escapeToTabs(): Promise { // so its Done/Close/Cancel must win. if ((await browser.$$(DISMISS_PRED).length) > 0) { const ctrl = browser.$$(DISMISS_PRED)[0]! - // eslint-disable-next-line no-console if (debug) console.log(` escapeToTabs[${i}]: dismissing "${await ctrl.getAttribute('label').catch(() => '?')}"`) await ctrl.click().catch(() => {}) await settleAfter(ctrl) @@ -195,10 +319,8 @@ export async function escapeToTabs(): Promise { // whose click no-ops (still present, still not at tabs) must fall through // to the back/pop path or we'd click it forever. if ((await atTabs()) || !(await ctrl.isExisting().catch(() => false))) continue - // eslint-disable-next-line no-console if (debug) console.log(` escapeToTabs[${i}]: dismiss no-oped, falling through to back/pop`) } else if (debug) { - // eslint-disable-next-line no-console console.log(` escapeToTabs[${i}]: no dismiss control, trying back/pop`) } if ((await els(T.COMMON_BACK_BUTTON).length) > 0) { diff --git a/shared/tests/e2e/ios-appium/wdio.android.conf.ts b/shared/tests/e2e/ios-appium/wdio.android.conf.ts index ae9980a1862e..c4ea79562031 100644 --- a/shared/tests/e2e/ios-appium/wdio.android.conf.ts +++ b/shared/tests/e2e/ios-appium/wdio.android.conf.ts @@ -27,10 +27,10 @@ export const config: WebdriverIO.Config = { capabilities: [androidCapabilities(serial)], logLevel: 'warn', framework: 'mocha', - // Mirrors the iOS config: the one-session suite accumulates load over many - // flows; retries: 2 (each with a fresh escapeToTabs reset) absorbs transient - // nav/list flake without masking real failures. Retries run ONLY on failure. - mochaOpts: {ui: 'bdd', timeout: 120000, retries: 2}, + // retries: 0, matching the iOS config - see wdio.conf.ts for why. A flow that + // genuinely needs a retry asks for one in its own describe, where the reason is + // visible to whoever reads the failure. + mochaOpts: {ui: 'bdd', timeout: 120000, retries: 0}, reporters: ['spec'], // relaxedSecurity lets Appium run privileged commands such as `mobile: shell`. // android-activity-restart.test.ts needs it (pidof) to prove the app process diff --git a/shared/tests/e2e/ios-appium/wdio.conf.ts b/shared/tests/e2e/ios-appium/wdio.conf.ts index f2b3b85b48a1..ecac835b2606 100644 --- a/shared/tests/e2e/ios-appium/wdio.conf.ts +++ b/shared/tests/e2e/ios-appium/wdio.conf.ts @@ -46,13 +46,15 @@ export const config: WebdriverIO.Config = { logLevel: 'warn', framework: 'mocha', // 120s: the tablet settings-subpages flow can run long; phone tests finish well - // under this. retries: 2 — the one-session suite accumulates load over 16 flows - // (KBFS/list loads, transient nav), and the old iOS-16.4 sims are slower/flakier - // still (paste-menu summon, list timing), so a flow can intermittently fail; up - // to two retries (each with a fresh escapeToTabs reset) absorbs that without - // masking real failures (a real break fails all attempts). Retries run ONLY on - // failure, so passing tests cost nothing. - mochaOpts: {ui: 'bdd', timeout: 120000, retries: 2}, + // under this. + // + // retries: 0. The claim this used to carry — "a real break fails all attempts" — + // is not true of every flow: a chat-search regression that failed three runs in + // four was reported green here, because a retry cannot tell a slow simulator + // from a thread that scrolled itself. Retries are opt-in per flow now: a flow + // that is genuinely load-sensitive calls this.retries(n) in its describe and + // says why, which keeps the cost visible where someone can judge it. + mochaOpts: {ui: 'bdd', timeout: 120000, retries: 0}, reporters: ['spec'], services: [['appium', {args: {basePath: '/', port}}]], // Set device orientation once at session start (e.g. iPad in landscape). diff --git a/shared/tests/e2e/shared/test-ids.ts b/shared/tests/e2e/shared/test-ids.ts index bafe3dbeb78f..eb1003bf10e6 100644 --- a/shared/tests/e2e/shared/test-ids.ts +++ b/shared/tests/e2e/shared/test-ids.ts @@ -14,6 +14,7 @@ export const NAV_TAB_SETTINGS = 'nav-tab-settings' // Chat export const CHAT_INBOX_LIST = 'chat-inbox-list' export const CHAT_INBOX_ROW = 'chat-inbox-row' +export const CHAT_INBOX_CHANNEL_ROW = 'chat-inbox-channel-row' export const CHAT_MESSAGE_LIST = 'chat-message-list' export const CHAT_INPUT = 'chat-input' export const CHAT_SEND_BUTTON = 'chat-send-button' @@ -31,6 +32,27 @@ export const CHAT_INFO_PANEL_SETTINGS_TAB = 'chat-info-panel-settings-tab' // Android only: iOS 26 folds Search/Info into one native "More" header menu, // but the Android header keeps the plain info icon — icons have no tappable text export const CHAT_HEADER_INFO_BUTTON = 'chat-header-info-button' +export const CHAT_HEADER_SEARCH_BUTTON = 'chat-header-search-button' +export const CHAT_THREAD_SEARCH_CANCEL = 'chat-thread-search-cancel' +// The thread search query field. It focuses itself a beat after mounting, so a test types into it +// rather than sending keys and hoping the focus landed. +export const CHAT_THREAD_SEARCH_INPUT = 'chat-thread-search-input' +// "3 of 18" in the thread search bar. Read through this rather than matching " of " on screen: the +// thread behind the bar is full of message text and a body containing " of " matches first. +export const CHAT_THREAD_SEARCH_COUNT = 'chat-thread-search-count' +export const CHAT_THREAD_SEARCH_PREV = 'chat-thread-search-prev' +export const CHAT_THREAD_SEARCH_NEXT = 'chat-thread-search-next' +// The row a thread search is currently sitting on. Really "the centre-highlighted row": pinned +// messages, reply jumps and permalinks highlight one too, so this only means "search hit" inside a +// search flow. Present only while the row is highlighted. +export const CHAT_SEARCH_HIT = 'chat-search-hit' +// The per-message "..." actions button (desktop only, revealed on hover). Icon-only, so there is +// no text to match on, and it is the only way into "Copy a link to this message". +export const CHAT_MESSAGE_MENU_BUTTON = 'chat-message-menu-button' +// The header above the oldest loaded message. Mounted in every state - loading, more to load, start +// of the conversation - so its position is readable throughout, which is how a test sees a page of +// older messages arrive. +export const CHAT_THREAD_TOP = 'chat-thread-top' // Files export const FILES_BROWSER = 'files-browser'