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 0ec316422864..7c066639942f 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,27 @@ 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 = {} + +// Stable MVCP config (anchor visible rows across data prepends). Referenced by native; desktop +// inlines an equivalent. +const mvcpData = {data: true} as const + const keyExtractor = (ordinal: ItemType) => String(ordinal) // Item type for list recycling pool separation. A message that leads its author group renders an @@ -138,8 +144,6 @@ const usePagination = (p: { return {onEndReached, onStartReached} } -const centerTolerancePx = 8 - // When a centeredOrdinal is set at mount, start there; otherwise start at the end (newest). const useInitialScrollIndex = ( messageOrdinals: ReadonlyArray, @@ -210,6 +214,44 @@ 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]) + + 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) @@ -219,12 +261,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. dataKey is the - // library's answer to that, but it cannot be used here: the fresh-data reset drops - // readyToRender and restores it inside one layout-effect pass, so the containers never render - // in the not-ready state that useFreshDataTransitionVisibility waits for before clearing its - // pending flag. The wrapper then stays at opacity 0 with the thread fully measured behind it. - // Remounting sidesteps it because a fresh mount seeds that flag from the current epoch. + // 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) @@ -303,111 +342,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) @@ -511,12 +446,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 (
} data={messageOrdinals as unknown as T.Chat.Ordinal[]} renderItem={renderItem} @@ -543,10 +471,13 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { style={Kb.Styles.castStyleDesktop(desktopStyles.list)} initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex} + // headerLayout because SpecialTopMessage measures larger than its estimate a frame after + // the list has already scrolled to the end, which pushes every message down by the + // difference and leaves the newest one off screen. maintainScrollAtEnd={ centeredOrdinal !== undefined ? false - : {on: {dataChange: true, footerLayout: true, itemLayout: true}} + : {on: {dataChange: true, footerLayout: true, headerLayout: true, itemLayout: true}} } // Stays on while centered: the full thread response lands after the cached one and // re-measures rows above the target, which slides it out of view unless anchored. @@ -594,174 +525,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 @@ -777,119 +610,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 @@ -910,99 +642,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 && ( @@ -1027,42 +728,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..a339e2e9108f 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 @@ -1018,7 +1019,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/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 ( - + 5) { -+ if (prevSizeKnown === void 0 || Math.abs(prevSizeKnown - size) > 5) { - shouldMaintainScrollAtEnd = true; - } - onItemSizeChanged == null ? void 0 : onItemSizeChanged({ -@@ -5683,13 +5683,11 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { - return /* @__PURE__ */ React2__namespace.createElement(ContainerSlotBase, { ...props }); - }); - function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { -- const completedTransitionEpoch = React2.useRef(transitionEpoch); -- const isTransitionPending = completedTransitionEpoch.current !== transitionEpoch; -+ const [completedTransitionEpoch, setCompletedTransitionEpoch] = React2.useState(transitionEpoch); -+ const isTransitionPending = completedTransitionEpoch !== transitionEpoch; - React2.useLayoutEffect(() => { -- if (!readyToRender) { -- completedTransitionEpoch.current = transitionEpoch; -- } -- }, [readyToRender, transitionEpoch]); -+ setCompletedTransitionEpoch(transitionEpoch); -+ }, [transitionEpoch]); - return readyToRender && !isTransitionPending; - } - -@@ -6402,7 +6400,8 @@ var ScheduledWork = class { - const work = this.work.get(key); - if (work) { - this.work.delete(key); -- work[1](work[0]); -+ const [handle, cancelWork] = work; -+ cancelWork(handle); - } - } - has(key) { -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index 5315f44..5776876 100644 ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -4812,7 +4812,7 @@ function applyItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem) { - if (!needsRecalculate && state.containerItemKeys.has(itemKey)) { - needsRecalculate = true; - } -- if (prevSizeKnown !== void 0 && Math.abs(prevSizeKnown - size) > 5) { -+ if (prevSizeKnown === void 0 || Math.abs(prevSizeKnown - size) > 5) { - shouldMaintainScrollAtEnd = true; - } - onItemSizeChanged == null ? void 0 : onItemSizeChanged({ -@@ -5662,13 +5662,11 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { - return /* @__PURE__ */ React2.createElement(ContainerSlotBase, { ...props }); - }); - function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { -- const completedTransitionEpoch = useRef(transitionEpoch); -- const isTransitionPending = completedTransitionEpoch.current !== transitionEpoch; -+ const [completedTransitionEpoch, setCompletedTransitionEpoch] = useState(transitionEpoch); -+ const isTransitionPending = completedTransitionEpoch !== transitionEpoch; - useLayoutEffect(() => { -- if (!readyToRender) { -- completedTransitionEpoch.current = transitionEpoch; -- } -- }, [readyToRender, transitionEpoch]); -+ setCompletedTransitionEpoch(transitionEpoch); -+ }, [transitionEpoch]); - return readyToRender && !isTransitionPending; - } - -@@ -6381,7 +6379,8 @@ var ScheduledWork = class { - const work = this.work.get(key); - if (work) { - this.work.delete(key); -- work[1](work[0]); -+ const [handle, cancelWork] = work; -+ cancelWork(handle); - } - } - has(key) { -diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index ea03488..e70e58c 100644 ---- a/node_modules/@legendapp/list/react-native.web.js -+++ b/node_modules/@legendapp/list/react-native.web.js -@@ -4850,7 +4850,7 @@ function applyItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem) { - if (!needsRecalculate && state.containerItemKeys.has(itemKey)) { - needsRecalculate = true; - } -- if (prevSizeKnown !== void 0 && Math.abs(prevSizeKnown - size) > 5) { -+ if (prevSizeKnown === void 0 || Math.abs(prevSizeKnown - size) > 5) { - shouldMaintainScrollAtEnd = true; - } - onItemSizeChanged == null ? void 0 : onItemSizeChanged({ -@@ -5750,13 +5750,11 @@ function useDOMOrder(ref) { - }, [ctx]); - } - function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { -- const completedTransitionEpoch = React3.useRef(transitionEpoch); -- const isTransitionPending = completedTransitionEpoch.current !== transitionEpoch; -+ const [completedTransitionEpoch, setCompletedTransitionEpoch] = React3.useState(transitionEpoch); -+ const isTransitionPending = completedTransitionEpoch !== transitionEpoch; - React3.useLayoutEffect(() => { -- if (!readyToRender) { -- completedTransitionEpoch.current = transitionEpoch; -- } -- }, [readyToRender, transitionEpoch]); -+ setCompletedTransitionEpoch(transitionEpoch); -+ }, [transitionEpoch]); - return readyToRender && !isTransitionPending; - } - -@@ -7054,7 +7052,8 @@ var ScheduledWork = class { - const work = this.work.get(key); - if (work) { - this.work.delete(key); -- work[1](work[0]); -+ const [handle, cancelWork] = work; -+ cancelWork(handle); - } - } - has(key) { -diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index c96345b..e93d72d 100644 ---- a/node_modules/@legendapp/list/react-native.web.mjs -+++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -4829,7 +4829,7 @@ function applyItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem) { - if (!needsRecalculate && state.containerItemKeys.has(itemKey)) { - needsRecalculate = true; - } -- if (prevSizeKnown !== void 0 && Math.abs(prevSizeKnown - size) > 5) { -+ if (prevSizeKnown === void 0 || Math.abs(prevSizeKnown - size) > 5) { - shouldMaintainScrollAtEnd = true; - } - onItemSizeChanged == null ? void 0 : onItemSizeChanged({ -@@ -5729,13 +5729,11 @@ function useDOMOrder(ref) { - }, [ctx]); - } - function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { -- const completedTransitionEpoch = useRef(transitionEpoch); -- const isTransitionPending = completedTransitionEpoch.current !== transitionEpoch; -+ const [completedTransitionEpoch, setCompletedTransitionEpoch] = useState(transitionEpoch); -+ const isTransitionPending = completedTransitionEpoch !== transitionEpoch; - useLayoutEffect(() => { -- if (!readyToRender) { -- completedTransitionEpoch.current = transitionEpoch; -- } -- }, [readyToRender, transitionEpoch]); -+ setCompletedTransitionEpoch(transitionEpoch); -+ }, [transitionEpoch]); - return readyToRender && !isTransitionPending; - } - -@@ -7033,7 +7031,8 @@ var ScheduledWork = class { - const work = this.work.get(key); - if (work) { - this.work.delete(key); -- work[1](work[0]); -+ const [handle, cancelWork] = work; -+ cancelWork(handle); - } - } - has(key) { -diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index ea03488..e70e58c 100644 ---- a/node_modules/@legendapp/list/react.js -+++ b/node_modules/@legendapp/list/react.js -@@ -4850,7 +4850,7 @@ function applyItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem) { - if (!needsRecalculate && state.containerItemKeys.has(itemKey)) { - needsRecalculate = true; - } -- if (prevSizeKnown !== void 0 && Math.abs(prevSizeKnown - size) > 5) { -+ if (prevSizeKnown === void 0 || Math.abs(prevSizeKnown - size) > 5) { - shouldMaintainScrollAtEnd = true; - } - onItemSizeChanged == null ? void 0 : onItemSizeChanged({ -@@ -5750,13 +5750,11 @@ function useDOMOrder(ref) { - }, [ctx]); - } - function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { -- const completedTransitionEpoch = React3.useRef(transitionEpoch); -- const isTransitionPending = completedTransitionEpoch.current !== transitionEpoch; -+ const [completedTransitionEpoch, setCompletedTransitionEpoch] = React3.useState(transitionEpoch); -+ const isTransitionPending = completedTransitionEpoch !== transitionEpoch; - React3.useLayoutEffect(() => { -- if (!readyToRender) { -- completedTransitionEpoch.current = transitionEpoch; -- } -- }, [readyToRender, transitionEpoch]); -+ setCompletedTransitionEpoch(transitionEpoch); -+ }, [transitionEpoch]); - return readyToRender && !isTransitionPending; - } - -@@ -7054,7 +7052,8 @@ var ScheduledWork = class { - const work = this.work.get(key); - if (work) { - this.work.delete(key); -- work[1](work[0]); -+ const [handle, cancelWork] = work; -+ cancelWork(handle); - } - } - has(key) { -diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index c96345b..e93d72d 100644 ---- a/node_modules/@legendapp/list/react.mjs -+++ b/node_modules/@legendapp/list/react.mjs -@@ -4829,7 +4829,7 @@ function applyItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem) { - if (!needsRecalculate && state.containerItemKeys.has(itemKey)) { - needsRecalculate = true; - } -- if (prevSizeKnown !== void 0 && Math.abs(prevSizeKnown - size) > 5) { -+ if (prevSizeKnown === void 0 || Math.abs(prevSizeKnown - size) > 5) { - shouldMaintainScrollAtEnd = true; - } - onItemSizeChanged == null ? void 0 : onItemSizeChanged({ -@@ -5729,13 +5729,11 @@ function useDOMOrder(ref) { - }, [ctx]); - } - function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { -- const completedTransitionEpoch = useRef(transitionEpoch); -- const isTransitionPending = completedTransitionEpoch.current !== transitionEpoch; -+ const [completedTransitionEpoch, setCompletedTransitionEpoch] = useState(transitionEpoch); -+ const isTransitionPending = completedTransitionEpoch !== transitionEpoch; - useLayoutEffect(() => { -- if (!readyToRender) { -- completedTransitionEpoch.current = transitionEpoch; -- } -- }, [readyToRender, transitionEpoch]); -+ setCompletedTransitionEpoch(transitionEpoch); -+ }, [transitionEpoch]); - return readyToRender && !isTransitionPending; - } - -@@ -7033,7 +7031,8 @@ var ScheduledWork = class { - const work = this.work.get(key); - if (work) { - this.work.delete(key); -- work[1](work[0]); -+ const [handle, cancelWork] = work; -+ cancelWork(handle); - } - } - has(key) { diff --git a/shared/patches/@legendapp+list+3.3.5.patch b/shared/patches/@legendapp+list+3.3.5.patch new file mode 100644 index 000000000000..3c66b008d707 --- /dev/null +++ b/shared/patches/@legendapp+list+3.3.5.patch @@ -0,0 +1,10034 @@ +diff --git a/node_modules/@legendapp/list/.DS_Store b/node_modules/@legendapp/list/.DS_Store +deleted file mode 100644 +index b86e710..0000000 +Binary files a/node_modules/@legendapp/list/.DS_Store and /dev/null differ +diff --git a/node_modules/@legendapp/list/animated.d.ts b/node_modules/@legendapp/list/animated.d.ts +index 14beb98..56eefce 100644 +--- a/node_modules/@legendapp/list/animated.d.ts ++++ b/node_modules/@legendapp/list/animated.d.ts +@@ -488,6 +488,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.d.ts b/node_modules/@legendapp/list/react-native.d.ts +index ce1fe00..9a4311c 100644 +--- a/node_modules/@legendapp/list/react-native.d.ts ++++ b/node_modules/@legendapp/list/react-native.d.ts +@@ -488,6 +488,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.js b/node_modules/@legendapp/list/react-native.js +index b3c5a30..9d7ad6d 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -457,611 +457,6 @@ 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(); +-} +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- 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 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 +- ) +- ); +- } +-} +- +-// 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 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 +- }); +- } +- (_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; +- } +- 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; +- } +- 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); +- } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); +- } +- 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/utils/checkThreshold.ts +-var HYSTERESIS_MULTIPLIER = 1.3; +-function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { +- const absDistance = Math.abs(distance); +- return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; +-} +-var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { +- const absDistance = Math.abs(distance); +- const within = atThreshold || threshold > 0 && absDistance <= threshold; +- const updateSnapshot = () => { +- setSnapshot({ +- atThreshold, +- contentSize: context.contentSize, +- dataLength: context.dataLength, +- scrollPosition: context.scrollPosition +- }); +- }; +- if (!wasReached) { +- if (!within) { +- return false; +- } +- onReached(distance); +- updateSnapshot(); +- return true; +- } +- const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); +- if (reset) { +- setSnapshot(void 0); +- return false; +- } +- if (within) { +- const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; +- if (changed) { +- updateSnapshot(); +- } +- } +- return true; +-}; +- +-// src/utils/edgeReachedGate.ts +-function resetEdgeLatch(ctx, edge) { +- const state = ctx.state; +- if (edge === "start") { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } else { +- state.isEndReached = false; +- state.endReachedSnapshot = void 0; +- } +-} +-function resetSharedEdgeGateIfOutsideHysteresis(ctx) { +- const state = ctx.state; +- if (!state.edgeReachedGate) { +- return; +- } +- const contentSize = getContentSize(ctx); +- const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); +- const isContentLess = contentSize < state.scrollLength; +- const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; +- const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; +- const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); +- const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); +- if (isOutsideStart && isOutsideEnd) { +- state.edgeReachedGate = void 0; +- } +-} +-function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { +- return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; +-} +-function markReachedEdge(ctx) { +- ctx.state.edgeReachedGate = "closed"; +-} +-function prepareReachedEdgeForNextUserScroll(ctx) { +- if (ctx.state.edgeReachedGate) { +- ctx.state.edgeReachedGate = "prepared"; +- } +-} +-function beginReachedEdgeUserScroll(ctx, scrollDelta) { +- const state = ctx.state; +- if (state.edgeReachedGate !== "prepared") { +- return void 0; +- } +- const allowedEdge = scrollDelta < 0 ? "start" : "end"; +- state.edgeReachedGate = "closed"; +- resetEdgeLatch(ctx, allowedEdge); +- return allowedEdge; +-} +- +-// src/utils/hasActiveInitialScroll.ts +-function hasActiveInitialScroll(state) { +- return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; +-} +- +-// src/utils/checkAtBottom.ts +-function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- var _a3; +- const state = ctx.state; +- if (!state) { +- return; +- } +- const { +- queuedInitialLayout, +- scrollLength, +- scroll, +- maintainingScrollAtEnd, +- props: { maintainScrollAtEndThreshold, onEndReachedThreshold } +- } = state; +- const contentSize = getContentSize(ctx); +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (contentSize > 0 && queuedInitialLayout) { +- const insetEnd = getContentInsetEnd(ctx); +- const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; +- const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); +- set$( +- ctx, +- "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength +- ); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; +- if (!shouldSkipThresholdChecks) { +- state.isEndReached = checkThreshold( +- distanceFromEnd, +- isContentLess, +- onEndReachedThreshold * scrollLength, +- state.isEndReached, +- state.endReachedSnapshot, +- { +- contentSize, +- dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a4, _b; +- if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); +- } +- }, +- (snapshot) => { +- state.endReachedSnapshot = snapshot; +- } +- ); +- } +- } +-} +- +-// src/utils/checkAtTop.ts +-function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- const state = ctx == null ? void 0 : ctx.state; +- if (!state) { +- return; +- } +- const { +- isStartReached, +- props: { data, onStartReachedThreshold }, +- scroll, +- scrollLength, +- startReachedSnapshot, +- totalSize +- } = state; +- const dataLength = data.length; +- const threshold = onStartReachedThreshold * scrollLength; +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } +- set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearStart", scroll <= threshold); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; +- if (!shouldSkipThresholdChecks) { +- state.isStartReached = checkThreshold( +- scroll, +- false, +- threshold, +- state.isStartReached, +- startReachedSnapshot, +- { +- contentSize: totalSize, +- dataLength, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a3, _b; +- if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); +- } +- }, +- (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) { +- 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 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); +- } +- } +-} +- +-// 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")) { +- 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; +- } +- 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; +- } +- 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 }); +- } +- } +- } +-} +- +-// 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); +- } +- 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 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); +- } +- } +- 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; +- } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; +-} +- + // src/core/getStartOffsetAdjustment.ts + function getStartOffsetAdjustment(ctx) { + const { state } = ctx; +@@ -1265,58 +660,279 @@ 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]); ++ 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 ++var HYSTERESIS_MULTIPLIER = 1.3; ++function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { ++ const absDistance = Math.abs(distance); ++ return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; ++} ++var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { ++ const absDistance = Math.abs(distance); ++ const within = atThreshold || threshold > 0 && absDistance <= threshold; ++ const updateSnapshot = () => { ++ setSnapshot({ ++ atThreshold, ++ contentSize: context.contentSize, ++ dataLength: context.dataLength, ++ scrollPosition: context.scrollPosition ++ }); ++ }; ++ if (!wasReached) { ++ if (!within) { ++ return false; ++ } ++ onReached(distance); ++ updateSnapshot(); ++ return true; ++ } ++ const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); ++ if (reset) { ++ setSnapshot(void 0); ++ return false; ++ } ++ if (within) { ++ const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; ++ if (changed) { ++ updateSnapshot(); ++ } ++ } ++ return true; ++}; ++ ++// src/utils/edgeReachedGate.ts ++function resetEdgeLatch(ctx, edge) { ++ const state = ctx.state; ++ if (edge === "start") { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } else { ++ state.isEndReached = false; ++ state.endReachedSnapshot = void 0; ++ } ++} ++function resetSharedEdgeGateIfOutsideHysteresis(ctx) { ++ const state = ctx.state; ++ if (!state.edgeReachedGate) { ++ return; ++ } ++ const contentSize = getContentSize(ctx); ++ const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isContentLess = contentSize < state.scrollLength; ++ const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; ++ const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; ++ const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); ++ const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); ++ if (isOutsideStart && isOutsideEnd) { ++ state.edgeReachedGate = void 0; ++ } ++} ++function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { ++ return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; ++} ++function markReachedEdge(ctx) { ++ ctx.state.edgeReachedGate = "closed"; ++} ++function prepareReachedEdgeForNextUserScroll(ctx) { ++ if (ctx.state.edgeReachedGate) { ++ ctx.state.edgeReachedGate = "prepared"; ++ } ++} ++function beginReachedEdgeUserScroll(ctx, scrollDelta) { ++ const state = ctx.state; ++ if (state.edgeReachedGate !== "prepared") { ++ return void 0; ++ } ++ const allowedEdge = scrollDelta < 0 ? "start" : "end"; ++ state.edgeReachedGate = "closed"; ++ resetEdgeLatch(ctx, allowedEdge); ++ return allowedEdge; ++} ++ ++// src/utils/hasActiveInitialScroll.ts ++function hasActiveInitialScroll(state) { ++ return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; ++} ++ ++// src/utils/checkAtBottom.ts ++function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ var _a3; ++ const state = ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ queuedInitialLayout, ++ scrollLength, ++ scroll, ++ maintainingScrollAtEnd, ++ props: { maintainScrollAtEndThreshold, onEndReachedThreshold } ++ } = state; ++ const contentSize = getContentSize(ctx); ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (contentSize > 0 && queuedInitialLayout) { ++ const insetEnd = getContentInsetEnd(ctx); ++ const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const isContentLess = contentSize < scrollLength; ++ set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); ++ set$( ++ ctx, ++ "isWithinMaintainScrollAtEndThreshold", ++ isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ ); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; ++ if (!shouldSkipThresholdChecks) { ++ state.isEndReached = checkThreshold( ++ distanceFromEnd, ++ isContentLess, ++ onEndReachedThreshold * scrollLength, ++ state.isEndReached, ++ state.endReachedSnapshot, ++ { ++ contentSize, ++ dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a4, _b; ++ if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.endReachedSnapshot = snapshot; ++ } ++ ); ++ } ++ } ++} ++ ++// src/utils/checkAtTop.ts ++function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ const state = ctx == null ? void 0 : ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ isStartReached, ++ props: { data, onStartReachedThreshold }, ++ scroll, ++ scrollLength, ++ startReachedSnapshot, ++ totalSize ++ } = state; ++ const dataLength = data.length; ++ const threshold = onStartReachedThreshold * scrollLength; ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } ++ set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearStart", scroll <= threshold); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; ++ if (!shouldSkipThresholdChecks) { ++ state.isStartReached = checkThreshold( ++ scroll, ++ false, ++ threshold, ++ state.isStartReached, ++ startReachedSnapshot, ++ { ++ contentSize: totalSize, ++ dataLength, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a3, _b; ++ if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.startReachedSnapshot = snapshot; ++ } ++ ); ++ } + } + +-// 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/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { ++ var _a3, _b; + 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); ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; ++ checkThresholds(ctx); + } + + // src/core/finishScrollTo.ts +@@ -1357,6 +973,124 @@ function finishScrollTo(ctx) { + } + } + ++// 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 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 ++ }); ++ } ++ (_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; ++ } ++ 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; ++ } ++ 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); ++ } ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); ++ } ++ 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; +@@ -1549,6 +1283,69 @@ function doScrollTo(ctx, params) { + } + } + ++// 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) { ++ 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 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); ++ } ++ } ++} ++ + // src/core/doMaintainScrollAtEnd.ts + function doMaintainScrollAtEnd(ctx) { + const state = ctx.state; +@@ -1575,9 +1372,12 @@ function doMaintainScrollAtEnd(ctx) { + const activeState = maintainScrollAtEnd.animated ? "animated" : "instant"; + const scrollAtRequest = state.scroll; + state.maintainingScrollAtEnd = pendingState; ++ clearScrollTargetSettle(state); + requestAnimationFrame(() => { + const isStillWithinThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); +- const didScrollSinceRequest = state.scroll !== scrollAtRequest; ++ const lastIssued = state.lastIssuedScrollOffset; ++ const isSettlingOntoIssuedScroll = lastIssued !== void 0 && Math.abs(state.scroll - lastIssued) <= EDGE_POSITION_EPSILON; ++ const didScrollSinceRequest = state.scroll !== scrollAtRequest && !isSettlingOntoIssuedScroll; + if (isStillWithinThreshold || !didScrollSinceRequest) { + state.maintainingScrollAtEnd = activeState; + const scroller = refScroller.current; +@@ -1972,11 +1772,32 @@ function prepareMVCP(ctx, dataChanged) { + } + } + +-// src/platform/flushSync.native.ts +-var flushSync = (fn) => { +- fn(); +-}; +- ++// src/platform/flushSync.native.ts ++var flushSync = (fn) => { ++ fn(); ++}; ++ ++// 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")) { ++ return; ++ } ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ + // src/utils/getScrollVelocity.ts + var MAX_SCROLL_VELOCITY_WINDOW_MS = 1e3; + var SCROLL_VELOCITY_HALF_LIFE_MS = 200; +@@ -2224,7 +2045,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 { +@@ -2246,6 +2067,7 @@ function scrollTo(ctx, params) { + if (!noScrollingTo) { + if (isInitialScroll) { + initialScrollCompletion.resetFlags(state); ++ clearScrollTargetSettle(state); + } + const averageSizeSnapshot = getAverageSizeSnapshot(state); + state.scrollingTo = { +@@ -2256,6 +2078,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (!animated && 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; +@@ -2263,7 +2094,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 }); +@@ -2276,6 +2107,310 @@ function scrollTo(ctx, params) { + } + } + ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 1; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_MAX_MS = 1e3; ++var SETTLE_MAX_CORRECTIONS = 8; ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++ state.scheduledWork.cancel("scrollTargetSettle"); ++ state.scheduledWork.cancel("scrollTargetSettleDeadline"); ++} ++function beginScrollTargetSettle(ctx, params) { ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ const { alignItemsAtEnd, data, maintainScrollAtEnd } = state.props; ++ const hasEndAnchoring = !!maintainScrollAtEnd || !!alignItemsAtEnd; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem && hasEndAnchoring) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ clearScrollTargetSettle(state); ++ const now = Date.now(); ++ state.scrollTargetSettle = { ++ corrections: 0, ++ deadline: now + SETTLE_MAX_MS, ++ id: getId(state, index), ++ measuredIndex: void 0, ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++ state.scheduledWork.timeout(() => clearScrollTargetSettle(state), SETTLE_MAX_MS, "scrollTargetSettleDeadline"); ++} ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function applyScrollTargetCorrection(ctx, id) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle || settle.id !== id) { ++ return; ++ } ++ const index = state.indexByKey.get(id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0 || Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ if (settle.corrections >= SETTLE_MAX_CORRECTIONS) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ settle.corrections++; ++ settle.measuredIndex = void 0; ++ scrollTo(ctx, { ++ animated: false, ++ index, ++ itemSize: getItemSizeAtIndex(ctx, index), ++ // Correcting where a scroll already landed is not a new imperative scroll: claiming the ++ // session would suppress the list's own handling of subsequent scroll events and re-arm ++ // this settle from inside its own correction. ++ noScrollingTo: true, ++ offset: position, ++ viewOffset: settle.viewOffset, ++ viewPosition: settle.viewPosition ++ }); ++ const scrollingTo = state.scrollingTo; ++ if (scrollingTo) { ++ scrollingTo.targetOffset = state.scrollPending; ++ scrollingTo.offset = position; ++ } ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++} ++function settleScrollTarget(ctx, options) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const measured = options == null ? void 0 : options.minIndexSizeChanged; ++ if (measured !== void 0) { ++ settle.measuredIndex = Math.min((_a3 = settle.measuredIndex) != null ? _a3 : Number.POSITIVE_INFINITY, measured); ++ } ++ if (options == null ? void 0 : options.isCompensating) { ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ settle.quietPasses = 0; ++ 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; ++ } ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ if (settle.measuredIndex === void 0 || settle.measuredIndex > index) { ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ if (Math.abs(targetOffset - state.scroll) <= SETTLE_POSITION_EPSILON) { ++ settle.measuredIndex = void 0; ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ state.scheduledWork.frame(() => applyScrollTargetCorrection(ctx, settle.id), "scrollTargetSettle"); ++ return true; ++} ++ ++// 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 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 ++ ) ++ ); ++ } ++} ++ ++// 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; ++ } ++ 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; ++ } ++ 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 }); ++ } ++ } ++ } ++} ++ ++// 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); ++ } ++ 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 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); ++ } ++ } ++ 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; ++ } ++ complete(); ++} ++ + // src/core/scrollToIndex.ts + function clampScrollIndex(index, dataLength) { + if (dataLength <= 0) { +@@ -4320,6 +4455,7 @@ function calculateItemsInView(ctx, params = {}) { + startIndex + }); + totalSize = getContentSize(ctx); ++ const minIndexSizeChangedThisPass = minIndexSizeChanged; + if (minIndexSizeChanged !== void 0) { + state.minIndexSizeChanged = void 0; + } +@@ -4337,8 +4473,18 @@ 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); +- if (didMVCPAdjustScroll) { ++ const scrollAdjustPendingAfterMVCP = (_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0; ++ const didMVCPAdjust = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || scrollAdjustPendingAfterMVCP !== scrollAdjustPendingBeforeMVCP); ++ const mvcp = state.props.maintainVisibleContentPosition; ++ const isUnanchoredDataChange = dataChanged && !mvcp.data; ++ const isCompensating = didMVCPAdjust || scrollAdjustPendingAfterMVCP !== 0 || !!state.pendingNativeMVCPAdjust; ++ if (!suppressInitialScrollSideEffects) { ++ settleScrollTarget(ctx, { ++ isCompensating, ++ minIndexSizeChanged: isUnanchoredDataChange ? 0 : minIndexSizeChangedThisPass ++ }); ++ } ++ if (didMVCPAdjust) { + updateScroll2(state.scroll); + updateScrollRange(); + } +@@ -5831,6 +5977,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; +@@ -5840,6 +5987,8 @@ 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) { ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -6926,13 +7075,14 @@ function getRenderedItem(ctx, key) { + + // 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) { +@@ -7652,6 +7802,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScrollBeginDrag: (event) => { + var _a4, _b2; + prepareReachedEdgeForNextUserScroll(ctx); ++ clearScrollTargetSettle(state); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, + onScrollEnd: () => prepareReachedEdgeForNextUserScroll(ctx) +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 40e87cd..8e6f6d0 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -436,611 +436,6 @@ 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(); +-} +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- 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 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 +- ) +- ); +- } +-} +- +-// 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 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 +- }); +- } +- (_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; +- } +- 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; +- } +- 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); +- } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); +- } +- 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/utils/checkThreshold.ts +-var HYSTERESIS_MULTIPLIER = 1.3; +-function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { +- const absDistance = Math.abs(distance); +- return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; +-} +-var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { +- const absDistance = Math.abs(distance); +- const within = atThreshold || threshold > 0 && absDistance <= threshold; +- const updateSnapshot = () => { +- setSnapshot({ +- atThreshold, +- contentSize: context.contentSize, +- dataLength: context.dataLength, +- scrollPosition: context.scrollPosition +- }); +- }; +- if (!wasReached) { +- if (!within) { +- return false; +- } +- onReached(distance); +- updateSnapshot(); +- return true; +- } +- const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); +- if (reset) { +- setSnapshot(void 0); +- return false; +- } +- if (within) { +- const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; +- if (changed) { +- updateSnapshot(); +- } +- } +- return true; +-}; +- +-// src/utils/edgeReachedGate.ts +-function resetEdgeLatch(ctx, edge) { +- const state = ctx.state; +- if (edge === "start") { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } else { +- state.isEndReached = false; +- state.endReachedSnapshot = void 0; +- } +-} +-function resetSharedEdgeGateIfOutsideHysteresis(ctx) { +- const state = ctx.state; +- if (!state.edgeReachedGate) { +- return; +- } +- const contentSize = getContentSize(ctx); +- const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); +- const isContentLess = contentSize < state.scrollLength; +- const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; +- const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; +- const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); +- const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); +- if (isOutsideStart && isOutsideEnd) { +- state.edgeReachedGate = void 0; +- } +-} +-function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { +- return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; +-} +-function markReachedEdge(ctx) { +- ctx.state.edgeReachedGate = "closed"; +-} +-function prepareReachedEdgeForNextUserScroll(ctx) { +- if (ctx.state.edgeReachedGate) { +- ctx.state.edgeReachedGate = "prepared"; +- } +-} +-function beginReachedEdgeUserScroll(ctx, scrollDelta) { +- const state = ctx.state; +- if (state.edgeReachedGate !== "prepared") { +- return void 0; +- } +- const allowedEdge = scrollDelta < 0 ? "start" : "end"; +- state.edgeReachedGate = "closed"; +- resetEdgeLatch(ctx, allowedEdge); +- return allowedEdge; +-} +- +-// src/utils/hasActiveInitialScroll.ts +-function hasActiveInitialScroll(state) { +- return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; +-} +- +-// src/utils/checkAtBottom.ts +-function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- var _a3; +- const state = ctx.state; +- if (!state) { +- return; +- } +- const { +- queuedInitialLayout, +- scrollLength, +- scroll, +- maintainingScrollAtEnd, +- props: { maintainScrollAtEndThreshold, onEndReachedThreshold } +- } = state; +- const contentSize = getContentSize(ctx); +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (contentSize > 0 && queuedInitialLayout) { +- const insetEnd = getContentInsetEnd(ctx); +- const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; +- const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); +- set$( +- ctx, +- "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength +- ); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; +- if (!shouldSkipThresholdChecks) { +- state.isEndReached = checkThreshold( +- distanceFromEnd, +- isContentLess, +- onEndReachedThreshold * scrollLength, +- state.isEndReached, +- state.endReachedSnapshot, +- { +- contentSize, +- dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a4, _b; +- if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); +- } +- }, +- (snapshot) => { +- state.endReachedSnapshot = snapshot; +- } +- ); +- } +- } +-} +- +-// src/utils/checkAtTop.ts +-function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- const state = ctx == null ? void 0 : ctx.state; +- if (!state) { +- return; +- } +- const { +- isStartReached, +- props: { data, onStartReachedThreshold }, +- scroll, +- scrollLength, +- startReachedSnapshot, +- totalSize +- } = state; +- const dataLength = data.length; +- const threshold = onStartReachedThreshold * scrollLength; +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } +- set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearStart", scroll <= threshold); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; +- if (!shouldSkipThresholdChecks) { +- state.isStartReached = checkThreshold( +- scroll, +- false, +- threshold, +- state.isStartReached, +- startReachedSnapshot, +- { +- contentSize: totalSize, +- dataLength, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a3, _b; +- if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); +- } +- }, +- (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) { +- 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 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); +- } +- } +-} +- +-// 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")) { +- 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; +- } +- 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; +- } +- 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 }); +- } +- } +- } +-} +- +-// 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); +- } +- 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 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); +- } +- } +- 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; +- } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; +-} +- + // src/core/getStartOffsetAdjustment.ts + function getStartOffsetAdjustment(ctx) { + const { state } = ctx; +@@ -1244,58 +639,279 @@ 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]); ++ 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 ++var HYSTERESIS_MULTIPLIER = 1.3; ++function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { ++ const absDistance = Math.abs(distance); ++ return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; ++} ++var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { ++ const absDistance = Math.abs(distance); ++ const within = atThreshold || threshold > 0 && absDistance <= threshold; ++ const updateSnapshot = () => { ++ setSnapshot({ ++ atThreshold, ++ contentSize: context.contentSize, ++ dataLength: context.dataLength, ++ scrollPosition: context.scrollPosition ++ }); ++ }; ++ if (!wasReached) { ++ if (!within) { ++ return false; ++ } ++ onReached(distance); ++ updateSnapshot(); ++ return true; ++ } ++ const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); ++ if (reset) { ++ setSnapshot(void 0); ++ return false; ++ } ++ if (within) { ++ const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; ++ if (changed) { ++ updateSnapshot(); ++ } ++ } ++ return true; ++}; ++ ++// src/utils/edgeReachedGate.ts ++function resetEdgeLatch(ctx, edge) { ++ const state = ctx.state; ++ if (edge === "start") { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } else { ++ state.isEndReached = false; ++ state.endReachedSnapshot = void 0; ++ } ++} ++function resetSharedEdgeGateIfOutsideHysteresis(ctx) { ++ const state = ctx.state; ++ if (!state.edgeReachedGate) { ++ return; ++ } ++ const contentSize = getContentSize(ctx); ++ const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isContentLess = contentSize < state.scrollLength; ++ const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; ++ const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; ++ const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); ++ const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); ++ if (isOutsideStart && isOutsideEnd) { ++ state.edgeReachedGate = void 0; ++ } ++} ++function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { ++ return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; ++} ++function markReachedEdge(ctx) { ++ ctx.state.edgeReachedGate = "closed"; ++} ++function prepareReachedEdgeForNextUserScroll(ctx) { ++ if (ctx.state.edgeReachedGate) { ++ ctx.state.edgeReachedGate = "prepared"; ++ } ++} ++function beginReachedEdgeUserScroll(ctx, scrollDelta) { ++ const state = ctx.state; ++ if (state.edgeReachedGate !== "prepared") { ++ return void 0; ++ } ++ const allowedEdge = scrollDelta < 0 ? "start" : "end"; ++ state.edgeReachedGate = "closed"; ++ resetEdgeLatch(ctx, allowedEdge); ++ return allowedEdge; ++} ++ ++// src/utils/hasActiveInitialScroll.ts ++function hasActiveInitialScroll(state) { ++ return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; ++} ++ ++// src/utils/checkAtBottom.ts ++function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ var _a3; ++ const state = ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ queuedInitialLayout, ++ scrollLength, ++ scroll, ++ maintainingScrollAtEnd, ++ props: { maintainScrollAtEndThreshold, onEndReachedThreshold } ++ } = state; ++ const contentSize = getContentSize(ctx); ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (contentSize > 0 && queuedInitialLayout) { ++ const insetEnd = getContentInsetEnd(ctx); ++ const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const isContentLess = contentSize < scrollLength; ++ set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); ++ set$( ++ ctx, ++ "isWithinMaintainScrollAtEndThreshold", ++ isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ ); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; ++ if (!shouldSkipThresholdChecks) { ++ state.isEndReached = checkThreshold( ++ distanceFromEnd, ++ isContentLess, ++ onEndReachedThreshold * scrollLength, ++ state.isEndReached, ++ state.endReachedSnapshot, ++ { ++ contentSize, ++ dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a4, _b; ++ if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.endReachedSnapshot = snapshot; ++ } ++ ); ++ } ++ } ++} ++ ++// src/utils/checkAtTop.ts ++function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ const state = ctx == null ? void 0 : ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ isStartReached, ++ props: { data, onStartReachedThreshold }, ++ scroll, ++ scrollLength, ++ startReachedSnapshot, ++ totalSize ++ } = state; ++ const dataLength = data.length; ++ const threshold = onStartReachedThreshold * scrollLength; ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } ++ set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearStart", scroll <= threshold); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; ++ if (!shouldSkipThresholdChecks) { ++ state.isStartReached = checkThreshold( ++ scroll, ++ false, ++ threshold, ++ state.isStartReached, ++ startReachedSnapshot, ++ { ++ contentSize: totalSize, ++ dataLength, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a3, _b; ++ if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.startReachedSnapshot = snapshot; ++ } ++ ); ++ } + } + +-// 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/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { ++ var _a3, _b; + 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); ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; ++ checkThresholds(ctx); + } + + // src/core/finishScrollTo.ts +@@ -1336,6 +952,124 @@ function finishScrollTo(ctx) { + } + } + ++// 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 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 ++ }); ++ } ++ (_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; ++ } ++ 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; ++ } ++ 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); ++ } ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); ++ } ++ 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; +@@ -1528,6 +1262,69 @@ function doScrollTo(ctx, params) { + } + } + ++// 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) { ++ 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 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); ++ } ++ } ++} ++ + // src/core/doMaintainScrollAtEnd.ts + function doMaintainScrollAtEnd(ctx) { + const state = ctx.state; +@@ -1554,9 +1351,12 @@ function doMaintainScrollAtEnd(ctx) { + const activeState = maintainScrollAtEnd.animated ? "animated" : "instant"; + const scrollAtRequest = state.scroll; + state.maintainingScrollAtEnd = pendingState; ++ clearScrollTargetSettle(state); + requestAnimationFrame(() => { + const isStillWithinThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); +- const didScrollSinceRequest = state.scroll !== scrollAtRequest; ++ const lastIssued = state.lastIssuedScrollOffset; ++ const isSettlingOntoIssuedScroll = lastIssued !== void 0 && Math.abs(state.scroll - lastIssued) <= EDGE_POSITION_EPSILON; ++ const didScrollSinceRequest = state.scroll !== scrollAtRequest && !isSettlingOntoIssuedScroll; + if (isStillWithinThreshold || !didScrollSinceRequest) { + state.maintainingScrollAtEnd = activeState; + const scroller = refScroller.current; +@@ -1951,11 +1751,32 @@ function prepareMVCP(ctx, dataChanged) { + } + } + +-// src/platform/flushSync.native.ts +-var flushSync = (fn) => { +- fn(); +-}; +- ++// src/platform/flushSync.native.ts ++var flushSync = (fn) => { ++ fn(); ++}; ++ ++// 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")) { ++ return; ++ } ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ + // src/utils/getScrollVelocity.ts + var MAX_SCROLL_VELOCITY_WINDOW_MS = 1e3; + var SCROLL_VELOCITY_HALF_LIFE_MS = 200; +@@ -2203,7 +2024,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 { +@@ -2225,6 +2046,7 @@ function scrollTo(ctx, params) { + if (!noScrollingTo) { + if (isInitialScroll) { + initialScrollCompletion.resetFlags(state); ++ clearScrollTargetSettle(state); + } + const averageSizeSnapshot = getAverageSizeSnapshot(state); + state.scrollingTo = { +@@ -2235,6 +2057,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (!animated && 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; +@@ -2242,7 +2073,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 }); +@@ -2255,6 +2086,310 @@ function scrollTo(ctx, params) { + } + } + ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 1; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_MAX_MS = 1e3; ++var SETTLE_MAX_CORRECTIONS = 8; ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++ state.scheduledWork.cancel("scrollTargetSettle"); ++ state.scheduledWork.cancel("scrollTargetSettleDeadline"); ++} ++function beginScrollTargetSettle(ctx, params) { ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ const { alignItemsAtEnd, data, maintainScrollAtEnd } = state.props; ++ const hasEndAnchoring = !!maintainScrollAtEnd || !!alignItemsAtEnd; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem && hasEndAnchoring) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ clearScrollTargetSettle(state); ++ const now = Date.now(); ++ state.scrollTargetSettle = { ++ corrections: 0, ++ deadline: now + SETTLE_MAX_MS, ++ id: getId(state, index), ++ measuredIndex: void 0, ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++ state.scheduledWork.timeout(() => clearScrollTargetSettle(state), SETTLE_MAX_MS, "scrollTargetSettleDeadline"); ++} ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function applyScrollTargetCorrection(ctx, id) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle || settle.id !== id) { ++ return; ++ } ++ const index = state.indexByKey.get(id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0 || Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ if (settle.corrections >= SETTLE_MAX_CORRECTIONS) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ settle.corrections++; ++ settle.measuredIndex = void 0; ++ scrollTo(ctx, { ++ animated: false, ++ index, ++ itemSize: getItemSizeAtIndex(ctx, index), ++ // Correcting where a scroll already landed is not a new imperative scroll: claiming the ++ // session would suppress the list's own handling of subsequent scroll events and re-arm ++ // this settle from inside its own correction. ++ noScrollingTo: true, ++ offset: position, ++ viewOffset: settle.viewOffset, ++ viewPosition: settle.viewPosition ++ }); ++ const scrollingTo = state.scrollingTo; ++ if (scrollingTo) { ++ scrollingTo.targetOffset = state.scrollPending; ++ scrollingTo.offset = position; ++ } ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++} ++function settleScrollTarget(ctx, options) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const measured = options == null ? void 0 : options.minIndexSizeChanged; ++ if (measured !== void 0) { ++ settle.measuredIndex = Math.min((_a3 = settle.measuredIndex) != null ? _a3 : Number.POSITIVE_INFINITY, measured); ++ } ++ if (options == null ? void 0 : options.isCompensating) { ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ settle.quietPasses = 0; ++ 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; ++ } ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ if (settle.measuredIndex === void 0 || settle.measuredIndex > index) { ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ if (Math.abs(targetOffset - state.scroll) <= SETTLE_POSITION_EPSILON) { ++ settle.measuredIndex = void 0; ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ state.scheduledWork.frame(() => applyScrollTargetCorrection(ctx, settle.id), "scrollTargetSettle"); ++ return true; ++} ++ ++// 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 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 ++ ) ++ ); ++ } ++} ++ ++// 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; ++ } ++ 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; ++ } ++ 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 }); ++ } ++ } ++ } ++} ++ ++// 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); ++ } ++ 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 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); ++ } ++ } ++ 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; ++ } ++ complete(); ++} ++ + // src/core/scrollToIndex.ts + function clampScrollIndex(index, dataLength) { + if (dataLength <= 0) { +@@ -4299,6 +4434,7 @@ function calculateItemsInView(ctx, params = {}) { + startIndex + }); + totalSize = getContentSize(ctx); ++ const minIndexSizeChangedThisPass = minIndexSizeChanged; + if (minIndexSizeChanged !== void 0) { + state.minIndexSizeChanged = void 0; + } +@@ -4316,8 +4452,18 @@ 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); +- if (didMVCPAdjustScroll) { ++ const scrollAdjustPendingAfterMVCP = (_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0; ++ const didMVCPAdjust = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || scrollAdjustPendingAfterMVCP !== scrollAdjustPendingBeforeMVCP); ++ const mvcp = state.props.maintainVisibleContentPosition; ++ const isUnanchoredDataChange = dataChanged && !mvcp.data; ++ const isCompensating = didMVCPAdjust || scrollAdjustPendingAfterMVCP !== 0 || !!state.pendingNativeMVCPAdjust; ++ if (!suppressInitialScrollSideEffects) { ++ settleScrollTarget(ctx, { ++ isCompensating, ++ minIndexSizeChanged: isUnanchoredDataChange ? 0 : minIndexSizeChangedThisPass ++ }); ++ } ++ if (didMVCPAdjust) { + updateScroll2(state.scroll); + updateScrollRange(); + } +@@ -5810,6 +5956,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; +@@ -5819,6 +5966,8 @@ 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) { ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -6905,13 +7054,14 @@ function getRenderedItem(ctx, key) { + + // 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) { +@@ -7631,6 +7781,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScrollBeginDrag: (event) => { + var _a4, _b2; + prepareReachedEdgeForNextUserScroll(ctx); ++ clearScrollTargetSettle(state); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, + onScrollEnd: () => prepareReachedEdgeForNextUserScroll(ctx) +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 914d2da..ccb19f4 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -434,605 +434,6 @@ 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(); +-} +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- 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 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 +- ) +- ); +- } +-} +- +-// 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 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 +- }); +- } +- (_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; +- } +- 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; +- } +- 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); +- } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); +- } +- 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/utils/checkThreshold.ts +-var HYSTERESIS_MULTIPLIER = 1.3; +-function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { +- const absDistance = Math.abs(distance); +- return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; +-} +-var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { +- const absDistance = Math.abs(distance); +- const within = atThreshold || threshold > 0 && absDistance <= threshold; +- const updateSnapshot = () => { +- setSnapshot({ +- atThreshold, +- contentSize: context.contentSize, +- dataLength: context.dataLength, +- scrollPosition: context.scrollPosition +- }); +- }; +- if (!wasReached) { +- if (!within) { +- return false; +- } +- onReached(distance); +- updateSnapshot(); +- return true; +- } +- const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); +- if (reset) { +- setSnapshot(void 0); +- return false; +- } +- if (within) { +- const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; +- if (changed) { +- updateSnapshot(); +- } +- } +- return true; +-}; +- +-// src/utils/edgeReachedGate.ts +-function resetEdgeLatch(ctx, edge) { +- const state = ctx.state; +- if (edge === "start") { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } else { +- state.isEndReached = false; +- state.endReachedSnapshot = void 0; +- } +-} +-function resetSharedEdgeGateIfOutsideHysteresis(ctx) { +- const state = ctx.state; +- if (!state.edgeReachedGate) { +- return; +- } +- const contentSize = getContentSize(ctx); +- const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); +- const isContentLess = contentSize < state.scrollLength; +- const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; +- const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; +- const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); +- const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); +- if (isOutsideStart && isOutsideEnd) { +- state.edgeReachedGate = void 0; +- } +-} +-function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { +- return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; +-} +-function markReachedEdge(ctx) { +- ctx.state.edgeReachedGate = "closed"; +-} +-function prepareReachedEdgeForNextUserScroll(ctx) { +- if (ctx.state.edgeReachedGate) { +- ctx.state.edgeReachedGate = "prepared"; +- } +-} +-function beginReachedEdgeUserScroll(ctx, scrollDelta) { +- const state = ctx.state; +- if (state.edgeReachedGate !== "prepared") { +- return void 0; +- } +- const allowedEdge = scrollDelta < 0 ? "start" : "end"; +- state.edgeReachedGate = "closed"; +- resetEdgeLatch(ctx, allowedEdge); +- return allowedEdge; +-} +- +-// src/utils/hasActiveInitialScroll.ts +-function hasActiveInitialScroll(state) { +- return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; +-} +- +-// src/utils/checkAtBottom.ts +-function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- var _a3; +- const state = ctx.state; +- if (!state) { +- return; +- } +- const { +- queuedInitialLayout, +- scrollLength, +- scroll, +- maintainingScrollAtEnd, +- props: { maintainScrollAtEndThreshold, onEndReachedThreshold } +- } = state; +- const contentSize = getContentSize(ctx); +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (contentSize > 0 && queuedInitialLayout) { +- const insetEnd = getContentInsetEnd(ctx); +- const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; +- const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); +- set$( +- ctx, +- "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength +- ); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; +- if (!shouldSkipThresholdChecks) { +- state.isEndReached = checkThreshold( +- distanceFromEnd, +- isContentLess, +- onEndReachedThreshold * scrollLength, +- state.isEndReached, +- state.endReachedSnapshot, +- { +- contentSize, +- dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a4, _b; +- if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); +- } +- }, +- (snapshot) => { +- state.endReachedSnapshot = snapshot; +- } +- ); +- } +- } +-} +- +-// src/utils/checkAtTop.ts +-function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- const state = ctx == null ? void 0 : ctx.state; +- if (!state) { +- return; +- } +- const { +- isStartReached, +- props: { data, onStartReachedThreshold }, +- scroll, +- scrollLength, +- startReachedSnapshot, +- totalSize +- } = state; +- const dataLength = data.length; +- const threshold = onStartReachedThreshold * scrollLength; +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } +- set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearStart", scroll <= threshold); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; +- if (!shouldSkipThresholdChecks) { +- state.isStartReached = checkThreshold( +- scroll, +- false, +- threshold, +- state.isStartReached, +- startReachedSnapshot, +- { +- contentSize: totalSize, +- dataLength, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a3, _b; +- if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); +- } +- }, +- (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); +-} +-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) { +- 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); +- } +- } +-} +- +-// 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")) { +- 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; +- } +- 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; +- } +- 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 }); +- } +- } +- } +-} +- +-// 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); +- } +- 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 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); +- } +- } +- 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); +- } +- 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; +- } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; +-} +- + // src/core/getStartOffsetAdjustment.ts + function getStartOffsetAdjustment(ctx) { + const { state } = ctx; +@@ -1234,58 +635,273 @@ 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]); ++ 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 ++var HYSTERESIS_MULTIPLIER = 1.3; ++function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { ++ const absDistance = Math.abs(distance); ++ return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; ++} ++var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { ++ const absDistance = Math.abs(distance); ++ const within = atThreshold || threshold > 0 && absDistance <= threshold; ++ const updateSnapshot = () => { ++ setSnapshot({ ++ atThreshold, ++ contentSize: context.contentSize, ++ dataLength: context.dataLength, ++ scrollPosition: context.scrollPosition ++ }); ++ }; ++ if (!wasReached) { ++ if (!within) { ++ return false; ++ } ++ onReached(distance); ++ updateSnapshot(); ++ return true; ++ } ++ const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); ++ if (reset) { ++ setSnapshot(void 0); ++ return false; ++ } ++ if (within) { ++ const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; ++ if (changed) { ++ updateSnapshot(); ++ } ++ } ++ return true; ++}; ++ ++// src/utils/edgeReachedGate.ts ++function resetEdgeLatch(ctx, edge) { ++ const state = ctx.state; ++ if (edge === "start") { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } else { ++ state.isEndReached = false; ++ state.endReachedSnapshot = void 0; ++ } ++} ++function resetSharedEdgeGateIfOutsideHysteresis(ctx) { ++ const state = ctx.state; ++ if (!state.edgeReachedGate) { ++ return; ++ } ++ const contentSize = getContentSize(ctx); ++ const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isContentLess = contentSize < state.scrollLength; ++ const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; ++ const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; ++ const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); ++ const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); ++ if (isOutsideStart && isOutsideEnd) { ++ state.edgeReachedGate = void 0; ++ } ++} ++function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { ++ return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; ++} ++function markReachedEdge(ctx) { ++ ctx.state.edgeReachedGate = "closed"; ++} ++function prepareReachedEdgeForNextUserScroll(ctx) { ++ if (ctx.state.edgeReachedGate) { ++ ctx.state.edgeReachedGate = "prepared"; ++ } ++} ++function beginReachedEdgeUserScroll(ctx, scrollDelta) { ++ const state = ctx.state; ++ if (state.edgeReachedGate !== "prepared") { ++ return void 0; ++ } ++ const allowedEdge = scrollDelta < 0 ? "start" : "end"; ++ state.edgeReachedGate = "closed"; ++ resetEdgeLatch(ctx, allowedEdge); ++ return allowedEdge; ++} ++ ++// src/utils/hasActiveInitialScroll.ts ++function hasActiveInitialScroll(state) { ++ return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; ++} ++ ++// src/utils/checkAtBottom.ts ++function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ var _a3; ++ const state = ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ queuedInitialLayout, ++ scrollLength, ++ scroll, ++ maintainingScrollAtEnd, ++ props: { maintainScrollAtEndThreshold, onEndReachedThreshold } ++ } = state; ++ const contentSize = getContentSize(ctx); ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (contentSize > 0 && queuedInitialLayout) { ++ const insetEnd = getContentInsetEnd(ctx); ++ const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const isContentLess = contentSize < scrollLength; ++ set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); ++ set$( ++ ctx, ++ "isWithinMaintainScrollAtEndThreshold", ++ isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ ); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; ++ if (!shouldSkipThresholdChecks) { ++ state.isEndReached = checkThreshold( ++ distanceFromEnd, ++ isContentLess, ++ onEndReachedThreshold * scrollLength, ++ state.isEndReached, ++ state.endReachedSnapshot, ++ { ++ contentSize, ++ dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a4, _b; ++ if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.endReachedSnapshot = snapshot; ++ } ++ ); ++ } ++ } ++} ++ ++// src/utils/checkAtTop.ts ++function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ const state = ctx == null ? void 0 : ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ isStartReached, ++ props: { data, onStartReachedThreshold }, ++ scroll, ++ scrollLength, ++ startReachedSnapshot, ++ totalSize ++ } = state; ++ const dataLength = data.length; ++ const threshold = onStartReachedThreshold * scrollLength; ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } ++ set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearStart", scroll <= threshold); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; ++ if (!shouldSkipThresholdChecks) { ++ state.isStartReached = checkThreshold( ++ scroll, ++ false, ++ threshold, ++ state.isStartReached, ++ startReachedSnapshot, ++ { ++ contentSize: totalSize, ++ dataLength, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a3, _b; ++ if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.startReachedSnapshot = snapshot; ++ } ++ ); ++ } + } + +-// 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/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { ++ var _a3, _b; + 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); ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; ++ checkThresholds(ctx); + } + + // src/core/finishScrollTo.ts +@@ -1425,6 +1041,181 @@ function listenForScrollEnd(ctx, params) { + scheduledWork.register("platformScrollCompletion", cancel); + } + ++// 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 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 ++ }); ++ } ++ (_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; ++ } ++ 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; ++ } ++ 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); ++ } ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); ++ } ++ 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; ++} ++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) { ++ 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); ++ } ++ } ++} ++ + // src/core/doMaintainScrollAtEnd.ts + function doMaintainScrollAtEnd(ctx) { + const state = ctx.state; +@@ -1451,9 +1242,12 @@ function doMaintainScrollAtEnd(ctx) { + const activeState = maintainScrollAtEnd.animated ? "animated" : "instant"; + const scrollAtRequest = state.scroll; + state.maintainingScrollAtEnd = pendingState; ++ clearScrollTargetSettle(state); + requestAnimationFrame(() => { + const isStillWithinThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); +- const didScrollSinceRequest = state.scroll !== scrollAtRequest; ++ const lastIssued = state.lastIssuedScrollOffset; ++ const isSettlingOntoIssuedScroll = lastIssued !== void 0 && Math.abs(state.scroll - lastIssued) <= EDGE_POSITION_EPSILON; ++ const didScrollSinceRequest = state.scroll !== scrollAtRequest && !isSettlingOntoIssuedScroll; + if (isStillWithinThreshold || !didScrollSinceRequest) { + state.maintainingScrollAtEnd = activeState; + const scroller = refScroller.current; +@@ -1809,6 +1603,27 @@ function prepareMVCP(ctx, dataChanged) { + } + } + ++// 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")) { ++ return; ++ } ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ + // src/utils/getScrollVelocity.ts + var MAX_SCROLL_VELOCITY_WINDOW_MS = 1e3; + var SCROLL_VELOCITY_HALF_LIFE_MS = 200; +@@ -2069,7 +1884,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 { +@@ -2091,6 +1906,7 @@ function scrollTo(ctx, params) { + if (!noScrollingTo) { + if (isInitialScroll) { + initialScrollCompletion.resetFlags(state); ++ clearScrollTargetSettle(state); + } + const averageSizeSnapshot = getAverageSizeSnapshot(state); + state.scrollingTo = { +@@ -2101,6 +1917,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (!animated && 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; +@@ -2108,7 +1933,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 }); +@@ -2121,6 +1946,316 @@ function scrollTo(ctx, params) { + } + } + ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 1; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_MAX_MS = 1e3; ++var SETTLE_MAX_CORRECTIONS = 8; ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++ state.scheduledWork.cancel("scrollTargetSettle"); ++ state.scheduledWork.cancel("scrollTargetSettleDeadline"); ++} ++function beginScrollTargetSettle(ctx, params) { ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ const { alignItemsAtEnd, data, maintainScrollAtEnd } = state.props; ++ const hasEndAnchoring = !!maintainScrollAtEnd || !!alignItemsAtEnd; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem && hasEndAnchoring) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ clearScrollTargetSettle(state); ++ const now = Date.now(); ++ state.scrollTargetSettle = { ++ corrections: 0, ++ deadline: now + SETTLE_MAX_MS, ++ id: getId(state, index), ++ measuredIndex: void 0, ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++ state.scheduledWork.timeout(() => clearScrollTargetSettle(state), SETTLE_MAX_MS, "scrollTargetSettleDeadline"); ++} ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function applyScrollTargetCorrection(ctx, id) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle || settle.id !== id) { ++ return; ++ } ++ const index = state.indexByKey.get(id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0 || Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ if (settle.corrections >= SETTLE_MAX_CORRECTIONS) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ settle.corrections++; ++ settle.measuredIndex = void 0; ++ scrollTo(ctx, { ++ animated: false, ++ index, ++ itemSize: getItemSizeAtIndex(ctx, index), ++ // Correcting where a scroll already landed is not a new imperative scroll: claiming the ++ // session would suppress the list's own handling of subsequent scroll events and re-arm ++ // this settle from inside its own correction. ++ noScrollingTo: true, ++ offset: position, ++ viewOffset: settle.viewOffset, ++ viewPosition: settle.viewPosition ++ }); ++ const scrollingTo = state.scrollingTo; ++ if (scrollingTo) { ++ scrollingTo.targetOffset = state.scrollPending; ++ scrollingTo.offset = position; ++ } ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++} ++function settleScrollTarget(ctx, options) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const measured = options == null ? void 0 : options.minIndexSizeChanged; ++ if (measured !== void 0) { ++ settle.measuredIndex = Math.min((_a3 = settle.measuredIndex) != null ? _a3 : Number.POSITIVE_INFINITY, measured); ++ } ++ if (options == null ? void 0 : options.isCompensating) { ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ settle.quietPasses = 0; ++ 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; ++ } ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ if (settle.measuredIndex === void 0 || settle.measuredIndex > index) { ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ if (Math.abs(targetOffset - state.scroll) <= SETTLE_POSITION_EPSILON) { ++ settle.measuredIndex = void 0; ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ state.scheduledWork.frame(() => applyScrollTargetCorrection(ctx, settle.id), "scrollTargetSettle"); ++ return true; ++} ++ ++// 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 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 ++ ) ++ ); ++ } ++} ++ ++// 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; ++ } ++ 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; ++ } ++ 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 }); ++ } ++ } ++ } ++} ++ ++// 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); ++ } ++ 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 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); ++ } ++ } ++ 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); ++ } ++ 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; ++ } ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; ++} ++ + // src/core/scrollToIndex.ts + function clampScrollIndex(index, dataLength) { + if (dataLength <= 0) { +@@ -4350,6 +4485,7 @@ function calculateItemsInView(ctx, params = {}) { + startIndex + }); + totalSize = getContentSize(ctx); ++ const minIndexSizeChangedThisPass = minIndexSizeChanged; + if (minIndexSizeChanged !== void 0) { + state.minIndexSizeChanged = void 0; + } +@@ -4367,8 +4503,18 @@ 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); +- if (didMVCPAdjustScroll) { ++ const scrollAdjustPendingAfterMVCP = (_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0; ++ const didMVCPAdjust = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || scrollAdjustPendingAfterMVCP !== scrollAdjustPendingBeforeMVCP); ++ const mvcp = state.props.maintainVisibleContentPosition; ++ const isUnanchoredDataChange = dataChanged && !mvcp.data; ++ const isCompensating = didMVCPAdjust || scrollAdjustPendingAfterMVCP !== 0 || !!state.pendingNativeMVCPAdjust; ++ if (!suppressInitialScrollSideEffects) { ++ settleScrollTarget(ctx, { ++ isCompensating, ++ minIndexSizeChanged: isUnanchoredDataChange ? 0 : minIndexSizeChangedThisPass ++ }); ++ } ++ if (didMVCPAdjust) { + updateScroll2(state.scroll); + updateScrollRange(); + } +@@ -6006,6 +6152,8 @@ function resolveWindowScrollTarget({ clampedOffset, horizontal, listPos, scroll + // src/components/ListComponentScrollView.tsx + var SCROLLBAR_HIDDEN_STYLE_ID = "legend-list-scrollbar-axis-hidden-style"; + var SCROLL_END_FALLBACK_MS = 200; ++var SCROLL_EXTENT_EPSILON = 1; ++var REACHABLE_RETRY_FRAMES = 30; + var SCROLLBAR_HIDDEN_STYLE = `.${LEGEND_LIST_SCROLLBAR_Y_HIDDEN_CLASS}::-webkit-scrollbar:vertical{width:0;display:none;}.${LEGEND_LIST_SCROLLBAR_X_HIDDEN_CLASS}::-webkit-scrollbar:horizontal{height:0;display:none;}`; + function ensureScrollbarHiddenStyle() { + if (typeof document === "undefined" || document.getElementById(SCROLLBAR_HIDDEN_STYLE_ID)) { +@@ -6094,6 +6242,26 @@ var ListComponentScrollView = React3.forwardRef(function ListComponentScrollView + } + return horizontal ? scrollElement.scrollLeft : scrollElement.scrollTop; + }, [getMaxScrollOffset, horizontal, isWindowScroll]); ++ const reissueHandleRef = React3.useRef(0); ++ const scrollUntilReachable = React3.useCallback( ++ (offset, animated, run) => { ++ cancelAnimationFrame(reissueHandleRef.current); ++ let attempts = 0; ++ let previousMaxOffset = Number.NEGATIVE_INFINITY; ++ const attempt = () => { ++ const liveMaxOffset = getMaxScrollOffset(); ++ run(clampOffset(offset, liveMaxOffset)); ++ const isStillCommitting = liveMaxOffset > previousMaxOffset; ++ previousMaxOffset = liveMaxOffset; ++ if (!animated && isStillCommitting && Number.isFinite(offset) && offset - liveMaxOffset > SCROLL_EXTENT_EPSILON && ++attempts < REACHABLE_RETRY_FRAMES) { ++ reissueHandleRef.current = requestAnimationFrame(attempt); ++ } ++ }; ++ attempt(); ++ }, ++ [getMaxScrollOffset] ++ ); ++ React3.useEffect(() => () => cancelAnimationFrame(reissueHandleRef.current), []); + const scrollToLocalOffset = React3.useCallback( + (offset, animated) => { + const scrollElement = scrollRef.current; +@@ -6101,29 +6269,34 @@ var ListComponentScrollView = React3.forwardRef(function ListComponentScrollView + if (!target || typeof target.scrollTo !== "function") { + return; + } +- const maxOffset = getMaxScrollOffset(); +- const clampedOffset = clampOffset(offset, maxOffset); + const behavior = animated ? "smooth" : "auto"; + const options = { behavior }; + if (isWindowScroll) { + const scroll = getWindowScrollPosition(); + const listPos = getElementDocumentPosition(scrollElement, scroll); + const { left, top } = resolveWindowScrollTarget({ +- clampedOffset, ++ clampedOffset: clampOffset(offset, getMaxScrollOffset()), + horizontal, + listPos, + scroll + }); + options.left = left; + options.top = top; +- } else if (horizontal) { +- options.left = clampedOffset; +- } else { +- options.top = clampedOffset; ++ ctx.state.lastIssuedScrollOffset = clampOffset(offset, getMaxScrollOffset()); ++ target.scrollTo(options); ++ return; + } +- target.scrollTo(options); ++ scrollUntilReachable(offset, animated, (reachableOffset) => { ++ if (horizontal) { ++ options.left = reachableOffset; ++ } else { ++ options.top = reachableOffset; ++ } ++ ctx.state.lastIssuedScrollOffset = reachableOffset; ++ target.scrollTo(options); ++ }); + }, +- [getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll] ++ [ctx, getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll, scrollUntilReachable] + ); + React3.useImperativeHandle(ref, () => { + const api = { +@@ -6149,8 +6322,7 @@ var ListComponentScrollView = React3.forwardRef(function ListComponentScrollView + }, + scrollToEnd: (options = {}) => { + const { animated = true } = options; +- const endOffset = getMaxScrollOffset(); +- scrollToLocalOffset(endOffset, animated); ++ scrollToLocalOffset(getMaxScrollOffset(), animated); + }, + scrollToOffset: (params) => { + const { offset, animated = true } = params; +@@ -6499,6 +6671,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; +@@ -6508,6 +6681,8 @@ 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) { ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7570,13 +7745,14 @@ function getRenderedItem(ctx, key) { + + // 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) { +@@ -8288,6 +8464,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScrollBeginDrag: (event) => { + var _a4, _b2; + prepareReachedEdgeForNextUserScroll(ctx); ++ clearScrollTargetSettle(state); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, + onScrollEnd: () => prepareReachedEdgeForNextUserScroll(ctx) +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 95465f2..4fb1e41 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -413,605 +413,6 @@ 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(); +-} +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- 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 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 +- ) +- ); +- } +-} +- +-// 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 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 +- }); +- } +- (_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; +- } +- 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; +- } +- 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); +- } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); +- } +- 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/utils/checkThreshold.ts +-var HYSTERESIS_MULTIPLIER = 1.3; +-function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { +- const absDistance = Math.abs(distance); +- return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; +-} +-var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { +- const absDistance = Math.abs(distance); +- const within = atThreshold || threshold > 0 && absDistance <= threshold; +- const updateSnapshot = () => { +- setSnapshot({ +- atThreshold, +- contentSize: context.contentSize, +- dataLength: context.dataLength, +- scrollPosition: context.scrollPosition +- }); +- }; +- if (!wasReached) { +- if (!within) { +- return false; +- } +- onReached(distance); +- updateSnapshot(); +- return true; +- } +- const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); +- if (reset) { +- setSnapshot(void 0); +- return false; +- } +- if (within) { +- const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; +- if (changed) { +- updateSnapshot(); +- } +- } +- return true; +-}; +- +-// src/utils/edgeReachedGate.ts +-function resetEdgeLatch(ctx, edge) { +- const state = ctx.state; +- if (edge === "start") { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } else { +- state.isEndReached = false; +- state.endReachedSnapshot = void 0; +- } +-} +-function resetSharedEdgeGateIfOutsideHysteresis(ctx) { +- const state = ctx.state; +- if (!state.edgeReachedGate) { +- return; +- } +- const contentSize = getContentSize(ctx); +- const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); +- const isContentLess = contentSize < state.scrollLength; +- const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; +- const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; +- const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); +- const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); +- if (isOutsideStart && isOutsideEnd) { +- state.edgeReachedGate = void 0; +- } +-} +-function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { +- return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; +-} +-function markReachedEdge(ctx) { +- ctx.state.edgeReachedGate = "closed"; +-} +-function prepareReachedEdgeForNextUserScroll(ctx) { +- if (ctx.state.edgeReachedGate) { +- ctx.state.edgeReachedGate = "prepared"; +- } +-} +-function beginReachedEdgeUserScroll(ctx, scrollDelta) { +- const state = ctx.state; +- if (state.edgeReachedGate !== "prepared") { +- return void 0; +- } +- const allowedEdge = scrollDelta < 0 ? "start" : "end"; +- state.edgeReachedGate = "closed"; +- resetEdgeLatch(ctx, allowedEdge); +- return allowedEdge; +-} +- +-// src/utils/hasActiveInitialScroll.ts +-function hasActiveInitialScroll(state) { +- return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; +-} +- +-// src/utils/checkAtBottom.ts +-function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- var _a3; +- const state = ctx.state; +- if (!state) { +- return; +- } +- const { +- queuedInitialLayout, +- scrollLength, +- scroll, +- maintainingScrollAtEnd, +- props: { maintainScrollAtEndThreshold, onEndReachedThreshold } +- } = state; +- const contentSize = getContentSize(ctx); +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (contentSize > 0 && queuedInitialLayout) { +- const insetEnd = getContentInsetEnd(ctx); +- const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; +- const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); +- set$( +- ctx, +- "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength +- ); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; +- if (!shouldSkipThresholdChecks) { +- state.isEndReached = checkThreshold( +- distanceFromEnd, +- isContentLess, +- onEndReachedThreshold * scrollLength, +- state.isEndReached, +- state.endReachedSnapshot, +- { +- contentSize, +- dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a4, _b; +- if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); +- } +- }, +- (snapshot) => { +- state.endReachedSnapshot = snapshot; +- } +- ); +- } +- } +-} +- +-// src/utils/checkAtTop.ts +-function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- const state = ctx == null ? void 0 : ctx.state; +- if (!state) { +- return; +- } +- const { +- isStartReached, +- props: { data, onStartReachedThreshold }, +- scroll, +- scrollLength, +- startReachedSnapshot, +- totalSize +- } = state; +- const dataLength = data.length; +- const threshold = onStartReachedThreshold * scrollLength; +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } +- set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearStart", scroll <= threshold); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; +- if (!shouldSkipThresholdChecks) { +- state.isStartReached = checkThreshold( +- scroll, +- false, +- threshold, +- state.isStartReached, +- startReachedSnapshot, +- { +- contentSize: totalSize, +- dataLength, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a3, _b; +- if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); +- } +- }, +- (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); +-} +-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) { +- 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); +- } +- } +-} +- +-// 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")) { +- 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; +- } +- 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; +- } +- 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 }); +- } +- } +- } +-} +- +-// 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); +- } +- 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 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); +- } +- } +- 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); +- } +- 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; +- } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; +-} +- + // src/core/getStartOffsetAdjustment.ts + function getStartOffsetAdjustment(ctx) { + const { state } = ctx; +@@ -1213,58 +614,273 @@ 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]); ++ 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 ++var HYSTERESIS_MULTIPLIER = 1.3; ++function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { ++ const absDistance = Math.abs(distance); ++ return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; ++} ++var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { ++ const absDistance = Math.abs(distance); ++ const within = atThreshold || threshold > 0 && absDistance <= threshold; ++ const updateSnapshot = () => { ++ setSnapshot({ ++ atThreshold, ++ contentSize: context.contentSize, ++ dataLength: context.dataLength, ++ scrollPosition: context.scrollPosition ++ }); ++ }; ++ if (!wasReached) { ++ if (!within) { ++ return false; ++ } ++ onReached(distance); ++ updateSnapshot(); ++ return true; ++ } ++ const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); ++ if (reset) { ++ setSnapshot(void 0); ++ return false; ++ } ++ if (within) { ++ const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; ++ if (changed) { ++ updateSnapshot(); ++ } ++ } ++ return true; ++}; ++ ++// src/utils/edgeReachedGate.ts ++function resetEdgeLatch(ctx, edge) { ++ const state = ctx.state; ++ if (edge === "start") { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } else { ++ state.isEndReached = false; ++ state.endReachedSnapshot = void 0; ++ } ++} ++function resetSharedEdgeGateIfOutsideHysteresis(ctx) { ++ const state = ctx.state; ++ if (!state.edgeReachedGate) { ++ return; ++ } ++ const contentSize = getContentSize(ctx); ++ const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isContentLess = contentSize < state.scrollLength; ++ const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; ++ const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; ++ const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); ++ const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); ++ if (isOutsideStart && isOutsideEnd) { ++ state.edgeReachedGate = void 0; ++ } ++} ++function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { ++ return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; ++} ++function markReachedEdge(ctx) { ++ ctx.state.edgeReachedGate = "closed"; ++} ++function prepareReachedEdgeForNextUserScroll(ctx) { ++ if (ctx.state.edgeReachedGate) { ++ ctx.state.edgeReachedGate = "prepared"; ++ } ++} ++function beginReachedEdgeUserScroll(ctx, scrollDelta) { ++ const state = ctx.state; ++ if (state.edgeReachedGate !== "prepared") { ++ return void 0; ++ } ++ const allowedEdge = scrollDelta < 0 ? "start" : "end"; ++ state.edgeReachedGate = "closed"; ++ resetEdgeLatch(ctx, allowedEdge); ++ return allowedEdge; ++} ++ ++// src/utils/hasActiveInitialScroll.ts ++function hasActiveInitialScroll(state) { ++ return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; ++} ++ ++// src/utils/checkAtBottom.ts ++function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ var _a3; ++ const state = ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ queuedInitialLayout, ++ scrollLength, ++ scroll, ++ maintainingScrollAtEnd, ++ props: { maintainScrollAtEndThreshold, onEndReachedThreshold } ++ } = state; ++ const contentSize = getContentSize(ctx); ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (contentSize > 0 && queuedInitialLayout) { ++ const insetEnd = getContentInsetEnd(ctx); ++ const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const isContentLess = contentSize < scrollLength; ++ set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); ++ set$( ++ ctx, ++ "isWithinMaintainScrollAtEndThreshold", ++ isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ ); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; ++ if (!shouldSkipThresholdChecks) { ++ state.isEndReached = checkThreshold( ++ distanceFromEnd, ++ isContentLess, ++ onEndReachedThreshold * scrollLength, ++ state.isEndReached, ++ state.endReachedSnapshot, ++ { ++ contentSize, ++ dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a4, _b; ++ if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.endReachedSnapshot = snapshot; ++ } ++ ); ++ } ++ } ++} ++ ++// src/utils/checkAtTop.ts ++function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ const state = ctx == null ? void 0 : ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ isStartReached, ++ props: { data, onStartReachedThreshold }, ++ scroll, ++ scrollLength, ++ startReachedSnapshot, ++ totalSize ++ } = state; ++ const dataLength = data.length; ++ const threshold = onStartReachedThreshold * scrollLength; ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } ++ set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearStart", scroll <= threshold); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; ++ if (!shouldSkipThresholdChecks) { ++ state.isStartReached = checkThreshold( ++ scroll, ++ false, ++ threshold, ++ state.isStartReached, ++ startReachedSnapshot, ++ { ++ contentSize: totalSize, ++ dataLength, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a3, _b; ++ if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.startReachedSnapshot = snapshot; ++ } ++ ); ++ } + } + +-// 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/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { ++ var _a3, _b; + 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); ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; ++ checkThresholds(ctx); + } + + // src/core/finishScrollTo.ts +@@ -1404,6 +1020,181 @@ function listenForScrollEnd(ctx, params) { + scheduledWork.register("platformScrollCompletion", cancel); + } + ++// 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 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 ++ }); ++ } ++ (_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; ++ } ++ 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; ++ } ++ 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); ++ } ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); ++ } ++ 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; ++} ++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) { ++ 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); ++ } ++ } ++} ++ + // src/core/doMaintainScrollAtEnd.ts + function doMaintainScrollAtEnd(ctx) { + const state = ctx.state; +@@ -1430,9 +1221,12 @@ function doMaintainScrollAtEnd(ctx) { + const activeState = maintainScrollAtEnd.animated ? "animated" : "instant"; + const scrollAtRequest = state.scroll; + state.maintainingScrollAtEnd = pendingState; ++ clearScrollTargetSettle(state); + requestAnimationFrame(() => { + const isStillWithinThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); +- const didScrollSinceRequest = state.scroll !== scrollAtRequest; ++ const lastIssued = state.lastIssuedScrollOffset; ++ const isSettlingOntoIssuedScroll = lastIssued !== void 0 && Math.abs(state.scroll - lastIssued) <= EDGE_POSITION_EPSILON; ++ const didScrollSinceRequest = state.scroll !== scrollAtRequest && !isSettlingOntoIssuedScroll; + if (isStillWithinThreshold || !didScrollSinceRequest) { + state.maintainingScrollAtEnd = activeState; + const scroller = refScroller.current; +@@ -1788,6 +1582,27 @@ function prepareMVCP(ctx, dataChanged) { + } + } + ++// 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")) { ++ return; ++ } ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ + // src/utils/getScrollVelocity.ts + var MAX_SCROLL_VELOCITY_WINDOW_MS = 1e3; + var SCROLL_VELOCITY_HALF_LIFE_MS = 200; +@@ -2048,7 +1863,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 { +@@ -2070,6 +1885,7 @@ function scrollTo(ctx, params) { + if (!noScrollingTo) { + if (isInitialScroll) { + initialScrollCompletion.resetFlags(state); ++ clearScrollTargetSettle(state); + } + const averageSizeSnapshot = getAverageSizeSnapshot(state); + state.scrollingTo = { +@@ -2080,6 +1896,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (!animated && 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; +@@ -2087,7 +1912,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 }); +@@ -2100,6 +1925,316 @@ function scrollTo(ctx, params) { + } + } + ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 1; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_MAX_MS = 1e3; ++var SETTLE_MAX_CORRECTIONS = 8; ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++ state.scheduledWork.cancel("scrollTargetSettle"); ++ state.scheduledWork.cancel("scrollTargetSettleDeadline"); ++} ++function beginScrollTargetSettle(ctx, params) { ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ const { alignItemsAtEnd, data, maintainScrollAtEnd } = state.props; ++ const hasEndAnchoring = !!maintainScrollAtEnd || !!alignItemsAtEnd; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem && hasEndAnchoring) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ clearScrollTargetSettle(state); ++ const now = Date.now(); ++ state.scrollTargetSettle = { ++ corrections: 0, ++ deadline: now + SETTLE_MAX_MS, ++ id: getId(state, index), ++ measuredIndex: void 0, ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++ state.scheduledWork.timeout(() => clearScrollTargetSettle(state), SETTLE_MAX_MS, "scrollTargetSettleDeadline"); ++} ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function applyScrollTargetCorrection(ctx, id) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle || settle.id !== id) { ++ return; ++ } ++ const index = state.indexByKey.get(id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0 || Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ if (settle.corrections >= SETTLE_MAX_CORRECTIONS) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ settle.corrections++; ++ settle.measuredIndex = void 0; ++ scrollTo(ctx, { ++ animated: false, ++ index, ++ itemSize: getItemSizeAtIndex(ctx, index), ++ // Correcting where a scroll already landed is not a new imperative scroll: claiming the ++ // session would suppress the list's own handling of subsequent scroll events and re-arm ++ // this settle from inside its own correction. ++ noScrollingTo: true, ++ offset: position, ++ viewOffset: settle.viewOffset, ++ viewPosition: settle.viewPosition ++ }); ++ const scrollingTo = state.scrollingTo; ++ if (scrollingTo) { ++ scrollingTo.targetOffset = state.scrollPending; ++ scrollingTo.offset = position; ++ } ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++} ++function settleScrollTarget(ctx, options) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const measured = options == null ? void 0 : options.minIndexSizeChanged; ++ if (measured !== void 0) { ++ settle.measuredIndex = Math.min((_a3 = settle.measuredIndex) != null ? _a3 : Number.POSITIVE_INFINITY, measured); ++ } ++ if (options == null ? void 0 : options.isCompensating) { ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ settle.quietPasses = 0; ++ 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; ++ } ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ if (settle.measuredIndex === void 0 || settle.measuredIndex > index) { ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ if (Math.abs(targetOffset - state.scroll) <= SETTLE_POSITION_EPSILON) { ++ settle.measuredIndex = void 0; ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ state.scheduledWork.frame(() => applyScrollTargetCorrection(ctx, settle.id), "scrollTargetSettle"); ++ return true; ++} ++ ++// 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 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 ++ ) ++ ); ++ } ++} ++ ++// 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; ++ } ++ 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; ++ } ++ 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 }); ++ } ++ } ++ } ++} ++ ++// 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); ++ } ++ 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 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); ++ } ++ } ++ 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); ++ } ++ 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; ++ } ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; ++} ++ + // src/core/scrollToIndex.ts + function clampScrollIndex(index, dataLength) { + if (dataLength <= 0) { +@@ -4329,6 +4464,7 @@ function calculateItemsInView(ctx, params = {}) { + startIndex + }); + totalSize = getContentSize(ctx); ++ const minIndexSizeChangedThisPass = minIndexSizeChanged; + if (minIndexSizeChanged !== void 0) { + state.minIndexSizeChanged = void 0; + } +@@ -4346,8 +4482,18 @@ 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); +- if (didMVCPAdjustScroll) { ++ const scrollAdjustPendingAfterMVCP = (_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0; ++ const didMVCPAdjust = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || scrollAdjustPendingAfterMVCP !== scrollAdjustPendingBeforeMVCP); ++ const mvcp = state.props.maintainVisibleContentPosition; ++ const isUnanchoredDataChange = dataChanged && !mvcp.data; ++ const isCompensating = didMVCPAdjust || scrollAdjustPendingAfterMVCP !== 0 || !!state.pendingNativeMVCPAdjust; ++ if (!suppressInitialScrollSideEffects) { ++ settleScrollTarget(ctx, { ++ isCompensating, ++ minIndexSizeChanged: isUnanchoredDataChange ? 0 : minIndexSizeChangedThisPass ++ }); ++ } ++ if (didMVCPAdjust) { + updateScroll2(state.scroll); + updateScrollRange(); + } +@@ -5985,6 +6131,8 @@ function resolveWindowScrollTarget({ clampedOffset, horizontal, listPos, scroll + // src/components/ListComponentScrollView.tsx + var SCROLLBAR_HIDDEN_STYLE_ID = "legend-list-scrollbar-axis-hidden-style"; + var SCROLL_END_FALLBACK_MS = 200; ++var SCROLL_EXTENT_EPSILON = 1; ++var REACHABLE_RETRY_FRAMES = 30; + var SCROLLBAR_HIDDEN_STYLE = `.${LEGEND_LIST_SCROLLBAR_Y_HIDDEN_CLASS}::-webkit-scrollbar:vertical{width:0;display:none;}.${LEGEND_LIST_SCROLLBAR_X_HIDDEN_CLASS}::-webkit-scrollbar:horizontal{height:0;display:none;}`; + function ensureScrollbarHiddenStyle() { + if (typeof document === "undefined" || document.getElementById(SCROLLBAR_HIDDEN_STYLE_ID)) { +@@ -6073,6 +6221,26 @@ var ListComponentScrollView = forwardRef(function ListComponentScrollView2({ + } + return horizontal ? scrollElement.scrollLeft : scrollElement.scrollTop; + }, [getMaxScrollOffset, horizontal, isWindowScroll]); ++ const reissueHandleRef = useRef(0); ++ const scrollUntilReachable = useCallback( ++ (offset, animated, run) => { ++ cancelAnimationFrame(reissueHandleRef.current); ++ let attempts = 0; ++ let previousMaxOffset = Number.NEGATIVE_INFINITY; ++ const attempt = () => { ++ const liveMaxOffset = getMaxScrollOffset(); ++ run(clampOffset(offset, liveMaxOffset)); ++ const isStillCommitting = liveMaxOffset > previousMaxOffset; ++ previousMaxOffset = liveMaxOffset; ++ if (!animated && isStillCommitting && Number.isFinite(offset) && offset - liveMaxOffset > SCROLL_EXTENT_EPSILON && ++attempts < REACHABLE_RETRY_FRAMES) { ++ reissueHandleRef.current = requestAnimationFrame(attempt); ++ } ++ }; ++ attempt(); ++ }, ++ [getMaxScrollOffset] ++ ); ++ useEffect(() => () => cancelAnimationFrame(reissueHandleRef.current), []); + const scrollToLocalOffset = useCallback( + (offset, animated) => { + const scrollElement = scrollRef.current; +@@ -6080,29 +6248,34 @@ var ListComponentScrollView = forwardRef(function ListComponentScrollView2({ + if (!target || typeof target.scrollTo !== "function") { + return; + } +- const maxOffset = getMaxScrollOffset(); +- const clampedOffset = clampOffset(offset, maxOffset); + const behavior = animated ? "smooth" : "auto"; + const options = { behavior }; + if (isWindowScroll) { + const scroll = getWindowScrollPosition(); + const listPos = getElementDocumentPosition(scrollElement, scroll); + const { left, top } = resolveWindowScrollTarget({ +- clampedOffset, ++ clampedOffset: clampOffset(offset, getMaxScrollOffset()), + horizontal, + listPos, + scroll + }); + options.left = left; + options.top = top; +- } else if (horizontal) { +- options.left = clampedOffset; +- } else { +- options.top = clampedOffset; ++ ctx.state.lastIssuedScrollOffset = clampOffset(offset, getMaxScrollOffset()); ++ target.scrollTo(options); ++ return; + } +- target.scrollTo(options); ++ scrollUntilReachable(offset, animated, (reachableOffset) => { ++ if (horizontal) { ++ options.left = reachableOffset; ++ } else { ++ options.top = reachableOffset; ++ } ++ ctx.state.lastIssuedScrollOffset = reachableOffset; ++ target.scrollTo(options); ++ }); + }, +- [getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll] ++ [ctx, getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll, scrollUntilReachable] + ); + useImperativeHandle(ref, () => { + const api = { +@@ -6128,8 +6301,7 @@ var ListComponentScrollView = forwardRef(function ListComponentScrollView2({ + }, + scrollToEnd: (options = {}) => { + const { animated = true } = options; +- const endOffset = getMaxScrollOffset(); +- scrollToLocalOffset(endOffset, animated); ++ scrollToLocalOffset(getMaxScrollOffset(), animated); + }, + scrollToOffset: (params) => { + const { offset, animated = true } = params; +@@ -6478,6 +6650,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; +@@ -6487,6 +6660,8 @@ 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) { ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7549,13 +7724,14 @@ function getRenderedItem(ctx, key) { + + // 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) { +@@ -8267,6 +8443,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScrollBeginDrag: (event) => { + var _a4, _b2; + prepareReachedEdgeForNextUserScroll(ctx); ++ clearScrollTargetSettle(state); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, + onScrollEnd: () => prepareReachedEdgeForNextUserScroll(ctx) +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 914d2da..ccb19f4 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -434,605 +434,6 @@ 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(); +-} +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- 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 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 +- ) +- ); +- } +-} +- +-// 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 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 +- }); +- } +- (_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; +- } +- 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; +- } +- 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); +- } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); +- } +- 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/utils/checkThreshold.ts +-var HYSTERESIS_MULTIPLIER = 1.3; +-function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { +- const absDistance = Math.abs(distance); +- return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; +-} +-var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { +- const absDistance = Math.abs(distance); +- const within = atThreshold || threshold > 0 && absDistance <= threshold; +- const updateSnapshot = () => { +- setSnapshot({ +- atThreshold, +- contentSize: context.contentSize, +- dataLength: context.dataLength, +- scrollPosition: context.scrollPosition +- }); +- }; +- if (!wasReached) { +- if (!within) { +- return false; +- } +- onReached(distance); +- updateSnapshot(); +- return true; +- } +- const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); +- if (reset) { +- setSnapshot(void 0); +- return false; +- } +- if (within) { +- const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; +- if (changed) { +- updateSnapshot(); +- } +- } +- return true; +-}; +- +-// src/utils/edgeReachedGate.ts +-function resetEdgeLatch(ctx, edge) { +- const state = ctx.state; +- if (edge === "start") { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } else { +- state.isEndReached = false; +- state.endReachedSnapshot = void 0; +- } +-} +-function resetSharedEdgeGateIfOutsideHysteresis(ctx) { +- const state = ctx.state; +- if (!state.edgeReachedGate) { +- return; +- } +- const contentSize = getContentSize(ctx); +- const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); +- const isContentLess = contentSize < state.scrollLength; +- const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; +- const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; +- const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); +- const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); +- if (isOutsideStart && isOutsideEnd) { +- state.edgeReachedGate = void 0; +- } +-} +-function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { +- return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; +-} +-function markReachedEdge(ctx) { +- ctx.state.edgeReachedGate = "closed"; +-} +-function prepareReachedEdgeForNextUserScroll(ctx) { +- if (ctx.state.edgeReachedGate) { +- ctx.state.edgeReachedGate = "prepared"; +- } +-} +-function beginReachedEdgeUserScroll(ctx, scrollDelta) { +- const state = ctx.state; +- if (state.edgeReachedGate !== "prepared") { +- return void 0; +- } +- const allowedEdge = scrollDelta < 0 ? "start" : "end"; +- state.edgeReachedGate = "closed"; +- resetEdgeLatch(ctx, allowedEdge); +- return allowedEdge; +-} +- +-// src/utils/hasActiveInitialScroll.ts +-function hasActiveInitialScroll(state) { +- return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; +-} +- +-// src/utils/checkAtBottom.ts +-function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- var _a3; +- const state = ctx.state; +- if (!state) { +- return; +- } +- const { +- queuedInitialLayout, +- scrollLength, +- scroll, +- maintainingScrollAtEnd, +- props: { maintainScrollAtEndThreshold, onEndReachedThreshold } +- } = state; +- const contentSize = getContentSize(ctx); +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (contentSize > 0 && queuedInitialLayout) { +- const insetEnd = getContentInsetEnd(ctx); +- const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; +- const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); +- set$( +- ctx, +- "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength +- ); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; +- if (!shouldSkipThresholdChecks) { +- state.isEndReached = checkThreshold( +- distanceFromEnd, +- isContentLess, +- onEndReachedThreshold * scrollLength, +- state.isEndReached, +- state.endReachedSnapshot, +- { +- contentSize, +- dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a4, _b; +- if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); +- } +- }, +- (snapshot) => { +- state.endReachedSnapshot = snapshot; +- } +- ); +- } +- } +-} +- +-// src/utils/checkAtTop.ts +-function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- const state = ctx == null ? void 0 : ctx.state; +- if (!state) { +- return; +- } +- const { +- isStartReached, +- props: { data, onStartReachedThreshold }, +- scroll, +- scrollLength, +- startReachedSnapshot, +- totalSize +- } = state; +- const dataLength = data.length; +- const threshold = onStartReachedThreshold * scrollLength; +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } +- set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearStart", scroll <= threshold); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; +- if (!shouldSkipThresholdChecks) { +- state.isStartReached = checkThreshold( +- scroll, +- false, +- threshold, +- state.isStartReached, +- startReachedSnapshot, +- { +- contentSize: totalSize, +- dataLength, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a3, _b; +- if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); +- } +- }, +- (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); +-} +-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) { +- 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); +- } +- } +-} +- +-// 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")) { +- 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; +- } +- 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; +- } +- 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 }); +- } +- } +- } +-} +- +-// 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); +- } +- 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 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); +- } +- } +- 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); +- } +- 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; +- } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; +-} +- + // src/core/getStartOffsetAdjustment.ts + function getStartOffsetAdjustment(ctx) { + const { state } = ctx; +@@ -1234,58 +635,273 @@ 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]); ++ 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 ++var HYSTERESIS_MULTIPLIER = 1.3; ++function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { ++ const absDistance = Math.abs(distance); ++ return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; ++} ++var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { ++ const absDistance = Math.abs(distance); ++ const within = atThreshold || threshold > 0 && absDistance <= threshold; ++ const updateSnapshot = () => { ++ setSnapshot({ ++ atThreshold, ++ contentSize: context.contentSize, ++ dataLength: context.dataLength, ++ scrollPosition: context.scrollPosition ++ }); ++ }; ++ if (!wasReached) { ++ if (!within) { ++ return false; ++ } ++ onReached(distance); ++ updateSnapshot(); ++ return true; ++ } ++ const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); ++ if (reset) { ++ setSnapshot(void 0); ++ return false; ++ } ++ if (within) { ++ const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; ++ if (changed) { ++ updateSnapshot(); ++ } ++ } ++ return true; ++}; ++ ++// src/utils/edgeReachedGate.ts ++function resetEdgeLatch(ctx, edge) { ++ const state = ctx.state; ++ if (edge === "start") { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } else { ++ state.isEndReached = false; ++ state.endReachedSnapshot = void 0; ++ } ++} ++function resetSharedEdgeGateIfOutsideHysteresis(ctx) { ++ const state = ctx.state; ++ if (!state.edgeReachedGate) { ++ return; ++ } ++ const contentSize = getContentSize(ctx); ++ const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isContentLess = contentSize < state.scrollLength; ++ const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; ++ const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; ++ const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); ++ const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); ++ if (isOutsideStart && isOutsideEnd) { ++ state.edgeReachedGate = void 0; ++ } ++} ++function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { ++ return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; ++} ++function markReachedEdge(ctx) { ++ ctx.state.edgeReachedGate = "closed"; ++} ++function prepareReachedEdgeForNextUserScroll(ctx) { ++ if (ctx.state.edgeReachedGate) { ++ ctx.state.edgeReachedGate = "prepared"; ++ } ++} ++function beginReachedEdgeUserScroll(ctx, scrollDelta) { ++ const state = ctx.state; ++ if (state.edgeReachedGate !== "prepared") { ++ return void 0; ++ } ++ const allowedEdge = scrollDelta < 0 ? "start" : "end"; ++ state.edgeReachedGate = "closed"; ++ resetEdgeLatch(ctx, allowedEdge); ++ return allowedEdge; ++} ++ ++// src/utils/hasActiveInitialScroll.ts ++function hasActiveInitialScroll(state) { ++ return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; ++} ++ ++// src/utils/checkAtBottom.ts ++function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ var _a3; ++ const state = ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ queuedInitialLayout, ++ scrollLength, ++ scroll, ++ maintainingScrollAtEnd, ++ props: { maintainScrollAtEndThreshold, onEndReachedThreshold } ++ } = state; ++ const contentSize = getContentSize(ctx); ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (contentSize > 0 && queuedInitialLayout) { ++ const insetEnd = getContentInsetEnd(ctx); ++ const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const isContentLess = contentSize < scrollLength; ++ set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); ++ set$( ++ ctx, ++ "isWithinMaintainScrollAtEndThreshold", ++ isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ ); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; ++ if (!shouldSkipThresholdChecks) { ++ state.isEndReached = checkThreshold( ++ distanceFromEnd, ++ isContentLess, ++ onEndReachedThreshold * scrollLength, ++ state.isEndReached, ++ state.endReachedSnapshot, ++ { ++ contentSize, ++ dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a4, _b; ++ if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.endReachedSnapshot = snapshot; ++ } ++ ); ++ } ++ } ++} ++ ++// src/utils/checkAtTop.ts ++function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ const state = ctx == null ? void 0 : ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ isStartReached, ++ props: { data, onStartReachedThreshold }, ++ scroll, ++ scrollLength, ++ startReachedSnapshot, ++ totalSize ++ } = state; ++ const dataLength = data.length; ++ const threshold = onStartReachedThreshold * scrollLength; ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } ++ set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearStart", scroll <= threshold); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; ++ if (!shouldSkipThresholdChecks) { ++ state.isStartReached = checkThreshold( ++ scroll, ++ false, ++ threshold, ++ state.isStartReached, ++ startReachedSnapshot, ++ { ++ contentSize: totalSize, ++ dataLength, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a3, _b; ++ if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.startReachedSnapshot = snapshot; ++ } ++ ); ++ } + } + +-// 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/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { ++ var _a3, _b; + 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); ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; ++ checkThresholds(ctx); + } + + // src/core/finishScrollTo.ts +@@ -1425,6 +1041,181 @@ function listenForScrollEnd(ctx, params) { + scheduledWork.register("platformScrollCompletion", cancel); + } + ++// 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 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 ++ }); ++ } ++ (_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; ++ } ++ 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; ++ } ++ 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); ++ } ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); ++ } ++ 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; ++} ++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) { ++ 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); ++ } ++ } ++} ++ + // src/core/doMaintainScrollAtEnd.ts + function doMaintainScrollAtEnd(ctx) { + const state = ctx.state; +@@ -1451,9 +1242,12 @@ function doMaintainScrollAtEnd(ctx) { + const activeState = maintainScrollAtEnd.animated ? "animated" : "instant"; + const scrollAtRequest = state.scroll; + state.maintainingScrollAtEnd = pendingState; ++ clearScrollTargetSettle(state); + requestAnimationFrame(() => { + const isStillWithinThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); +- const didScrollSinceRequest = state.scroll !== scrollAtRequest; ++ const lastIssued = state.lastIssuedScrollOffset; ++ const isSettlingOntoIssuedScroll = lastIssued !== void 0 && Math.abs(state.scroll - lastIssued) <= EDGE_POSITION_EPSILON; ++ const didScrollSinceRequest = state.scroll !== scrollAtRequest && !isSettlingOntoIssuedScroll; + if (isStillWithinThreshold || !didScrollSinceRequest) { + state.maintainingScrollAtEnd = activeState; + const scroller = refScroller.current; +@@ -1809,6 +1603,27 @@ function prepareMVCP(ctx, dataChanged) { + } + } + ++// 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")) { ++ return; ++ } ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ + // src/utils/getScrollVelocity.ts + var MAX_SCROLL_VELOCITY_WINDOW_MS = 1e3; + var SCROLL_VELOCITY_HALF_LIFE_MS = 200; +@@ -2069,7 +1884,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 { +@@ -2091,6 +1906,7 @@ function scrollTo(ctx, params) { + if (!noScrollingTo) { + if (isInitialScroll) { + initialScrollCompletion.resetFlags(state); ++ clearScrollTargetSettle(state); + } + const averageSizeSnapshot = getAverageSizeSnapshot(state); + state.scrollingTo = { +@@ -2101,6 +1917,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (!animated && 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; +@@ -2108,7 +1933,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 }); +@@ -2121,6 +1946,316 @@ function scrollTo(ctx, params) { + } + } + ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 1; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_MAX_MS = 1e3; ++var SETTLE_MAX_CORRECTIONS = 8; ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++ state.scheduledWork.cancel("scrollTargetSettle"); ++ state.scheduledWork.cancel("scrollTargetSettleDeadline"); ++} ++function beginScrollTargetSettle(ctx, params) { ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ const { alignItemsAtEnd, data, maintainScrollAtEnd } = state.props; ++ const hasEndAnchoring = !!maintainScrollAtEnd || !!alignItemsAtEnd; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem && hasEndAnchoring) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ clearScrollTargetSettle(state); ++ const now = Date.now(); ++ state.scrollTargetSettle = { ++ corrections: 0, ++ deadline: now + SETTLE_MAX_MS, ++ id: getId(state, index), ++ measuredIndex: void 0, ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++ state.scheduledWork.timeout(() => clearScrollTargetSettle(state), SETTLE_MAX_MS, "scrollTargetSettleDeadline"); ++} ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function applyScrollTargetCorrection(ctx, id) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle || settle.id !== id) { ++ return; ++ } ++ const index = state.indexByKey.get(id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0 || Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ if (settle.corrections >= SETTLE_MAX_CORRECTIONS) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ settle.corrections++; ++ settle.measuredIndex = void 0; ++ scrollTo(ctx, { ++ animated: false, ++ index, ++ itemSize: getItemSizeAtIndex(ctx, index), ++ // Correcting where a scroll already landed is not a new imperative scroll: claiming the ++ // session would suppress the list's own handling of subsequent scroll events and re-arm ++ // this settle from inside its own correction. ++ noScrollingTo: true, ++ offset: position, ++ viewOffset: settle.viewOffset, ++ viewPosition: settle.viewPosition ++ }); ++ const scrollingTo = state.scrollingTo; ++ if (scrollingTo) { ++ scrollingTo.targetOffset = state.scrollPending; ++ scrollingTo.offset = position; ++ } ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++} ++function settleScrollTarget(ctx, options) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const measured = options == null ? void 0 : options.minIndexSizeChanged; ++ if (measured !== void 0) { ++ settle.measuredIndex = Math.min((_a3 = settle.measuredIndex) != null ? _a3 : Number.POSITIVE_INFINITY, measured); ++ } ++ if (options == null ? void 0 : options.isCompensating) { ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ settle.quietPasses = 0; ++ 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; ++ } ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ if (settle.measuredIndex === void 0 || settle.measuredIndex > index) { ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ if (Math.abs(targetOffset - state.scroll) <= SETTLE_POSITION_EPSILON) { ++ settle.measuredIndex = void 0; ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ state.scheduledWork.frame(() => applyScrollTargetCorrection(ctx, settle.id), "scrollTargetSettle"); ++ return true; ++} ++ ++// 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 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 ++ ) ++ ); ++ } ++} ++ ++// 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; ++ } ++ 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; ++ } ++ 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 }); ++ } ++ } ++ } ++} ++ ++// 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); ++ } ++ 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 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); ++ } ++ } ++ 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); ++ } ++ 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; ++ } ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; ++} ++ + // src/core/scrollToIndex.ts + function clampScrollIndex(index, dataLength) { + if (dataLength <= 0) { +@@ -4350,6 +4485,7 @@ function calculateItemsInView(ctx, params = {}) { + startIndex + }); + totalSize = getContentSize(ctx); ++ const minIndexSizeChangedThisPass = minIndexSizeChanged; + if (minIndexSizeChanged !== void 0) { + state.minIndexSizeChanged = void 0; + } +@@ -4367,8 +4503,18 @@ 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); +- if (didMVCPAdjustScroll) { ++ const scrollAdjustPendingAfterMVCP = (_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0; ++ const didMVCPAdjust = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || scrollAdjustPendingAfterMVCP !== scrollAdjustPendingBeforeMVCP); ++ const mvcp = state.props.maintainVisibleContentPosition; ++ const isUnanchoredDataChange = dataChanged && !mvcp.data; ++ const isCompensating = didMVCPAdjust || scrollAdjustPendingAfterMVCP !== 0 || !!state.pendingNativeMVCPAdjust; ++ if (!suppressInitialScrollSideEffects) { ++ settleScrollTarget(ctx, { ++ isCompensating, ++ minIndexSizeChanged: isUnanchoredDataChange ? 0 : minIndexSizeChangedThisPass ++ }); ++ } ++ if (didMVCPAdjust) { + updateScroll2(state.scroll); + updateScrollRange(); + } +@@ -6006,6 +6152,8 @@ function resolveWindowScrollTarget({ clampedOffset, horizontal, listPos, scroll + // src/components/ListComponentScrollView.tsx + var SCROLLBAR_HIDDEN_STYLE_ID = "legend-list-scrollbar-axis-hidden-style"; + var SCROLL_END_FALLBACK_MS = 200; ++var SCROLL_EXTENT_EPSILON = 1; ++var REACHABLE_RETRY_FRAMES = 30; + var SCROLLBAR_HIDDEN_STYLE = `.${LEGEND_LIST_SCROLLBAR_Y_HIDDEN_CLASS}::-webkit-scrollbar:vertical{width:0;display:none;}.${LEGEND_LIST_SCROLLBAR_X_HIDDEN_CLASS}::-webkit-scrollbar:horizontal{height:0;display:none;}`; + function ensureScrollbarHiddenStyle() { + if (typeof document === "undefined" || document.getElementById(SCROLLBAR_HIDDEN_STYLE_ID)) { +@@ -6094,6 +6242,26 @@ var ListComponentScrollView = React3.forwardRef(function ListComponentScrollView + } + return horizontal ? scrollElement.scrollLeft : scrollElement.scrollTop; + }, [getMaxScrollOffset, horizontal, isWindowScroll]); ++ const reissueHandleRef = React3.useRef(0); ++ const scrollUntilReachable = React3.useCallback( ++ (offset, animated, run) => { ++ cancelAnimationFrame(reissueHandleRef.current); ++ let attempts = 0; ++ let previousMaxOffset = Number.NEGATIVE_INFINITY; ++ const attempt = () => { ++ const liveMaxOffset = getMaxScrollOffset(); ++ run(clampOffset(offset, liveMaxOffset)); ++ const isStillCommitting = liveMaxOffset > previousMaxOffset; ++ previousMaxOffset = liveMaxOffset; ++ if (!animated && isStillCommitting && Number.isFinite(offset) && offset - liveMaxOffset > SCROLL_EXTENT_EPSILON && ++attempts < REACHABLE_RETRY_FRAMES) { ++ reissueHandleRef.current = requestAnimationFrame(attempt); ++ } ++ }; ++ attempt(); ++ }, ++ [getMaxScrollOffset] ++ ); ++ React3.useEffect(() => () => cancelAnimationFrame(reissueHandleRef.current), []); + const scrollToLocalOffset = React3.useCallback( + (offset, animated) => { + const scrollElement = scrollRef.current; +@@ -6101,29 +6269,34 @@ var ListComponentScrollView = React3.forwardRef(function ListComponentScrollView + if (!target || typeof target.scrollTo !== "function") { + return; + } +- const maxOffset = getMaxScrollOffset(); +- const clampedOffset = clampOffset(offset, maxOffset); + const behavior = animated ? "smooth" : "auto"; + const options = { behavior }; + if (isWindowScroll) { + const scroll = getWindowScrollPosition(); + const listPos = getElementDocumentPosition(scrollElement, scroll); + const { left, top } = resolveWindowScrollTarget({ +- clampedOffset, ++ clampedOffset: clampOffset(offset, getMaxScrollOffset()), + horizontal, + listPos, + scroll + }); + options.left = left; + options.top = top; +- } else if (horizontal) { +- options.left = clampedOffset; +- } else { +- options.top = clampedOffset; ++ ctx.state.lastIssuedScrollOffset = clampOffset(offset, getMaxScrollOffset()); ++ target.scrollTo(options); ++ return; + } +- target.scrollTo(options); ++ scrollUntilReachable(offset, animated, (reachableOffset) => { ++ if (horizontal) { ++ options.left = reachableOffset; ++ } else { ++ options.top = reachableOffset; ++ } ++ ctx.state.lastIssuedScrollOffset = reachableOffset; ++ target.scrollTo(options); ++ }); + }, +- [getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll] ++ [ctx, getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll, scrollUntilReachable] + ); + React3.useImperativeHandle(ref, () => { + const api = { +@@ -6149,8 +6322,7 @@ var ListComponentScrollView = React3.forwardRef(function ListComponentScrollView + }, + scrollToEnd: (options = {}) => { + const { animated = true } = options; +- const endOffset = getMaxScrollOffset(); +- scrollToLocalOffset(endOffset, animated); ++ scrollToLocalOffset(getMaxScrollOffset(), animated); + }, + scrollToOffset: (params) => { + const { offset, animated = true } = params; +@@ -6499,6 +6671,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; +@@ -6508,6 +6681,8 @@ 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) { ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7570,13 +7745,14 @@ function getRenderedItem(ctx, key) { + + // 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) { +@@ -8288,6 +8464,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScrollBeginDrag: (event) => { + var _a4, _b2; + prepareReachedEdgeForNextUserScroll(ctx); ++ clearScrollTargetSettle(state); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, + onScrollEnd: () => prepareReachedEdgeForNextUserScroll(ctx) +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 95465f2..4fb1e41 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -413,605 +413,6 @@ 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(); +-} +-function cancelImperativeScroll(state) { +- cancelScrollCompletionChecks(state); +- state.scheduledWork.cancel("imperativeScrollReady"); +- state.scrollingTo = void 0; +- state.scrollTargetPinnedRange = void 0; +- 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 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 +- ) +- ); +- } +-} +- +-// 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 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 +- }); +- } +- (_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; +- } +- 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; +- } +- 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); +- } +- if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { +- return clearInitialScrollSession(state); +- } +- 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/utils/checkThreshold.ts +-var HYSTERESIS_MULTIPLIER = 1.3; +-function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { +- const absDistance = Math.abs(distance); +- return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; +-} +-var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { +- const absDistance = Math.abs(distance); +- const within = atThreshold || threshold > 0 && absDistance <= threshold; +- const updateSnapshot = () => { +- setSnapshot({ +- atThreshold, +- contentSize: context.contentSize, +- dataLength: context.dataLength, +- scrollPosition: context.scrollPosition +- }); +- }; +- if (!wasReached) { +- if (!within) { +- return false; +- } +- onReached(distance); +- updateSnapshot(); +- return true; +- } +- const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); +- if (reset) { +- setSnapshot(void 0); +- return false; +- } +- if (within) { +- const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; +- if (changed) { +- updateSnapshot(); +- } +- } +- return true; +-}; +- +-// src/utils/edgeReachedGate.ts +-function resetEdgeLatch(ctx, edge) { +- const state = ctx.state; +- if (edge === "start") { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } else { +- state.isEndReached = false; +- state.endReachedSnapshot = void 0; +- } +-} +-function resetSharedEdgeGateIfOutsideHysteresis(ctx) { +- const state = ctx.state; +- if (!state.edgeReachedGate) { +- return; +- } +- const contentSize = getContentSize(ctx); +- const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); +- const isContentLess = contentSize < state.scrollLength; +- const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; +- const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; +- const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); +- const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); +- if (isOutsideStart && isOutsideEnd) { +- state.edgeReachedGate = void 0; +- } +-} +-function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { +- return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; +-} +-function markReachedEdge(ctx) { +- ctx.state.edgeReachedGate = "closed"; +-} +-function prepareReachedEdgeForNextUserScroll(ctx) { +- if (ctx.state.edgeReachedGate) { +- ctx.state.edgeReachedGate = "prepared"; +- } +-} +-function beginReachedEdgeUserScroll(ctx, scrollDelta) { +- const state = ctx.state; +- if (state.edgeReachedGate !== "prepared") { +- return void 0; +- } +- const allowedEdge = scrollDelta < 0 ? "start" : "end"; +- state.edgeReachedGate = "closed"; +- resetEdgeLatch(ctx, allowedEdge); +- return allowedEdge; +-} +- +-// src/utils/hasActiveInitialScroll.ts +-function hasActiveInitialScroll(state) { +- return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; +-} +- +-// src/utils/checkAtBottom.ts +-function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- var _a3; +- const state = ctx.state; +- if (!state) { +- return; +- } +- const { +- queuedInitialLayout, +- scrollLength, +- scroll, +- maintainingScrollAtEnd, +- props: { maintainScrollAtEndThreshold, onEndReachedThreshold } +- } = state; +- const contentSize = getContentSize(ctx); +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (contentSize > 0 && queuedInitialLayout) { +- const insetEnd = getContentInsetEnd(ctx); +- const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; +- const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); +- set$( +- ctx, +- "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength +- ); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; +- if (!shouldSkipThresholdChecks) { +- state.isEndReached = checkThreshold( +- distanceFromEnd, +- isContentLess, +- onEndReachedThreshold * scrollLength, +- state.isEndReached, +- state.endReachedSnapshot, +- { +- contentSize, +- dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a4, _b; +- if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); +- } +- }, +- (snapshot) => { +- state.endReachedSnapshot = snapshot; +- } +- ); +- } +- } +-} +- +-// src/utils/checkAtTop.ts +-function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { +- const state = ctx == null ? void 0 : ctx.state; +- if (!state) { +- return; +- } +- const { +- isStartReached, +- props: { data, onStartReachedThreshold }, +- scroll, +- scrollLength, +- startReachedSnapshot, +- totalSize +- } = state; +- const dataLength = data.length; +- const threshold = onStartReachedThreshold * scrollLength; +- resetSharedEdgeGateIfOutsideHysteresis(ctx); +- if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { +- state.isStartReached = false; +- state.startReachedSnapshot = void 0; +- } +- set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); +- set$(ctx, "isNearStart", scroll <= threshold); +- const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; +- if (!shouldSkipThresholdChecks) { +- state.isStartReached = checkThreshold( +- scroll, +- false, +- threshold, +- state.isStartReached, +- startReachedSnapshot, +- { +- contentSize: totalSize, +- dataLength, +- scrollPosition: scroll +- }, +- (distance) => { +- var _a3, _b; +- if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { +- markReachedEdge(ctx); +- (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); +- } +- }, +- (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); +-} +-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) { +- 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); +- } +- } +-} +- +-// 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")) { +- 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; +- } +- 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; +- } +- 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 }); +- } +- } +- } +-} +- +-// 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); +- } +- 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 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); +- } +- } +- 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); +- } +- 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; +- } +- complete(); +-} +- +-// src/core/calculateOffsetForIndex.ts +-function calculateOffsetForIndex(ctx, index) { +- const state = ctx.state; +- return index !== void 0 ? state.positions[index] || 0 : 0; +-} +- + // src/core/getStartOffsetAdjustment.ts + function getStartOffsetAdjustment(ctx) { + const { state } = ctx; +@@ -1213,58 +614,273 @@ 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]); ++ 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 ++var HYSTERESIS_MULTIPLIER = 1.3; ++function isOutsideThresholdHysteresis(distance, atThreshold, threshold) { ++ const absDistance = Math.abs(distance); ++ return !atThreshold && threshold > 0 && absDistance >= threshold * HYSTERESIS_MULTIPLIER || !atThreshold && threshold <= 0 && absDistance > 0; ++} ++var checkThreshold = (distance, atThreshold, threshold, wasReached, snapshot, context, onReached, setSnapshot) => { ++ const absDistance = Math.abs(distance); ++ const within = atThreshold || threshold > 0 && absDistance <= threshold; ++ const updateSnapshot = () => { ++ setSnapshot({ ++ atThreshold, ++ contentSize: context.contentSize, ++ dataLength: context.dataLength, ++ scrollPosition: context.scrollPosition ++ }); ++ }; ++ if (!wasReached) { ++ if (!within) { ++ return false; ++ } ++ onReached(distance); ++ updateSnapshot(); ++ return true; ++ } ++ const reset = isOutsideThresholdHysteresis(distance, atThreshold, threshold); ++ if (reset) { ++ setSnapshot(void 0); ++ return false; ++ } ++ if (within) { ++ const changed = !snapshot || snapshot.atThreshold !== atThreshold || snapshot.contentSize !== context.contentSize || snapshot.dataLength !== context.dataLength; ++ if (changed) { ++ updateSnapshot(); ++ } ++ } ++ return true; ++}; ++ ++// src/utils/edgeReachedGate.ts ++function resetEdgeLatch(ctx, edge) { ++ const state = ctx.state; ++ if (edge === "start") { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } else { ++ state.isEndReached = false; ++ state.endReachedSnapshot = void 0; ++ } ++} ++function resetSharedEdgeGateIfOutsideHysteresis(ctx) { ++ const state = ctx.state; ++ if (!state.edgeReachedGate) { ++ return; ++ } ++ const contentSize = getContentSize(ctx); ++ const endDistance = contentSize - state.scroll - state.scrollLength - getContentInsetEnd(ctx); ++ const isContentLess = contentSize < state.scrollLength; ++ const startThreshold = state.props.onStartReachedThreshold * state.scrollLength; ++ const endThreshold = state.props.onEndReachedThreshold * state.scrollLength; ++ const isOutsideStart = isOutsideThresholdHysteresis(state.scroll, false, startThreshold); ++ const isOutsideEnd = isOutsideThresholdHysteresis(endDistance, isContentLess, endThreshold); ++ if (isOutsideStart && isOutsideEnd) { ++ state.edgeReachedGate = void 0; ++ } ++} ++function canDispatchReachedEdge(ctx, edge, allowedEdge, allowGateCreatedInCurrentCheck) { ++ return !ctx.state.edgeReachedGate || allowedEdge === edge || !!allowGateCreatedInCurrentCheck; ++} ++function markReachedEdge(ctx) { ++ ctx.state.edgeReachedGate = "closed"; ++} ++function prepareReachedEdgeForNextUserScroll(ctx) { ++ if (ctx.state.edgeReachedGate) { ++ ctx.state.edgeReachedGate = "prepared"; ++ } ++} ++function beginReachedEdgeUserScroll(ctx, scrollDelta) { ++ const state = ctx.state; ++ if (state.edgeReachedGate !== "prepared") { ++ return void 0; ++ } ++ const allowedEdge = scrollDelta < 0 ? "start" : "end"; ++ state.edgeReachedGate = "closed"; ++ resetEdgeLatch(ctx, allowedEdge); ++ return allowedEdge; ++} ++ ++// src/utils/hasActiveInitialScroll.ts ++function hasActiveInitialScroll(state) { ++ return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; ++} ++ ++// src/utils/checkAtBottom.ts ++function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ var _a3; ++ const state = ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ queuedInitialLayout, ++ scrollLength, ++ scroll, ++ maintainingScrollAtEnd, ++ props: { maintainScrollAtEndThreshold, onEndReachedThreshold } ++ } = state; ++ const contentSize = getContentSize(ctx); ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (contentSize > 0 && queuedInitialLayout) { ++ const insetEnd = getContentInsetEnd(ctx); ++ const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const isContentLess = contentSize < scrollLength; ++ set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); ++ set$( ++ ctx, ++ "isWithinMaintainScrollAtEndThreshold", ++ isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ ); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; ++ if (!shouldSkipThresholdChecks) { ++ state.isEndReached = checkThreshold( ++ distanceFromEnd, ++ isContentLess, ++ onEndReachedThreshold * scrollLength, ++ state.isEndReached, ++ state.endReachedSnapshot, ++ { ++ contentSize, ++ dataLength: (_a3 = state.props.data) == null ? void 0 : _a3.length, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a4, _b; ++ if (canDispatchReachedEdge(ctx, "end", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a4 = state.props).onEndReached) == null ? void 0 : _b.call(_a4, { distanceFromEnd: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.endReachedSnapshot = snapshot; ++ } ++ ); ++ } ++ } ++} ++ ++// src/utils/checkAtTop.ts ++function checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { ++ const state = ctx == null ? void 0 : ctx.state; ++ if (!state) { ++ return; ++ } ++ const { ++ isStartReached, ++ props: { data, onStartReachedThreshold }, ++ scroll, ++ scrollLength, ++ startReachedSnapshot, ++ totalSize ++ } = state; ++ const dataLength = data.length; ++ const threshold = onStartReachedThreshold * scrollLength; ++ resetSharedEdgeGateIfOutsideHysteresis(ctx); ++ if (isStartReached && threshold > 0 && scroll > threshold && startReachedSnapshot && (startReachedSnapshot.contentSize !== totalSize || startReachedSnapshot.dataLength !== dataLength)) { ++ state.isStartReached = false; ++ state.startReachedSnapshot = void 0; ++ } ++ set$(ctx, "isAtStart", scroll <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isNearStart", scroll <= threshold); ++ const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || !!state.scrollingTo; ++ if (!shouldSkipThresholdChecks) { ++ state.isStartReached = checkThreshold( ++ scroll, ++ false, ++ threshold, ++ state.isStartReached, ++ startReachedSnapshot, ++ { ++ contentSize: totalSize, ++ dataLength, ++ scrollPosition: scroll ++ }, ++ (distance) => { ++ var _a3, _b; ++ if (canDispatchReachedEdge(ctx, "start", allowedEdge, allowGateCreatedInCurrentCheck)) { ++ markReachedEdge(ctx); ++ (_b = (_a3 = state.props).onStartReached) == null ? void 0 : _b.call(_a3, { distanceFromStart: distance }); ++ } ++ }, ++ (snapshot) => { ++ state.startReachedSnapshot = snapshot; ++ } ++ ); ++ } + } + +-// 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/utils/checkThresholds.ts ++function checkThresholds(ctx, allowedEdge) { ++ const allowGateCreatedInCurrentCheck = !ctx.state.edgeReachedGate; ++ checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck); ++ checkAtTop(ctx, allowedEdge, allowGateCreatedInCurrentCheck); + } + +-// src/core/clampScrollOffset.ts +-function clampScrollOffset(ctx, offset, scrollTarget) { ++// src/core/recalculateSettledScroll.ts ++function recalculateSettledScroll(ctx) { ++ var _a3, _b; + 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); ++ if ((_a3 = state.props) == null ? void 0 : _a3.data) { ++ (_b = state.triggerCalculateItemsInView) == null ? void 0 : _b.call(state, { forceFullItemPositions: true }); + } +- clampedOffset = Math.max(0, clampedOffset); +- return clampedOffset; ++ checkThresholds(ctx); + } + + // src/core/finishScrollTo.ts +@@ -1404,6 +1020,181 @@ function listenForScrollEnd(ctx, params) { + scheduledWork.register("platformScrollCompletion", cancel); + } + ++// 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 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 ++ }); ++ } ++ (_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; ++ } ++ 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; ++ } ++ 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); ++ } ++ if (!state.initialScroll && !bootstrap && !hasInitialScrollSessionCompletion(completion)) { ++ return clearInitialScrollSession(state); ++ } ++ 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; ++} ++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) { ++ 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); ++ } ++ } ++} ++ + // src/core/doMaintainScrollAtEnd.ts + function doMaintainScrollAtEnd(ctx) { + const state = ctx.state; +@@ -1430,9 +1221,12 @@ function doMaintainScrollAtEnd(ctx) { + const activeState = maintainScrollAtEnd.animated ? "animated" : "instant"; + const scrollAtRequest = state.scroll; + state.maintainingScrollAtEnd = pendingState; ++ clearScrollTargetSettle(state); + requestAnimationFrame(() => { + const isStillWithinThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); +- const didScrollSinceRequest = state.scroll !== scrollAtRequest; ++ const lastIssued = state.lastIssuedScrollOffset; ++ const isSettlingOntoIssuedScroll = lastIssued !== void 0 && Math.abs(state.scroll - lastIssued) <= EDGE_POSITION_EPSILON; ++ const didScrollSinceRequest = state.scroll !== scrollAtRequest && !isSettlingOntoIssuedScroll; + if (isStillWithinThreshold || !didScrollSinceRequest) { + state.maintainingScrollAtEnd = activeState; + const scroller = refScroller.current; +@@ -1788,6 +1582,27 @@ function prepareMVCP(ctx, dataChanged) { + } + } + ++// 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")) { ++ return; ++ } ++ state.scheduledWork.frame(() => { ++ var _a3; ++ return (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++ }, "fullDrawDistancePrewarm"); ++} ++ + // src/utils/getScrollVelocity.ts + var MAX_SCROLL_VELOCITY_WINDOW_MS = 1e3; + var SCROLL_VELOCITY_HALF_LIFE_MS = 200; +@@ -2048,7 +1863,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 { +@@ -2070,6 +1885,7 @@ function scrollTo(ctx, params) { + if (!noScrollingTo) { + if (isInitialScroll) { + initialScrollCompletion.resetFlags(state); ++ clearScrollTargetSettle(state); + } + const averageSizeSnapshot = getAverageSizeSnapshot(state); + state.scrollingTo = { +@@ -2080,6 +1896,15 @@ function scrollTo(ctx, params) { + }; + if (!isInitialScroll) { + pinScrollTargetRenderRange(ctx, targetOffset, scrollTarget.index); ++ if (!animated && 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; +@@ -2087,7 +1912,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 }); +@@ -2100,6 +1925,316 @@ function scrollTo(ctx, params) { + } + } + ++// src/core/scrollTargetSettle.ts ++var SETTLE_POSITION_EPSILON = 1; ++var SETTLE_QUIET_PASSES_TO_RELEASE = 2; ++var SETTLE_MAX_MS = 1e3; ++var SETTLE_MAX_CORRECTIONS = 8; ++function clearScrollTargetSettle(state) { ++ state.scrollTargetSettle = void 0; ++ state.scheduledWork.cancel("scrollTargetSettle"); ++ state.scheduledWork.cancel("scrollTargetSettleDeadline"); ++} ++function beginScrollTargetSettle(ctx, params) { ++ const state = ctx.state; ++ const { index, viewOffset, viewPosition } = params; ++ const { alignItemsAtEnd, data, maintainScrollAtEnd } = state.props; ++ const hasEndAnchoring = !!maintainScrollAtEnd || !!alignItemsAtEnd; ++ const isEndAlignedLastItem = index === data.length - 1 && viewPosition === 1; ++ if (index < 0 || index >= data.length || isEndAlignedLastItem && hasEndAnchoring) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ clearScrollTargetSettle(state); ++ const now = Date.now(); ++ state.scrollTargetSettle = { ++ corrections: 0, ++ deadline: now + SETTLE_MAX_MS, ++ id: getId(state, index), ++ measuredIndex: void 0, ++ quietPasses: 0, ++ viewOffset, ++ viewPosition ++ }; ++ state.scheduledWork.timeout(() => clearScrollTargetSettle(state), SETTLE_MAX_MS, "scrollTargetSettleDeadline"); ++} ++function getSettleTargetOffset(ctx, settle, index, position) { ++ const params = { index, viewOffset: settle.viewOffset, viewPosition: settle.viewPosition }; ++ return clampScrollOffset(ctx, calculateOffsetWithOffsetPosition(ctx, position, params), params); ++} ++function applyScrollTargetCorrection(ctx, id) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle || settle.id !== id) { ++ return; ++ } ++ const index = state.indexByKey.get(id); ++ const position = index === void 0 ? void 0 : state.positions[index]; ++ if (index === void 0 || position === void 0 || Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ if (settle.corrections >= SETTLE_MAX_CORRECTIONS) { ++ clearScrollTargetSettle(state); ++ return; ++ } ++ settle.corrections++; ++ settle.measuredIndex = void 0; ++ scrollTo(ctx, { ++ animated: false, ++ index, ++ itemSize: getItemSizeAtIndex(ctx, index), ++ // Correcting where a scroll already landed is not a new imperative scroll: claiming the ++ // session would suppress the list's own handling of subsequent scroll events and re-arm ++ // this settle from inside its own correction. ++ noScrollingTo: true, ++ offset: position, ++ viewOffset: settle.viewOffset, ++ viewPosition: settle.viewPosition ++ }); ++ const scrollingTo = state.scrollingTo; ++ if (scrollingTo) { ++ scrollingTo.targetOffset = state.scrollPending; ++ scrollingTo.offset = position; ++ } ++ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); ++} ++function settleScrollTarget(ctx, options) { ++ var _a3; ++ const state = ctx.state; ++ const settle = state.scrollTargetSettle; ++ if (!settle) { ++ return false; ++ } ++ const measured = options == null ? void 0 : options.minIndexSizeChanged; ++ if (measured !== void 0) { ++ settle.measuredIndex = Math.min((_a3 = settle.measuredIndex) != null ? _a3 : Number.POSITIVE_INFINITY, measured); ++ } ++ if (options == null ? void 0 : options.isCompensating) { ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ settle.quietPasses = 0; ++ 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; ++ } ++ if (Date.now() > settle.deadline) { ++ clearScrollTargetSettle(state); ++ return false; ++ } ++ if (settle.measuredIndex === void 0 || settle.measuredIndex > index) { ++ return false; ++ } ++ const targetOffset = getSettleTargetOffset(ctx, settle, index, position); ++ if (Math.abs(targetOffset - state.scroll) <= SETTLE_POSITION_EPSILON) { ++ settle.measuredIndex = void 0; ++ settle.quietPasses++; ++ if (settle.quietPasses >= SETTLE_QUIET_PASSES_TO_RELEASE) { ++ clearScrollTargetSettle(state); ++ } ++ return false; ++ } ++ settle.quietPasses = 0; ++ state.scheduledWork.frame(() => applyScrollTargetCorrection(ctx, settle.id), "scrollTargetSettle"); ++ return true; ++} ++ ++// 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 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 ++ ) ++ ); ++ } ++} ++ ++// 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; ++ } ++ 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; ++ } ++ 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 }); ++ } ++ } ++ } ++} ++ ++// 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); ++ } ++ 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 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); ++ } ++ } ++ 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); ++ } ++ 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; ++ } ++ complete(); ++} ++ ++// src/core/calculateOffsetForIndex.ts ++function calculateOffsetForIndex(ctx, index) { ++ const state = ctx.state; ++ return index !== void 0 ? state.positions[index] || 0 : 0; ++} ++ + // src/core/scrollToIndex.ts + function clampScrollIndex(index, dataLength) { + if (dataLength <= 0) { +@@ -4329,6 +4464,7 @@ function calculateItemsInView(ctx, params = {}) { + startIndex + }); + totalSize = getContentSize(ctx); ++ const minIndexSizeChangedThisPass = minIndexSizeChanged; + if (minIndexSizeChanged !== void 0) { + state.minIndexSizeChanged = void 0; + } +@@ -4346,8 +4482,18 @@ 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); +- if (didMVCPAdjustScroll) { ++ const scrollAdjustPendingAfterMVCP = (_g = peek$(ctx, "scrollAdjustPending")) != null ? _g : 0; ++ const didMVCPAdjust = !!checkMVCP && (state.scroll !== scrollBeforeMVCP || scrollAdjustPendingAfterMVCP !== scrollAdjustPendingBeforeMVCP); ++ const mvcp = state.props.maintainVisibleContentPosition; ++ const isUnanchoredDataChange = dataChanged && !mvcp.data; ++ const isCompensating = didMVCPAdjust || scrollAdjustPendingAfterMVCP !== 0 || !!state.pendingNativeMVCPAdjust; ++ if (!suppressInitialScrollSideEffects) { ++ settleScrollTarget(ctx, { ++ isCompensating, ++ minIndexSizeChanged: isUnanchoredDataChange ? 0 : minIndexSizeChangedThisPass ++ }); ++ } ++ if (didMVCPAdjust) { + updateScroll2(state.scroll); + updateScrollRange(); + } +@@ -5985,6 +6131,8 @@ function resolveWindowScrollTarget({ clampedOffset, horizontal, listPos, scroll + // src/components/ListComponentScrollView.tsx + var SCROLLBAR_HIDDEN_STYLE_ID = "legend-list-scrollbar-axis-hidden-style"; + var SCROLL_END_FALLBACK_MS = 200; ++var SCROLL_EXTENT_EPSILON = 1; ++var REACHABLE_RETRY_FRAMES = 30; + var SCROLLBAR_HIDDEN_STYLE = `.${LEGEND_LIST_SCROLLBAR_Y_HIDDEN_CLASS}::-webkit-scrollbar:vertical{width:0;display:none;}.${LEGEND_LIST_SCROLLBAR_X_HIDDEN_CLASS}::-webkit-scrollbar:horizontal{height:0;display:none;}`; + function ensureScrollbarHiddenStyle() { + if (typeof document === "undefined" || document.getElementById(SCROLLBAR_HIDDEN_STYLE_ID)) { +@@ -6073,6 +6221,26 @@ var ListComponentScrollView = forwardRef(function ListComponentScrollView2({ + } + return horizontal ? scrollElement.scrollLeft : scrollElement.scrollTop; + }, [getMaxScrollOffset, horizontal, isWindowScroll]); ++ const reissueHandleRef = useRef(0); ++ const scrollUntilReachable = useCallback( ++ (offset, animated, run) => { ++ cancelAnimationFrame(reissueHandleRef.current); ++ let attempts = 0; ++ let previousMaxOffset = Number.NEGATIVE_INFINITY; ++ const attempt = () => { ++ const liveMaxOffset = getMaxScrollOffset(); ++ run(clampOffset(offset, liveMaxOffset)); ++ const isStillCommitting = liveMaxOffset > previousMaxOffset; ++ previousMaxOffset = liveMaxOffset; ++ if (!animated && isStillCommitting && Number.isFinite(offset) && offset - liveMaxOffset > SCROLL_EXTENT_EPSILON && ++attempts < REACHABLE_RETRY_FRAMES) { ++ reissueHandleRef.current = requestAnimationFrame(attempt); ++ } ++ }; ++ attempt(); ++ }, ++ [getMaxScrollOffset] ++ ); ++ useEffect(() => () => cancelAnimationFrame(reissueHandleRef.current), []); + const scrollToLocalOffset = useCallback( + (offset, animated) => { + const scrollElement = scrollRef.current; +@@ -6080,29 +6248,34 @@ var ListComponentScrollView = forwardRef(function ListComponentScrollView2({ + if (!target || typeof target.scrollTo !== "function") { + return; + } +- const maxOffset = getMaxScrollOffset(); +- const clampedOffset = clampOffset(offset, maxOffset); + const behavior = animated ? "smooth" : "auto"; + const options = { behavior }; + if (isWindowScroll) { + const scroll = getWindowScrollPosition(); + const listPos = getElementDocumentPosition(scrollElement, scroll); + const { left, top } = resolveWindowScrollTarget({ +- clampedOffset, ++ clampedOffset: clampOffset(offset, getMaxScrollOffset()), + horizontal, + listPos, + scroll + }); + options.left = left; + options.top = top; +- } else if (horizontal) { +- options.left = clampedOffset; +- } else { +- options.top = clampedOffset; ++ ctx.state.lastIssuedScrollOffset = clampOffset(offset, getMaxScrollOffset()); ++ target.scrollTo(options); ++ return; + } +- target.scrollTo(options); ++ scrollUntilReachable(offset, animated, (reachableOffset) => { ++ if (horizontal) { ++ options.left = reachableOffset; ++ } else { ++ options.top = reachableOffset; ++ } ++ ctx.state.lastIssuedScrollOffset = reachableOffset; ++ target.scrollTo(options); ++ }); + }, +- [getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll] ++ [ctx, getMaxScrollOffset, getScrollTarget, horizontal, isWindowScroll, scrollUntilReachable] + ); + useImperativeHandle(ref, () => { + const api = { +@@ -6128,8 +6301,7 @@ var ListComponentScrollView = forwardRef(function ListComponentScrollView2({ + }, + scrollToEnd: (options = {}) => { + const { animated = true } = options; +- const endOffset = getMaxScrollOffset(); +- scrollToLocalOffset(endOffset, animated); ++ scrollToLocalOffset(getMaxScrollOffset(), animated); + }, + scrollToOffset: (params) => { + const { offset, animated = true } = params; +@@ -6478,6 +6650,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; +@@ -6487,6 +6660,8 @@ 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) { ++ doMaintainScrollAtEnd(ctx); + } + } + state.didMeasureHeader = true; +@@ -7549,13 +7724,14 @@ function getRenderedItem(ctx, key) { + + // 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) { +@@ -8267,6 +8443,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + onScrollBeginDrag: (event) => { + var _a4, _b2; + prepareReachedEdgeForNextUserScroll(ctx); ++ clearScrollTargetSettle(state); + (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); + }, + onScrollEnd: () => prepareReachedEdgeForNextUserScroll(ctx) +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-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..667eab4b2d25 --- /dev/null +++ b/shared/tests/e2e/electron/flows/chat-thread-bottom.test.ts @@ -0,0 +1,95 @@ +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}` + 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 311ab79b2399..372c87406062 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..c01658038632 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,24 @@ 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 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' diff --git a/shared/yarn.lock b/shared/yarn.lock index 1a2bb23dcf1d..cc6c3427188d 100644 --- a/shared/yarn.lock +++ b/shared/yarn.lock @@ -2642,10 +2642,10 @@ dependencies: "@khanacademy/perseus-utils" "2.1.5" -"@legendapp/list@3.3.4": - version "3.3.4" - resolved "https://registry.yarnpkg.com/@legendapp/list/-/list-3.3.4.tgz#6d71d46d1026d92015116f00492839a629cc50b8" - integrity sha512-XsKCM7F8Fmy1jE7dZ9UEBCihouVOJ6B5pbAI1ssooadBu+YQHGoSQdxHs5dPXHb4qkGOIfUIAljIXnDzMYUCCQ== +"@legendapp/list@3.3.5": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@legendapp/list/-/list-3.3.5.tgz#a2a7b8e0c3897fc5bd159d7b87e0b62ef45a4f74" + integrity sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ== dependencies: use-sync-external-store "^1.5.0"