From aeaf3cae45a61f99fb7aac499d501266ff11d648 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:37:22 +0200 Subject: [PATCH 01/13] Add @shopify/flash-list patch to use natural DOM order --- ...2.3.0+007+sort-for-natural-DOM-order.patch | 89 +++++++++++++++++++ patches/@shopify/flash-list/details.md | 31 ++++++- 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch new file mode 100644 index 000000000000..a3fe6191ab3f --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch @@ -0,0 +1,89 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +index 8e3db51..58f7043 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +@@ -3,10 +3,13 @@ + * It handles the rendering of a collection of list items, manages layout updates, + * and coordinates with the RecyclerView context for layout changes. + */ +-import React, { useEffect, useImperativeHandle, useLayoutEffect } from "react"; ++import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react"; ++import { Platform } from "react-native"; + import { ViewHolder } from "./ViewHolder"; + import { CompatView } from "./components/CompatView"; + import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; ++const SORT_DELAY_MS = 1000; ++const TAB_SCROLL_THRESHOLD_MS = 400; + /** + * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances + * and handles layout updates for the entire collection +@@ -72,9 +75,67 @@ export const ViewHolderCollection = (props) => { + // return `${index} => ${reactKey}`; + // }) + // ); +- return (React.createElement(CompatView, { style: hasData && containerStyle }, containerLayout && ++ const containerRef = useRef(null); ++ const lastFocusTimeRef = useRef(0); ++ const renderEntriesRef = useRef(Array.from(renderStack.entries())); ++ const [, setSortId] = useState(0); ++ const doSort = useCallback(() => { ++ const entries = renderEntriesRef.current; ++ const direction = inverted ? -1 : 1; ++ const isSorted = entries.every((entry, i) => i === 0 || ++ direction * (entries[i - 1][1].index - entry[1].index) <= 0); ++ if (isSorted) { ++ return; ++ } ++ entries.sort(([, a], [, b]) => direction * (a.index - b.index)); ++ setSortId((prev) => prev + 1); ++ // eslint-disable-next-line react-hooks/exhaustive-deps ++ }, [inverted]); ++ if (Platform.OS === "web") { ++ // Reconcile: remove stale keys, append new keys ++ const existingKeys = new Set(renderEntriesRef.current.map(([key]) => key)); ++ renderEntriesRef.current = renderEntriesRef.current.filter(([key]) => renderStack.has(key)); ++ for (const key of renderStack.keys()) { ++ if (!existingKeys.has(key)) { ++ renderEntriesRef.current.push([key, renderStack.get(key)]); ++ } ++ } ++ } ++ else { ++ renderEntriesRef.current = Array.from(renderStack.entries()); ++ } ++ useEffect(() => { ++ if (Platform.OS !== "web") { ++ return; ++ } ++ const container = containerRef.current; ++ if (!container) { ++ return; ++ } ++ const onFocusIn = () => { ++ lastFocusTimeRef.current = Date.now(); ++ doSort(); ++ }; ++ container.addEventListener("focusin", onFocusIn); ++ return () => container.removeEventListener("focusin", onFocusIn); ++ // eslint-disable-next-line react-hooks/exhaustive-deps ++ }, []); ++ useEffect(() => { ++ if (Platform.OS !== "web") { ++ return; ++ } ++ const isRecentFocus = Date.now() - lastFocusTimeRef.current < TAB_SCROLL_THRESHOLD_MS; ++ if (isRecentFocus) { ++ doSort(); ++ return; ++ } ++ const timeoutId = setTimeout(doSort, SORT_DELAY_MS); ++ return () => clearTimeout(timeoutId); ++ // eslint-disable-next-line react-hooks/exhaustive-deps ++ }, [renderStack, renderId]); ++ return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && + hasData && +- Array.from(renderStack.entries(), ([reactKey, { index }]) => { ++ renderEntriesRef.current.map(([reactKey, { index }]) => { + const item = data[index]; + // Suppress separators for items in the last row to prevent + // height mismatch. The last data item has no separator (no diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 45570d16761f..5140ae7fa86a 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -16,8 +16,7 @@ 1. **First `useLayoutEffect`** (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions. 2. **Second `useLayoutEffect`** (measures individual items): If `containerViewSizeRef.current` is 0x0 (because the first effect bailed out), return early before calling `modifyChildrenLayout()`. This prevents item measurements taken under `display: none` (also 0) from corrupting stored layouts. When the container becomes visible again, `onLayout` fires (React Native Web uses ResizeObserver), triggering a re-render with correct dimensions so FlashList resumes normally without re-initialization. -- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. The `src/` file contains the full explanatory comments describing the intent of each guard. The `dist/` file contains only the bare code without comments, since it is compiled output. If the `dist/` file changes in a future version, refer to the `src/` diff to understand the intent and re-apply the equivalent guards. -- Upstream PR/issue: TBD +- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2231 - E/App issue: https://github.com/Expensify/App/issues/83976 - PR introducing patch: https://github.com/Expensify/App/pull/84887 @@ -48,3 +47,31 @@ - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/33725 - PR introducing patch: https://github.com/Expensify/App/pull/85114 + +### [@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch) + +- Reason: Fixes scrambled DOM order in virtualized list items on web. FlashList uses `position: absolute` to position items, so visual order is determined by CSS `top`/`left` values rather than DOM order. Due to recycling (reusing ViewHolder components for different data items), the DOM order reflects Map insertion order rather than data index order. This causes three web-specific issues: + 1. **Screen reader reading order**: Assistive technologies follow DOM order, so items are read in a scrambled sequence that doesn't match the visual layout. + 2. **Keyboard Tab navigation**: Tab key follows DOM order, so focus jumps unpredictably between items instead of following the visual top-to-bottom sequence. + 3. **Cross-item text selection**: Selecting text across multiple list items selects them in DOM order rather than visual order, producing garbled selections. + + **How it works:** + + 1. **Stable render order during scroll**: Render entries are maintained in a ref (`renderEntriesRef`) that preserves its order across renders. On each render, a reconcile step removes keys that left the render stack and appends new keys. Because FlashList's recycling mutates index values in place on shared object references (`keyInfo.index = newIndex`), the entries in the ref always have current index values without needing updates — only the array order can be stale. This means during normal scrolling, React sees children in the same order and produces zero `insertBefore` calls, avoiding any DOM reordering. + + 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a `useEffect` sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. A separate state counter (`sortId`) triggers this re-render instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. + + 3. **Immediate sort on keyboard focus** (default `TAB_SCROLL_THRESHOLD_MS` = 400ms): A `focusin` event listener on the container immediately sorts when focus enters the list, ensuring Tab navigation always follows the correct order without waiting for the deferred timeout. Additionally, the sort effect checks if focus occurred recently (within `TAB_SCROLL_THRESHOLD_MS`) to also sort immediately on tab-triggered scroll re-renders. By checking the recency of focus rather than its presence, this correctly distinguishes tab-triggered re-renders (sort immediately) from scroll-triggered re-renders that happen while an element is still focused (defer sort to protect hover). + + **Why the deferred approach is necessary:** + + When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. The stable-ref approach ensures that during scrolling the array order doesn't change (no `insertBefore`), and the sort only fires after scrolling pauses, giving the browser time to process hover state changes. + + **Platform gating:** + + On web: reconcile preserves order, deferred sort, focusin listener. + On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. + +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/86126 +- PR introducing patch: https://github.com/Expensify/App/pull/85825 From 01837b8517d744b25cea6aa819a8627be03d3d7b Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:04:02 +0200 Subject: [PATCH 02/13] Defer sorting till momentum end is called after scrollToIndex --- ...2.3.0+007+sort-for-natural-DOM-order.patch | 147 +++++++++++++++++- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch index a3fe6191ab3f..36e315358e26 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch @@ -1,8 +1,51 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index ee42f63..516f3de 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { + const refHolder = useMemo(() => new Map(), []); + // Initialize core RecyclerView manager and content offset management + const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); +- const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); ++ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); + // Initialize view holder collection ref + const viewHolderCollectionRef = useRef(null); + // Hook to handle list loading +@@ -238,6 +238,12 @@ const RecyclerViewComponent = (props, ref) => { + return; + } + if (isMomentumEnd) { ++ // Drain any pending callback registered via ++ // `runAfterProgrammaticScroll` BEFORE the early return below ++ // so the drain still fires while offset projection is still ++ // disabled. This is the moment FlashList's `VelocityTracker` ++ // confirms the browser-native smooth scroll has truly settled. ++ notifyProgrammaticScrollSettled(); + computeFirstVisibleIndexForOffsetCorrection(); + if (!recyclerViewManager.isOffsetProjectionEnabled) { + return; +@@ -266,6 +272,7 @@ const RecyclerViewComponent = (props, ref) => { + computeFirstVisibleIndexForOffsetCorrection, + horizontal, + isHorizontalRTL, ++ notifyProgrammaticScrollSettled, + recyclerViewManager, + velocityTracker, + ]); +@@ -459,7 +466,7 @@ const RecyclerViewComponent = (props, ref) => { + recyclerViewManager.animationOptimizationsEnabled = false; + }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() + ? recyclerViewManager.getChildContainerDimensions() +- : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted }), ++ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, runAfterProgrammaticScroll: runAfterProgrammaticScroll }), + renderEmpty, + renderFooter), + stickyHeaderIndices && stickyHeaderIndices.length > 0 diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..58f7043 100644 +index 8e3db51..c274336 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -3,10 +3,13 @@ +@@ -3,17 +3,20 @@ * It handles the rendering of a collection of list items, manages layout updates, * and coordinates with the RecyclerView context for layout changes. */ @@ -17,7 +60,15 @@ index 8e3db51..58f7043 100644 /** * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances * and handles layout updates for the entire collection -@@ -72,9 +75,67 @@ export const ViewHolderCollection = (props) => { + * @template TItem - The type of items in the data array + */ + export const ViewHolderCollection = (props) => { +- const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, } = props; ++ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, runAfterProgrammaticScroll, } = props; + const [renderId, setRenderId] = React.useState(0); + const containerLayout = getChildContainerLayout(); + const fixedContainerSize = horizontal +@@ -72,9 +75,81 @@ export const ViewHolderCollection = (props) => { // return `${index} => ${reactKey}`; // }) // ); @@ -29,8 +80,7 @@ index 8e3db51..58f7043 100644 + const doSort = useCallback(() => { + const entries = renderEntriesRef.current; + const direction = inverted ? -1 : 1; -+ const isSorted = entries.every((entry, i) => i === 0 || -+ direction * (entries[i - 1][1].index - entry[1].index) <= 0); ++ const isSorted = entries.every((entry, i) => i === 0 || direction * (entries[i - 1][1].index - entry[1].index) <= 0); + if (isSorted) { + return; + } @@ -38,6 +88,21 @@ index 8e3db51..58f7043 100644 + setSortId((prev) => prev + 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inverted]); ++ // Defers `doSort` while a programmatic scroll animation is in flight. ++ // Without this, committing the sort triggers `insertBefore` calls which ++ // cause React's selection-preservation logic to write `scrollTop` on the ++ // scroll container, cancelling the in-flight smooth scroll animation ++ // (the "starts and freezes" bug on web). The deferred call runs once the ++ // scroll settles. ++ const maybeDoSort = useCallback(() => { ++ if (isScrollingProgrammatically()) { ++ runAfterProgrammaticScroll(() => { ++ doSort(); ++ }); ++ return; ++ } ++ doSort(); ++ }, [isScrollingProgrammatically, runAfterProgrammaticScroll, doSort]); + if (Platform.OS === "web") { + // Reconcile: remove stale keys, append new keys + const existingKeys = new Set(renderEntriesRef.current.map(([key]) => key)); @@ -61,7 +126,7 @@ index 8e3db51..58f7043 100644 + } + const onFocusIn = () => { + lastFocusTimeRef.current = Date.now(); -+ doSort(); ++ maybeDoSort(); + }; + container.addEventListener("focusin", onFocusIn); + return () => container.removeEventListener("focusin", onFocusIn); @@ -73,7 +138,7 @@ index 8e3db51..58f7043 100644 + } + const isRecentFocus = Date.now() - lastFocusTimeRef.current < TAB_SCROLL_THRESHOLD_MS; + if (isRecentFocus) { -+ doSort(); ++ maybeDoSort(); + return; + } + const timeoutId = setTimeout(doSort, SORT_DELAY_MS); @@ -87,3 +152,71 @@ index 8e3db51..58f7043 100644 const item = data[index]; // Suppress separators for items in the last row to prevent // height mismatch. The last data item has no separator (no +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +index 51b6f8c..309f338 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +@@ -25,6 +25,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + const isUnmounted = useUnmountFlag(); + const [_, setRenderId] = useState(0); + const pauseOffsetCorrection = useRef(false); ++ // True for the full duration of an in-flight programmatic scroll ++ // (`scrollToIndex` / `scrollToOffset` etc.). Cleared exactly once when the ++ // browser-native smooth scroll truly settles, via `notifyProgrammaticScrollSettled` ++ // (which `RecyclerView.onScrollHandler` invokes from `isMomentumEnd`). ++ // Backs `isScrollingProgrammatically()` so consumers can defer DOM work ++ // that would otherwise cancel the in-flight smooth scroll on web (e.g. ++ // sort-driven `insertBefore` reorderings). ++ const isProgrammaticScrollActive = useRef(false); ++ // Holds at most one callback registered via `runAfterProgrammaticScroll`, ++ // drained from `notifyProgrammaticScrollSettled`. ++ const pendingAfterScrollRef = useRef(null); + const pendingAndroidInvertedRafId = useRef(null); + const skipNextAndroidInvertedCorrection = useRef(false); + const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); +@@ -180,6 +191,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + updateScrollOffsetWithCallback, + computeFirstVisibleIndexForOffsetCorrection, + ]); ++ const isScrollingProgrammatically = useCallback(() => isProgrammaticScrollActive.current, []); ++ const runAfterProgrammaticScroll = useCallback((cb) => { ++ pendingAfterScrollRef.current = cb; ++ }, []); ++ // Invoked from `RecyclerView.onScrollHandler` inside the existing ++ // `isMomentumEnd` branch — the moment FlashList's `VelocityTracker` ++ // confirms the browser-native smooth scroll has truly settled (~100ms ++ // after the last scroll event). Drains the pending callback registered ++ // via `runAfterProgrammaticScroll`, if any. ++ const notifyProgrammaticScrollSettled = useCallback(() => { ++ isProgrammaticScrollActive.current = false; ++ const cb = pendingAfterScrollRef.current; ++ pendingAfterScrollRef.current = null; ++ cb === null || cb === void 0 ? void 0 : cb(); ++ }, []); + const handlerMethods = useMemo(() => { + return { + get props() { +@@ -289,6 +315,12 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + // Pause the scroll offset adjustments + pauseOffsetCorrection.current = true; + recyclerViewManager.setOffsetProjectionEnabled(false); ++ // Mark a programmatic scroll as in flight. Cleared in ++ // `notifyProgrammaticScrollSettled` when `isMomentumEnd` fires, ++ // not by the 200/300 ms timer below — that timer is a fixed ++ // heuristic for re-enabling offset correction and unrelated to ++ // the actual smooth-scroll completion time. ++ isProgrammaticScrollActive.current = true; + const getFinalOffset = () => { + const layout = recyclerViewManager.getLayout(index); + const offset = horizontal ? layout.x : layout.y; +@@ -547,6 +579,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + computeFirstVisibleIndexForOffsetCorrection, + applyInitialScrollIndex, + handlerMethods, ++ isScrollingProgrammatically, ++ runAfterProgrammaticScroll, ++ notifyProgrammaticScrollSettled, + }; + } + //# sourceMappingURL=useRecyclerViewController.js.map +\ No newline at end of file From 9dd567b6bc66f4f333369b70837468e9b0d78754 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:36:13 +0200 Subject: [PATCH 03/13] Update commit hashes --- ...y+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch => @shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch} (99%) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch similarity index 99% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch index 36e315358e26..fa10eff5d499 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index ee42f63..516f3de 100644 +index e41278c..e9e4fef 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js @@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { @@ -32,7 +32,7 @@ index ee42f63..516f3de 100644 recyclerViewManager, velocityTracker, ]); -@@ -459,7 +466,7 @@ const RecyclerViewComponent = (props, ref) => { +@@ -458,7 +465,7 @@ const RecyclerViewComponent = (props, ref) => { recyclerViewManager.animationOptimizationsEnabled = false; }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions() @@ -42,7 +42,7 @@ index ee42f63..516f3de 100644 renderFooter), stickyHeaderIndices && stickyHeaderIndices.length > 0 diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..c274336 100644 +index 8e3db51..fc984d2 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js @@ -3,17 +3,20 @@ From ffa49b3e1ac1423c916da34a09c4f0512749deb5 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Mon, 4 May 2026 18:51:04 +0200 Subject: [PATCH 04/13] Add scroll tracking and integrate it into sorting --- ...2.3.0+008+sort-for-natural-DOM-order.patch | 252 +++++++++++++++--- .../BaseSelectionListWithSections.tsx | 5 +- 2 files changed, 217 insertions(+), 40 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch index fa10eff5d499..b46ef7e2664d 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch @@ -1,5 +1,40 @@ +diff --git a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts +index 08b83f3..27ed1ae 100644 +--- a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts ++++ b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts +@@ -167,6 +167,30 @@ export interface FlashListRef { + * }); + */ + scrollToIndex: (params: ScrollToIndexParams) => Promise; ++ /** ++ * Announces an imminent programmatic scroll before `scrollToIndex` is ++ * actually called, so DOM-mutating side-effects gated on ++ * `isScrollingProgrammatically()` (notably the on-web sort applied by ++ * `ViewHolderCollection`) defer until the upcoming smooth scroll ++ * settles, rather than running synchronously and cancelling it. ++ * ++ * Useful for keyboard-navigation hooks that focus the new item first ++ * and call `scrollToIndex` afterwards: call `queueProgrammaticScroll()` ++ * before the focus assignment so the resulting `focusin` doesn't ++ * trigger an immediate sort that would later be cancelled by the ++ * smooth scroll. ++ * ++ * Cleared automatically when the next `scrollToIndex` is invoked ++ * (handed off to the in-flight flag) and again when the resulting ++ * scroll's momentum ends. Safe to call multiple times. ++ * ++ * @example ++ * // Inside an arrow-key navigation hook: ++ * listRef.current?.queueProgrammaticScroll(); ++ * itemDomNode.focus(); ++ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); ++ */ ++ queueProgrammaticScroll: () => void; + /** + * Scrolls to a specific item in the list. + * diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index e41278c..e9e4fef 100644 +index 9f3b776..c153718 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js @@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { @@ -7,14 +42,16 @@ index e41278c..e9e4fef 100644 // Initialize core RecyclerView manager and content offset management const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); - const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); -+ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); ++ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, notifyScrollActive, notifyScrollSettled, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); // Initialize view holder collection ref const viewHolderCollectionRef = useRef(null); // Hook to handle list loading -@@ -238,6 +238,12 @@ const RecyclerViewComponent = (props, ref) => { +@@ -238,12 +238,23 @@ const RecyclerViewComponent = (props, ref) => { return; } if (isMomentumEnd) { ++ // See `is-scrolling-flag.md`. ++ notifyScrollSettled(); + // Drain any pending callback registered via + // `runAfterProgrammaticScroll` BEFORE the early return below + // so the drain still fires while offset projection is still @@ -24,25 +61,36 @@ index e41278c..e9e4fef 100644 computeFirstVisibleIndexForOffsetCorrection(); if (!recyclerViewManager.isOffsetProjectionEnabled) { return; -@@ -266,6 +272,7 @@ const RecyclerViewComponent = (props, ref) => { + } + recyclerViewManager.resetVelocityCompute(); + } ++ else { ++ notifyScrollActive(); ++ } + // Update scroll position and trigger re-render if needed + if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { + setRenderId((prev) => prev + 1); +@@ -266,6 +277,9 @@ const RecyclerViewComponent = (props, ref) => { computeFirstVisibleIndexForOffsetCorrection, horizontal, isHorizontalRTL, + notifyProgrammaticScrollSettled, ++ notifyScrollActive, ++ notifyScrollSettled, recyclerViewManager, velocityTracker, ]); -@@ -458,7 +465,7 @@ const RecyclerViewComponent = (props, ref) => { +@@ -458,7 +472,7 @@ const RecyclerViewComponent = (props, ref) => { recyclerViewManager.animationOptimizationsEnabled = false; }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions() - : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted }), -+ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, runAfterProgrammaticScroll: runAfterProgrammaticScroll }), ++ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, isScrolling: isScrolling, runAfterProgrammaticScroll: runAfterProgrammaticScroll }), renderEmpty, renderFooter), stickyHeaderIndices && stickyHeaderIndices.length > 0 diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..fc984d2 100644 +index 8e3db51..10d648a 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js @@ -3,17 +3,20 @@ @@ -50,13 +98,13 @@ index 8e3db51..fc984d2 100644 * and coordinates with the RecyclerView context for layout changes. */ -import React, { useEffect, useImperativeHandle, useLayoutEffect } from "react"; -+import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react"; ++import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useReducer, useRef, } from "react"; +import { Platform } from "react-native"; import { ViewHolder } from "./ViewHolder"; import { CompatView } from "./components/CompatView"; import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; +const SORT_DELAY_MS = 1000; -+const TAB_SCROLL_THRESHOLD_MS = 400; ++const RECENT_FOCUS_WINDOW_MS = 400; /** * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances * and handles layout updates for the entire collection @@ -64,11 +112,11 @@ index 8e3db51..fc984d2 100644 */ export const ViewHolderCollection = (props) => { - const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, } = props; -+ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, runAfterProgrammaticScroll, } = props; ++ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, } = props; const [renderId, setRenderId] = React.useState(0); const containerLayout = getChildContainerLayout(); const fixedContainerSize = horizontal -@@ -72,9 +75,81 @@ export const ViewHolderCollection = (props) => { +@@ -72,9 +75,115 @@ export const ViewHolderCollection = (props) => { // return `${index} => ${reactKey}`; // }) // ); @@ -76,7 +124,10 @@ index 8e3db51..fc984d2 100644 + const containerRef = useRef(null); + const lastFocusTimeRef = useRef(0); + const renderEntriesRef = useRef(Array.from(renderStack.entries())); -+ const [, setSortId] = useState(0); ++ const [, bumpSortVersion] = useReducer((x) => x + 1, 0); ++ // At most one deferred-sort timer is alive at a time. Any code path that ++ // is about to sort (or schedule a new sort) must first clear this. ++ const pendingSortTimeoutRef = useRef(null); + const doSort = useCallback(() => { + const entries = renderEntriesRef.current; + const direction = inverted ? -1 : 1; @@ -85,24 +136,58 @@ index 8e3db51..fc984d2 100644 + return; + } + entries.sort(([, a], [, b]) => direction * (a.index - b.index)); -+ setSortId((prev) => prev + 1); ++ bumpSortVersion(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inverted]); -+ // Defers `doSort` while a programmatic scroll animation is in flight. -+ // Without this, committing the sort triggers `insertBefore` calls which -+ // cause React's selection-preservation logic to write `scrollTop` on the -+ // scroll container, cancelling the in-flight smooth scroll animation -+ // (the "starts and freezes" bug on web). The deferred call runs once the -+ // scroll settles. ++ const clearPendingSort = useCallback(() => { ++ if (pendingSortTimeoutRef.current !== null) { ++ clearTimeout(pendingSortTimeoutRef.current); ++ pendingSortTimeoutRef.current = null; ++ } ++ }, []); ++ const schedulePendingSort = useCallback(() => { ++ clearPendingSort(); ++ pendingSortTimeoutRef.current = setTimeout(() => { ++ if (isScrolling()) { ++ schedulePendingSort(); ++ return; ++ } ++ pendingSortTimeoutRef.current = null; ++ doSort(); ++ }, SORT_DELAY_MS); ++ // `schedulePendingSort` self-references for the reschedule path; closure ++ // resolution at call-time is correct here. ++ }, [clearPendingSort, doSort, isScrolling]); ++ // Schedules or commits a sort, deferring while a programmatic scroll is ++ // in flight (otherwise the sort's `insertBefore` calls would let React's ++ // selection-preservation logic write `scrollTop`, cancelling the smooth ++ // scroll animation — the "starts and freezes" bug on web). When the ++ // scroll settles we wait an additional `SORT_DELAY_MS` so any pointer/ ++ // focus events queued during the animation can land before the commit. + const maybeDoSort = useCallback(() => { ++ // Evict any pending sort timer first. Either we're about to commit ++ // synchronously (the timer would be redundant), or we're about to ++ // defer — in which case a stale timer from a *previous* scroll's ++ // drain must die so it can't fire mid-scroll during rapid-fire arrow ++ // nav (where `isMomentumEnd` doesn't fire between presses). ++ clearPendingSort(); + if (isScrollingProgrammatically()) { -+ runAfterProgrammaticScroll(() => { -+ doSort(); -+ }); ++ runAfterProgrammaticScroll(() => schedulePendingSort()); ++ return; ++ } ++ if (isScrolling()) { ++ schedulePendingSort(); + return; + } + doSort(); -+ }, [isScrollingProgrammatically, runAfterProgrammaticScroll, doSort]); ++ }, [ ++ isScrollingProgrammatically, ++ isScrolling, ++ runAfterProgrammaticScroll, ++ schedulePendingSort, ++ clearPendingSort, ++ doSort, ++ ]); + if (Platform.OS === "web") { + // Reconcile: remove stale keys, append new keys + const existingKeys = new Set(renderEntriesRef.current.map(([key]) => key)); @@ -117,11 +202,8 @@ index 8e3db51..fc984d2 100644 + renderEntriesRef.current = Array.from(renderStack.entries()); + } + useEffect(() => { -+ if (Platform.OS !== "web") { -+ return; -+ } + const container = containerRef.current; -+ if (!container) { ++ if (Platform.OS !== "web" || !container) { + return; + } + const onFocusIn = () => { @@ -136,13 +218,13 @@ index 8e3db51..fc984d2 100644 + if (Platform.OS !== "web") { + return; + } -+ const isRecentFocus = Date.now() - lastFocusTimeRef.current < TAB_SCROLL_THRESHOLD_MS; ++ const isRecentFocus = Date.now() - lastFocusTimeRef.current < RECENT_FOCUS_WINDOW_MS; + if (isRecentFocus) { + maybeDoSort(); + return; + } -+ const timeoutId = setTimeout(doSort, SORT_DELAY_MS); -+ return () => clearTimeout(timeoutId); ++ schedulePendingSort(); ++ return clearPendingSort; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renderStack, renderId]); + return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout && @@ -153,10 +235,10 @@ index 8e3db51..fc984d2 100644 // Suppress separators for items in the last row to prevent // height mismatch. The last data item has no separator (no diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 51b6f8c..309f338 100644 +index 51b6f8c..84e9be6 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -25,6 +25,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -25,6 +25,27 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe const isUnmounted = useUnmountFlag(); const [_, setRenderId] = useState(0); const pauseOffsetCorrection = useRef(false); @@ -167,36 +249,76 @@ index 51b6f8c..309f338 100644 + // Backs `isScrollingProgrammatically()` so consumers can defer DOM work + // that would otherwise cancel the in-flight smooth scroll on web (e.g. + // sort-driven `insertBefore` reorderings). -+ const isProgrammaticScrollActive = useRef(false); ++ const isProgrammaticScrollActiveRef = useRef(false); ++ // Set by the public `queueProgrammaticScroll()` API to announce that a ++ // `scrollToIndex` call is imminent. Lets `isScrollingProgrammatically()` ++ // start reporting true before the actual scroll starts — useful for ++ // keyboard-navigation hooks that focus the new item first and call ++ // `scrollToIndex` afterwards. Cleared at `scrollToIndex` entry (handoff ++ // to `isProgrammaticScrollActiveRef`) and again in ++ // `notifyProgrammaticScrollSettled` (defensive). ++ const isProgrammaticScrollQueuedRef = useRef(false); ++ // Source-agnostic "viewport in motion" flag. ++ const isScrollingRef = useRef(false); + // Holds at most one callback registered via `runAfterProgrammaticScroll`, + // drained from `notifyProgrammaticScrollSettled`. + const pendingAfterScrollRef = useRef(null); const pendingAndroidInvertedRafId = useRef(null); const skipNextAndroidInvertedCorrection = useRef(false); const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); -@@ -180,6 +191,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -180,6 +201,39 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe updateScrollOffsetWithCallback, computeFirstVisibleIndexForOffsetCorrection, ]); -+ const isScrollingProgrammatically = useCallback(() => isProgrammaticScrollActive.current, []); ++ const isScrollingProgrammatically = useCallback(() => isProgrammaticScrollActiveRef.current || ++ isProgrammaticScrollQueuedRef.current, []); ++ const isScrolling = useCallback(() => isScrollingRef.current, []); + const runAfterProgrammaticScroll = useCallback((cb) => { + pendingAfterScrollRef.current = cb; + }, []); ++ // Public API: announces an imminent programmatic scroll before ++ // `scrollToIndex` is called. Lets consumers signal "scroll is about to ++ // happen" so DOM-mutating side-effects gated on ++ // `isScrollingProgrammatically()` defer until the upcoming smooth scroll ++ // settles, even if the consumer focuses the target first and scrolls ++ // afterwards. ++ const queueProgrammaticScroll = useCallback(() => { ++ isProgrammaticScrollQueuedRef.current = true; ++ }, []); + // Invoked from `RecyclerView.onScrollHandler` inside the existing + // `isMomentumEnd` branch — the moment FlashList's `VelocityTracker` + // confirms the browser-native smooth scroll has truly settled (~100ms + // after the last scroll event). Drains the pending callback registered + // via `runAfterProgrammaticScroll`, if any. + const notifyProgrammaticScrollSettled = useCallback(() => { -+ isProgrammaticScrollActive.current = false; ++ isProgrammaticScrollActiveRef.current = false; ++ isProgrammaticScrollQueuedRef.current = false; + const cb = pendingAfterScrollRef.current; + pendingAfterScrollRef.current = null; + cb === null || cb === void 0 ? void 0 : cb(); ++ }, []); ++ const notifyScrollActive = useCallback(() => { ++ isScrollingRef.current = true; ++ }, []); ++ const notifyScrollSettled = useCallback(() => { ++ isScrollingRef.current = false; + }, []); const handlerMethods = useMemo(() => { return { get props() { -@@ -289,6 +315,12 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -271,6 +325,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + animated, + }); + }, ++ /** ++ * Announces an imminent programmatic scroll. See ++ * `FlashListRef#queueProgrammaticScroll` for full semantics. ++ */ ++ queueProgrammaticScroll, + /** + * Scrolls to a specific index in the list. + * Supports viewPosition and viewOffset for precise positioning. +@@ -289,6 +348,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe // Pause the scroll offset adjustments pauseOffsetCorrection.current = true; recyclerViewManager.setOffsetProjectionEnabled(false); @@ -205,18 +327,70 @@ index 51b6f8c..309f338 100644 + // not by the 200/300 ms timer below — that timer is a fixed + // heuristic for re-enabling offset correction and unrelated to + // the actual smooth-scroll completion time. -+ isProgrammaticScrollActive.current = true; ++ // ++ // Also hand the "queued" flag off to the "active" flag here so ++ // a stale `queueProgrammaticScroll()` from before this call ++ // can't keep gating sorts indefinitely. ++ isProgrammaticScrollQueuedRef.current = false; ++ isProgrammaticScrollActiveRef.current = true; const getFinalOffset = () => { const layout = recyclerViewManager.getLayout(index); const offset = horizontal ? layout.x : layout.y; -@@ -547,6 +579,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -493,6 +563,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + setTimeout, + isUnmounted, + updateScrollOffsetWithCallback, ++ queueProgrammaticScroll, + ]); + const applyInitialScrollIndex = useCallback(() => { + var _a, _b, _c; +@@ -547,6 +618,12 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, + isScrollingProgrammatically, ++ isScrolling, + runAfterProgrammaticScroll, + notifyProgrammaticScrollSettled, ++ notifyScrollActive, ++ notifyScrollSettled, }; } //# sourceMappingURL=useRecyclerViewController.js.map \ No newline at end of file +diff --git a/node_modules/@shopify/flash-list/src/FlashListRef.ts b/node_modules/@shopify/flash-list/src/FlashListRef.ts +index 07bac2a..beb2f72 100644 +--- a/node_modules/@shopify/flash-list/src/FlashListRef.ts ++++ b/node_modules/@shopify/flash-list/src/FlashListRef.ts +@@ -181,6 +181,31 @@ export interface FlashListRef { + */ + scrollToIndex: (params: ScrollToIndexParams) => Promise; + ++ /** ++ * Announces an imminent programmatic scroll before `scrollToIndex` is ++ * actually called, so DOM-mutating side-effects gated on ++ * `isScrollingProgrammatically()` (notably the on-web sort applied by ++ * `ViewHolderCollection`) defer until the upcoming smooth scroll ++ * settles, rather than running synchronously and cancelling it. ++ * ++ * Useful when the focus assignment happens first and `scrollToIndex` ++ * follows a few ticks later — as long as the call is guaranteed to ++ * happen, queue it up front so the intervening `focusin` doesn't ++ * trigger an immediate sort that the smooth scroll would then cancel. ++ * ++ * Cleared automatically when the next `scrollToIndex` is invoked ++ * (handed off to the in-flight flag) and again when the resulting ++ * scroll's momentum ends. Safe to call multiple times. ++ * ++ * @example ++ * listRef.current?.queueProgrammaticScroll(); ++ * itemDomNode.focus(); ++ * setTimeout(() => { ++ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); ++ * }, 0); ++ */ ++ queueProgrammaticScroll: () => void; ++ + /** + * Scrolls to a specific item in the list. + * diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index e8cd7d10162d..ad1c19741256 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -141,7 +141,10 @@ function BaseSelectionListWithSections({ }, setHasKeyBeenPressed, isFocused: isScreenFocused, - onArrowUpDownCallback: () => setShouldDisableHoverStyle(true), + onArrowUpDownCallback: () => { + setShouldDisableHoverStyle(true); + listRef.current?.queueProgrammaticScroll(); + }, }); const getFocusedItem = (): TItem | undefined => { From 805ae9891301c45243380adcfe361210e29065f2 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Mon, 4 May 2026 19:35:24 +0200 Subject: [PATCH 05/13] Change description, fix test --- patches/@shopify/flash-list/details.md | 22 ++++++++++++++++---- tests/unit/BaseSelectionListSectionsTest.tsx | 8 +++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 0a5a75f11452..c762fa8290f5 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -59,6 +59,7 @@ ### [@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch) - Reason: Fixes scrambled DOM order in virtualized list items on web. FlashList uses `position: absolute` to position items, so visual order is determined by CSS `top`/`left` values rather than DOM order. Due to recycling (reusing ViewHolder components for different data items), the DOM order reflects Map insertion order rather than data index order. This causes three web-specific issues: + 1. **Screen reader reading order**: Assistive technologies follow DOM order, so items are read in a scrambled sequence that doesn't match the visual layout. 2. **Keyboard Tab navigation**: Tab key follows DOM order, so focus jumps unpredictably between items instead of following the visual top-to-bottom sequence. 3. **Cross-item text selection**: Selecting text across multiple list items selects them in DOM order rather than visual order, producing garbled selections. @@ -67,17 +68,30 @@ 1. **Stable render order during scroll**: Render entries are maintained in a ref (`renderEntriesRef`) that preserves its order across renders. On each render, a reconcile step removes keys that left the render stack and appends new keys. Because FlashList's recycling mutates index values in place on shared object references (`keyInfo.index = newIndex`), the entries in the ref always have current index values without needing updates — only the array order can be stale. This means during normal scrolling, React sees children in the same order and produces zero `insertBefore` calls, avoiding any DOM reordering. - 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a `useEffect` sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. A separate state counter (`sortId`) triggers this re-render instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. + 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a `setTimeout` (handle held in `pendingSortTimeoutRef`, single-slot) sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. When the timer fires, it re-checks scroll state via `isScrolling()` — if any scroll is still in progress (a freshly started mousewheel, a continued momentum scroll, etc.), the timer reschedules itself rather than committing, so a long-running scroll never lets a stale timer fire in the middle of motion. The sort uses a separate, sort-only re-render trigger (`bumpSortVersion` from a `useReducer` counter) instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. + + 3. **Immediate sort on keyboard focus** (default `RECENT_FOCUS_WINDOW_MS` = 400ms): Tab navigation (and screen-reader element navigation that moves DOM focus) walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container calls `maybeDoSort` when focus enters the list, reconciling the order the moment the user tabs in. Tab itself normally doesn't scroll — but when the user tabs to a row that's currently outside the viewport, the browser auto-scrolls to bring it into view (typically centring it), and that scroll re-renders the list. A scroll-driven re-render would normally take the deferred (1s) path to let queued hover events drain; that's wrong mid-Tab, because the DOM would stay out of order while the user keeps tabbing. The sort effect therefore tracks the last `focusin` time, and when the render stack changes within `RECENT_FOCUS_WINDOW_MS` of that timestamp it also routes through `maybeDoSort` — i.e. the threshold answers "have we just tabbed?": if yes, we treat the re-render as tab-induced and sort right away rather than deferring. By keying on focus *recency* rather than focus *presence*, this distinguishes tab-triggered re-renders (sort right away) from scroll-triggered re-renders that happen while an element is still focused (defer the sort to protect hover state and any in-flight scroll). + + 4. **Unified scroll-aware gating via `maybeDoSort`**: Both focus-driven entry points (the `focusin` listener and the recent-focus branch of the sort effect) funnel through `maybeDoSort`, which picks one of three branches based on the current scroll state: + - *Programmatic scroll queued or in flight* (`isScrollingProgrammatically()` is true): the sort is held off via `runAfterProgrammaticScroll`. Once the scroll settles, the held callback hands off to `schedulePendingSort` so we still wait an additional `SORT_DELAY_MS` for queued pointer/focus events to land before committing. + - *Any other scroll in progress* (`isScrolling()` is true — touch drag, mousewheel, scrollbar drag, etc.): the sort is rescheduled via `schedulePendingSort`. + - *List is idle*: the sort runs synchronously — the fast-track that keeps Tab navigation snappy. - 3. **Immediate sort on keyboard focus** (default `TAB_SCROLL_THRESHOLD_MS` = 400ms): A `focusin` event listener on the container immediately sorts when focus enters the list, ensuring Tab navigation always follows the correct order without waiting for the deferred timeout. Additionally, the sort effect checks if focus occurred recently (within `TAB_SCROLL_THRESHOLD_MS`) to also sort immediately on tab-triggered scroll re-renders. By checking the recency of focus rather than its presence, this correctly distinguishes tab-triggered re-renders (sort immediately) from scroll-triggered re-renders that happen while an element is still focused (defer sort to protect hover). + Every entry into `maybeDoSort` first evicts any existing pending-sort timer, so a stale timer from a previous scroll's drain cannot fire mid-motion during rapid-fire arrow-key navigation (where `isMomentumEnd` doesn't fire between key-repeats and the same `pendingSortTimeoutRef` is the only one we ever own). The "scroll has truly ended" signal driving the programmatic-defer drain is FlashList's existing `isMomentumEnd`, fired by `VelocityTracker` ~100ms after the last `scroll` event — distance-independent and naturally overlap-safe (the browser merges overlapping smooth scrolls into one). + + 5. **Pre-scroll announcement (`queueProgrammaticScroll`)**: A new public method on `FlashListRef` lets the consumer announce an imminent programmatic scroll *before* `scrollToIndex` is actually called. It flips an "is queued" ref that `isScrollingProgrammatically()` already ORs in, so any sort triggered by an intervening event (notably the `focusin` that fires when the consumer focuses the target row first and only then calls `scrollToIndex`) is correctly held off rather than committing immediately and cancelling the upcoming smooth scroll. The queued flag is handed off to the in-flight ref at `scrollToIndex` entry and finally cleared when the scroll settles, so it cannot get stuck on. **Why the deferred approach is necessary:** - When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. The stable-ref approach ensures that during scrolling the array order doesn't change (no `insertBefore`), and the sort only fires after scrolling pauses, giving the browser time to process hover state changes. + Two distinct web-only hazards make immediate, mid-scroll DOM reordering wrong: + + 1. **Hover/pointer state loss**: When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. Keeping the array order stable during scrolling and only committing after the list goes idle gives the browser time to drain those events before any reorder. + + 2. **Smooth-scroll cancellation by `restoreSelection`**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The `maybeDoSort` gating above keeps every commit out of the smooth-scroll window, so the commit lands only after the animation has truly ended (`isMomentumEnd`). The pre-existing 200/300ms timer in `finishScrollToIndex` is *not* used as the settle signal — it is a fixed heuristic for `pauseOffsetCorrection`'s lifecycle and fires mid-animation on long scrolls. **Platform gating:** - On web: reconcile preserves order, deferred sort, focusin listener. + On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener triggers an immediate sort on tab-in, and every sort path is routed through `maybeDoSort`'s scroll-state gate. On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. - Upstream PR/issue: https://github.com/Shopify/flash-list/issues/1955 diff --git a/tests/unit/BaseSelectionListSectionsTest.tsx b/tests/unit/BaseSelectionListSectionsTest.tsx index a8ca88db0db5..9aa6683ed13d 100644 --- a/tests/unit/BaseSelectionListSectionsTest.tsx +++ b/tests/unit/BaseSelectionListSectionsTest.tsx @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; // Captures scrollToIndex calls so tests can assert on scroll behaviour const mockScrollToIndex = jest.fn(); +const mockQueueProgrammaticScroll = jest.fn(); // Mock FlashList jest.mock('@shopify/flash-list', () => { @@ -19,7 +20,7 @@ jest.mock('@shopify/flash-list', () => { const RN = jest.requireActual('react-native'); const FlashList = ReactLocal.forwardRef< - {scrollToIndex: (params: {index: number}) => void}, + {scrollToIndex: (params: {index: number}) => void; queueProgrammaticScroll: () => void}, Omit, 'children'> & { data?: unknown[]; renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; @@ -51,7 +52,10 @@ jest.mock('@shopify/flash-list', () => { }, ref, ) => { - ReactLocal.useImperativeHandle(ref, () => ({scrollToIndex: mockScrollToIndex})); + ReactLocal.useImperativeHandle(ref, () => ({ + scrollToIndex: mockScrollToIndex, + queueProgrammaticScroll: mockQueueProgrammaticScroll, + })); return ReactLocal.createElement( RN.ScrollView, From 14a263e242eedd1a5c80f86555d55b6b2d6748c5 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Mon, 4 May 2026 19:52:50 +0200 Subject: [PATCH 06/13] Update patch description --- patches/@shopify/flash-list/details.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index c762fa8290f5..27b129384741 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -68,7 +68,7 @@ 1. **Stable render order during scroll**: Render entries are maintained in a ref (`renderEntriesRef`) that preserves its order across renders. On each render, a reconcile step removes keys that left the render stack and appends new keys. Because FlashList's recycling mutates index values in place on shared object references (`keyInfo.index = newIndex`), the entries in the ref always have current index values without needing updates — only the array order can be stale. This means during normal scrolling, React sees children in the same order and produces zero `insertBefore` calls, avoiding any DOM reordering. - 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a `setTimeout` (handle held in `pendingSortTimeoutRef`, single-slot) sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. When the timer fires, it re-checks scroll state via `isScrolling()` — if any scroll is still in progress (a freshly started mousewheel, a continued momentum scroll, etc.), the timer reschedules itself rather than committing, so a long-running scroll never lets a stale timer fire in the middle of motion. The sort uses a separate, sort-only re-render trigger (`bumpSortVersion` from a `useReducer` counter) instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. + 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a single-slot `setTimeout` (armed by `schedulePendingSort`, handle in `pendingSortTimeoutRef`) sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. When the timer fires, it re-checks scroll state via `isScrolling()` — if any scroll is still in progress (a freshly started mousewheel, a continued momentum scroll, etc.), the timer reschedules itself rather than committing, so a long-running scroll never lets a stale timer fire in the middle of motion. The sort uses a separate, sort-only re-render trigger (`bumpSortVersion` from a `useReducer` counter) instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. 3. **Immediate sort on keyboard focus** (default `RECENT_FOCUS_WINDOW_MS` = 400ms): Tab navigation (and screen-reader element navigation that moves DOM focus) walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container calls `maybeDoSort` when focus enters the list, reconciling the order the moment the user tabs in. Tab itself normally doesn't scroll — but when the user tabs to a row that's currently outside the viewport, the browser auto-scrolls to bring it into view (typically centring it), and that scroll re-renders the list. A scroll-driven re-render would normally take the deferred (1s) path to let queued hover events drain; that's wrong mid-Tab, because the DOM would stay out of order while the user keeps tabbing. The sort effect therefore tracks the last `focusin` time, and when the render stack changes within `RECENT_FOCUS_WINDOW_MS` of that timestamp it also routes through `maybeDoSort` — i.e. the threshold answers "have we just tabbed?": if yes, we treat the re-render as tab-induced and sort right away rather than deferring. By keying on focus *recency* rather than focus *presence*, this distinguishes tab-triggered re-renders (sort right away) from scroll-triggered re-renders that happen while an element is still focused (defer the sort to protect hover state and any in-flight scroll). @@ -87,11 +87,11 @@ 1. **Hover/pointer state loss**: When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. Keeping the array order stable during scrolling and only committing after the list goes idle gives the browser time to drain those events before any reorder. - 2. **Smooth-scroll cancellation by `restoreSelection`**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The `maybeDoSort` gating above keeps every commit out of the smooth-scroll window, so the commit lands only after the animation has truly ended (`isMomentumEnd`). The pre-existing 200/300ms timer in `finishScrollToIndex` is *not* used as the settle signal — it is a fixed heuristic for `pauseOffsetCorrection`'s lifecycle and fires mid-animation on long scrolls. + 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The `maybeDoSort` gating above keeps every commit out of the smooth-scroll window, so the commit lands only after the animation has truly ended (`isMomentumEnd`). **Platform gating:** - On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener triggers an immediate sort on tab-in, and every sort path is routed through `maybeDoSort`'s scroll-state gate. + On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener triggers an immediate sort on tab-in, and every sort is scroll-state-gated — synchronous attempts go through `maybeDoSort`, deferred ones through `schedulePendingSort`'s timer-fire check. On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. - Upstream PR/issue: https://github.com/Shopify/flash-list/issues/1955 From ac1843277b5c7275a4337a270f581da8e37bd31b Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Tue, 5 May 2026 14:24:28 +0200 Subject: [PATCH 07/13] Remove isScrolling guard from maybeDoSort --- ...flash-list+2.3.0+008+sort-for-natural-DOM-order.patch | 9 ++------- patches/@shopify/flash-list/details.md | 9 ++++----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch index b46ef7e2664d..f4caf629f27f 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch @@ -90,7 +90,7 @@ index 9f3b776..c153718 100644 renderFooter), stickyHeaderIndices && stickyHeaderIndices.length > 0 diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..10d648a 100644 +index 8e3db51..9432131 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js @@ -3,17 +3,20 @@ @@ -116,7 +116,7 @@ index 8e3db51..10d648a 100644 const [renderId, setRenderId] = React.useState(0); const containerLayout = getChildContainerLayout(); const fixedContainerSize = horizontal -@@ -72,9 +75,115 @@ export const ViewHolderCollection = (props) => { +@@ -72,9 +75,110 @@ export const ViewHolderCollection = (props) => { // return `${index} => ${reactKey}`; // }) // ); @@ -175,14 +175,9 @@ index 8e3db51..10d648a 100644 + runAfterProgrammaticScroll(() => schedulePendingSort()); + return; + } -+ if (isScrolling()) { -+ schedulePendingSort(); -+ return; -+ } + doSort(); + }, [ + isScrollingProgrammatically, -+ isScrolling, + runAfterProgrammaticScroll, + schedulePendingSort, + clearPendingSort, diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 27b129384741..dc1b4eede793 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -72,10 +72,9 @@ 3. **Immediate sort on keyboard focus** (default `RECENT_FOCUS_WINDOW_MS` = 400ms): Tab navigation (and screen-reader element navigation that moves DOM focus) walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container calls `maybeDoSort` when focus enters the list, reconciling the order the moment the user tabs in. Tab itself normally doesn't scroll — but when the user tabs to a row that's currently outside the viewport, the browser auto-scrolls to bring it into view (typically centring it), and that scroll re-renders the list. A scroll-driven re-render would normally take the deferred (1s) path to let queued hover events drain; that's wrong mid-Tab, because the DOM would stay out of order while the user keeps tabbing. The sort effect therefore tracks the last `focusin` time, and when the render stack changes within `RECENT_FOCUS_WINDOW_MS` of that timestamp it also routes through `maybeDoSort` — i.e. the threshold answers "have we just tabbed?": if yes, we treat the re-render as tab-induced and sort right away rather than deferring. By keying on focus *recency* rather than focus *presence*, this distinguishes tab-triggered re-renders (sort right away) from scroll-triggered re-renders that happen while an element is still focused (defer the sort to protect hover state and any in-flight scroll). - 4. **Unified scroll-aware gating via `maybeDoSort`**: Both focus-driven entry points (the `focusin` listener and the recent-focus branch of the sort effect) funnel through `maybeDoSort`, which picks one of three branches based on the current scroll state: + 4. **Programmatic-scroll gating via `maybeDoSort`**: Both focus-driven entry points (the `focusin` listener and the recent-focus branch of the sort effect) funnel through `maybeDoSort`, which picks one of two branches: - *Programmatic scroll queued or in flight* (`isScrollingProgrammatically()` is true): the sort is held off via `runAfterProgrammaticScroll`. Once the scroll settles, the held callback hands off to `schedulePendingSort` so we still wait an additional `SORT_DELAY_MS` for queued pointer/focus events to land before committing. - - *Any other scroll in progress* (`isScrolling()` is true — touch drag, mousewheel, scrollbar drag, etc.): the sort is rescheduled via `schedulePendingSort`. - - *List is idle*: the sort runs synchronously — the fast-track that keeps Tab navigation snappy. + - *Otherwise*: the sort runs synchronously. This deliberately fires even mid-scroll on web — Tab navigation walks DOM order, so during a tab-induced browser auto-scroll-into-view we want the sort to commit immediately, even at the cost of perturbing that auto-scroll, because falling back to the deferred path would leave the DOM stale while the user keeps tabbing and risk Tab landing on the wrong (or out-of-bounds) row. User-initiated scrolls without a recent focus take a different code path that *does* defer (see #2 — that branch goes directly to `schedulePendingSort` without passing through `maybeDoSort`). Every entry into `maybeDoSort` first evicts any existing pending-sort timer, so a stale timer from a previous scroll's drain cannot fire mid-motion during rapid-fire arrow-key navigation (where `isMomentumEnd` doesn't fire between key-repeats and the same `pendingSortTimeoutRef` is the only one we ever own). The "scroll has truly ended" signal driving the programmatic-defer drain is FlashList's existing `isMomentumEnd`, fired by `VelocityTracker` ~100ms after the last `scroll` event — distance-independent and naturally overlap-safe (the browser merges overlapping smooth scrolls into one). @@ -87,11 +86,11 @@ 1. **Hover/pointer state loss**: When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. Keeping the array order stable during scrolling and only committing after the list goes idle gives the browser time to drain those events before any reorder. - 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The `maybeDoSort` gating above keeps every commit out of the smooth-scroll window, so the commit lands only after the animation has truly ended (`isMomentumEnd`). + 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The `maybeDoSort` gating above keeps programmatic-scroll commits out of the smooth-scroll window, so a `scrollToIndex` animation lands only after it has truly ended (`isMomentumEnd`). Browser auto-scroll-into-view triggered by Tab focusing an off-viewport row is intentionally *not* gated this way (see #4 above) — Tab-navigation correctness takes priority over preserving that auto-scroll's centring. **Platform gating:** - On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener triggers an immediate sort on tab-in, and every sort is scroll-state-gated — synchronous attempts go through `maybeDoSort`, deferred ones through `schedulePendingSort`'s timer-fire check. + On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener triggers an immediate sort on tab-in, and `maybeDoSort` defers only when a programmatic scroll is queued or in flight. The deferred path itself reschedules until any scroll has settled, via `schedulePendingSort`'s timer-fire `isScrolling()` re-check. On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. - Upstream PR/issue: https://github.com/Shopify/flash-list/issues/1955 From 26376ce3e877cfb682c551c533b00fe6c152c929 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Tue, 5 May 2026 17:01:55 +0200 Subject: [PATCH 08/13] Refactor to a hook --- ...2.3.0+008+sort-for-natural-DOM-order.patch | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch index f4caf629f27f..c54f712e94be 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch @@ -90,10 +90,10 @@ index 9f3b776..c153718 100644 renderFooter), stickyHeaderIndices && stickyHeaderIndices.length > 0 diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..9432131 100644 +index 8e3db51..bb64340 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -3,17 +3,20 @@ +@@ -3,17 +3,51 @@ * It handles the rendering of a collection of list items, manages layout updates, * and coordinates with the RecyclerView context for layout changes. */ @@ -105,6 +105,37 @@ index 8e3db51..9432131 100644 import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; +const SORT_DELAY_MS = 1000; +const RECENT_FOCUS_WINDOW_MS = 400; ++/** ++ * Single-slot setTimeout with a fire-time gate. Calling `schedule` again ++ * replaces any pending fire. When the timer expires, if `shouldDefer()` ++ * returns true the timer reschedules itself instead of invoking ++ * `callback`. Auto-cancels on unmount. ++ * ++ * @returns A tuple of `[schedule, cancel]`. `schedule` arms (or re-arms) ++ * the timer; `cancel` evicts whatever is in the slot. ++ */ ++function useDeferredCallback(callback, delayMs, shouldDefer) { ++ const timeoutRef = useRef(null); ++ const cancel = useCallback(() => { ++ if (timeoutRef.current !== null) { ++ clearTimeout(timeoutRef.current); ++ timeoutRef.current = null; ++ } ++ }, []); ++ const schedule = useCallback(() => { ++ cancel(); ++ timeoutRef.current = setTimeout(() => { ++ if (shouldDefer()) { ++ schedule(); ++ return; ++ } ++ timeoutRef.current = null; ++ callback(); ++ }, delayMs); ++ }, [callback, delayMs, shouldDefer, cancel]); ++ useEffect(() => cancel, [cancel]); ++ return [schedule, cancel]; ++} /** * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances * and handles layout updates for the entire collection @@ -116,7 +147,7 @@ index 8e3db51..9432131 100644 const [renderId, setRenderId] = React.useState(0); const containerLayout = getChildContainerLayout(); const fixedContainerSize = horizontal -@@ -72,9 +75,110 @@ export const ViewHolderCollection = (props) => { +@@ -72,9 +106,89 @@ export const ViewHolderCollection = (props) => { // return `${index} => ${reactKey}`; // }) // ); @@ -125,9 +156,6 @@ index 8e3db51..9432131 100644 + const lastFocusTimeRef = useRef(0); + const renderEntriesRef = useRef(Array.from(renderStack.entries())); + const [, bumpSortVersion] = useReducer((x) => x + 1, 0); -+ // At most one deferred-sort timer is alive at a time. Any code path that -+ // is about to sort (or schedule a new sort) must first clear this. -+ const pendingSortTimeoutRef = useRef(null); + const doSort = useCallback(() => { + const entries = renderEntriesRef.current; + const direction = inverted ? -1 : 1; @@ -139,25 +167,7 @@ index 8e3db51..9432131 100644 + bumpSortVersion(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inverted]); -+ const clearPendingSort = useCallback(() => { -+ if (pendingSortTimeoutRef.current !== null) { -+ clearTimeout(pendingSortTimeoutRef.current); -+ pendingSortTimeoutRef.current = null; -+ } -+ }, []); -+ const schedulePendingSort = useCallback(() => { -+ clearPendingSort(); -+ pendingSortTimeoutRef.current = setTimeout(() => { -+ if (isScrolling()) { -+ schedulePendingSort(); -+ return; -+ } -+ pendingSortTimeoutRef.current = null; -+ doSort(); -+ }, SORT_DELAY_MS); -+ // `schedulePendingSort` self-references for the reschedule path; closure -+ // resolution at call-time is correct here. -+ }, [clearPendingSort, doSort, isScrolling]); ++ const [schedulePendingSort, clearPendingSort] = useDeferredCallback(doSort, SORT_DELAY_MS, isScrolling); + // Schedules or commits a sort, deferring while a programmatic scroll is + // in flight (otherwise the sort's `insertBefore` calls would let React's + // selection-preservation logic write `scrollTop`, cancelling the smooth @@ -172,7 +182,7 @@ index 8e3db51..9432131 100644 + // nav (where `isMomentumEnd` doesn't fire between presses). + clearPendingSort(); + if (isScrollingProgrammatically()) { -+ runAfterProgrammaticScroll(() => schedulePendingSort()); ++ runAfterProgrammaticScroll(schedulePendingSort); + return; + } + doSort(); From 329938462b9fa761f68080a5003051748f99717d Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Thu, 7 May 2026 20:54:48 +0200 Subject: [PATCH 09/13] Refactor to use new approach --- ...2.3.0+008+sort-for-natural-DOM-order.patch | 321 +++++++++++------- 1 file changed, 198 insertions(+), 123 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch index c54f712e94be..90896b6ebb28 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -index 08b83f3..27ed1ae 100644 +index 08b83f3..b58e59a 100644 --- a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts +++ b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts @@ -167,6 +167,30 @@ export interface FlashListRef { @@ -13,28 +13,28 @@ index 08b83f3..27ed1ae 100644 + * `ViewHolderCollection`) defer until the upcoming smooth scroll + * settles, rather than running synchronously and cancelling it. + * -+ * Useful for keyboard-navigation hooks that focus the new item first -+ * and call `scrollToIndex` afterwards: call `queueProgrammaticScroll()` -+ * before the focus assignment so the resulting `focusin` doesn't -+ * trigger an immediate sort that would later be cancelled by the -+ * smooth scroll. ++ * Useful when the focus assignment happens first and `scrollToIndex` ++ * follows a few ticks later — as long as the call is guaranteed to ++ * happen, queue it up front so the intervening `focusin` doesn't ++ * trigger an immediate sort that the smooth scroll would then cancel. + * + * Cleared automatically when the next `scrollToIndex` is invoked + * (handed off to the in-flight flag) and again when the resulting + * scroll's momentum ends. Safe to call multiple times. + * + * @example -+ * // Inside an arrow-key navigation hook: + * listRef.current?.queueProgrammaticScroll(); + * itemDomNode.focus(); -+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); ++ * setTimeout(() => { ++ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); ++ * }, 0); + */ + queueProgrammaticScroll: () => void; /** * Scrolls to a specific item in the list. * diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -index 9f3b776..c153718 100644 +index 9f3b776..f1b7668 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js @@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => { @@ -42,21 +42,17 @@ index 9f3b776..c153718 100644 // Initialize core RecyclerView manager and content offset management const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props); - const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); -+ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, notifyScrollActive, notifyScrollSettled, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); ++ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, notifyScrollActive, notifyScrollSettled, getLastScrollTime, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef); // Initialize view holder collection ref const viewHolderCollectionRef = useRef(null); // Hook to handle list loading -@@ -238,12 +238,23 @@ const RecyclerViewComponent = (props, ref) => { +@@ -238,12 +238,19 @@ const RecyclerViewComponent = (props, ref) => { return; } if (isMomentumEnd) { -+ // See `is-scrolling-flag.md`. + notifyScrollSettled(); -+ // Drain any pending callback registered via -+ // `runAfterProgrammaticScroll` BEFORE the early return below -+ // so the drain still fires while offset projection is still -+ // disabled. This is the moment FlashList's `VelocityTracker` -+ // confirms the browser-native smooth scroll has truly settled. ++ // Drain BEFORE the early return below so the drain still ++ // fires while offset projection is still disabled. + notifyProgrammaticScrollSettled(); computeFirstVisibleIndexForOffsetCorrection(); if (!recyclerViewManager.isOffsetProjectionEnabled) { @@ -70,7 +66,7 @@ index 9f3b776..c153718 100644 // Update scroll position and trigger re-render if needed if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { setRenderId((prev) => prev + 1); -@@ -266,6 +277,9 @@ const RecyclerViewComponent = (props, ref) => { +@@ -266,6 +273,9 @@ const RecyclerViewComponent = (props, ref) => { computeFirstVisibleIndexForOffsetCorrection, horizontal, isHorizontalRTL, @@ -80,20 +76,63 @@ index 9f3b776..c153718 100644 recyclerViewManager, velocityTracker, ]); -@@ -458,7 +472,7 @@ const RecyclerViewComponent = (props, ref) => { +@@ -458,7 +468,7 @@ const RecyclerViewComponent = (props, ref) => { recyclerViewManager.animationOptimizationsEnabled = false; }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout() ? recyclerViewManager.getChildContainerDimensions() - : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted }), -+ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, isScrolling: isScrolling, runAfterProgrammaticScroll: runAfterProgrammaticScroll }), ++ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, isScrolling: isScrolling, runAfterProgrammaticScroll: runAfterProgrammaticScroll, getLastScrollTime: getLastScrollTime }), renderEmpty, renderFooter), stickyHeaderIndices && stickyHeaderIndices.length > 0 +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js +index 0df2879..f639313 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js +@@ -3,9 +3,11 @@ + * It handles the rendering of list items, separators, and manages layout updates for each item. + * The component is memoized to prevent unnecessary re-renders and includes layout comparison logic. + */ ++import { Platform } from "react-native"; + import React, { useCallback, useLayoutEffect, useMemo, useRef, } from "react"; + import { CompatView } from "./components/CompatView"; + import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle"; ++const INVISIBLE_MARKER_STYLE = { display: "none" }; + /** + * Internal ViewHolder component that handles the actual rendering of list items + * @template TItem - The type of item being rendered in the list +@@ -57,6 +59,7 @@ const ViewHolderInternal = (props) => { + const CompatContainer = (CellRendererComponent !== null && CellRendererComponent !== void 0 ? CellRendererComponent : CompatView); + return (React.createElement(CompatContainer, { ref: viewRef, onLayout: onLayout, style: style, index: index }, + children, ++ Platform.OS === "web" && (React.createElement("div", { "data-flashlist-index": index, "aria-hidden": true, style: INVISIBLE_MARKER_STYLE })), + separator)); + }; + /** +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts +index c37c4f3..fd2ff94 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts +@@ -54,6 +54,14 @@ export interface ViewHolderCollectionProps { + isInLastRow: (index: number) => boolean; + /** Whether the list is inverted */ + inverted: FlashListProps["inverted"]; ++ /** True while a programmatic scroll is queued or in flight. */ ++ isScrollingProgrammatically: () => boolean; ++ /** True while any scroll is in flight. */ ++ isScrolling: () => boolean; ++ /** Register a callback to run when the current programmatic-scroll animation settles. */ ++ runAfterProgrammaticScroll: (cb: () => void) => void; ++ /** Returns the timestamp (`Date.now()`) of the most recent scroll event, or 0 if none. */ ++ getLastScrollTime: () => number; + } + /** + * Ref interface for ViewHolderCollection that exposes methods to control layout updates diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..bb64340 100644 +index 8e3db51..f3aa0d8 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -@@ -3,17 +3,51 @@ +@@ -3,17 +3,81 @@ * It handles the rendering of a collection of list items, manages layout updates, * and coordinates with the RecyclerView context for layout changes. */ @@ -104,7 +143,9 @@ index 8e3db51..bb64340 100644 import { CompatView } from "./components/CompatView"; import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; +const SORT_DELAY_MS = 1000; -+const RECENT_FOCUS_WINDOW_MS = 400; ++// Max gap from last `focusin` to last `scroll` event for the scroll to ++// count as a focus-induced auto-scroll-into-view (vs a user-driven scroll). ++const FOCUS_INDUCED_SCROLL_WINDOW_MS = 30; +/** + * Single-slot setTimeout with a fire-time gate. Calling `schedule` again + * replaces any pending fire. When the timer expires, if `shouldDefer()` @@ -135,6 +176,34 @@ index 8e3db51..bb64340 100644 + }, [callback, delayMs, shouldDefer, cancel]); + useEffect(() => cancel, [cancel]); + return [schedule, cancel]; ++} ++/** ++ * Walks up from `target` to find a `data-flashlist-index` marker among ++ * a parent's direct children, returning the marker's `index` and the ++ * walk-up `depth` (number of `parentElement` hops). Iterates siblings ++ * last-to-first — the marker sits between `{children}` and `{separator}` ++ * inside the ViewHolder, so it's near the end. Returns `null` if no ++ * marker is found before reaching `root`. ++ */ ++function findFocusedIndexFromMarker(target, root) { ++ var _a; ++ let current = target; ++ let depth = 0; ++ while (current && current !== root) { ++ const parent = current.parentElement; ++ if (!parent) ++ break; ++ for (let i = parent.children.length - 1; i >= 0; i--) { ++ const child = parent.children[i]; ++ const idxStr = (_a = child.dataset) === null || _a === void 0 ? void 0 : _a.flashlistIndex; ++ if (idxStr != null) { ++ return { index: Number(idxStr), depth }; ++ } ++ } ++ current = parent; ++ depth++; ++ } ++ return null; +} /** * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances @@ -143,17 +212,20 @@ index 8e3db51..bb64340 100644 */ export const ViewHolderCollection = (props) => { - const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, } = props; -+ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, } = props; ++ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, getLastScrollTime, } = props; const [renderId, setRenderId] = React.useState(0); const containerLayout = getChildContainerLayout(); const fixedContainerSize = horizontal -@@ -72,9 +106,89 @@ export const ViewHolderCollection = (props) => { +@@ -72,9 +136,130 @@ export const ViewHolderCollection = (props) => { // return `${index} => ${reactKey}`; // }) // ); - return (React.createElement(CompatView, { style: hasData && containerStyle }, containerLayout && + const containerRef = useRef(null); + const lastFocusTimeRef = useRef(0); ++ const lastFocusedIndexRef = useRef(null); ++ const lastFocusedDepthRef = useRef(null); ++ const shouldSortOnNextFocusRef = useRef(false); + const renderEntriesRef = useRef(Array.from(renderStack.entries())); + const [, bumpSortVersion] = useReducer((x) => x + 1, 0); + const doSort = useCallback(() => { @@ -168,30 +240,58 @@ index 8e3db51..bb64340 100644 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inverted]); + const [schedulePendingSort, clearPendingSort] = useDeferredCallback(doSort, SORT_DELAY_MS, isScrolling); -+ // Schedules or commits a sort, deferring while a programmatic scroll is -+ // in flight (otherwise the sort's `insertBefore` calls would let React's -+ // selection-preservation logic write `scrollTop`, cancelling the smooth -+ // scroll animation — the "starts and freezes" bug on web). When the -+ // scroll settles we wait an additional `SORT_DELAY_MS` so any pointer/ -+ // focus events queued during the animation can land before the commit. -+ const maybeDoSort = useCallback(() => { -+ // Evict any pending sort timer first. Either we're about to commit -+ // synchronously (the timer would be redundant), or we're about to -+ // defer — in which case a stale timer from a *previous* scroll's -+ // drain must die so it can't fire mid-scroll during rapid-fire arrow -+ // nav (where `isMomentumEnd` doesn't fire between presses). ++ const maybeDoSortOnFocus = useCallback(() => { + clearPendingSort(); + if (isScrollingProgrammatically()) { + runAfterProgrammaticScroll(schedulePendingSort); + return; + } -+ doSort(); ++ if (shouldSortOnNextFocusRef.current) { ++ shouldSortOnNextFocusRef.current = false; ++ doSort(); ++ } ++ schedulePendingSort(); + }, [ + isScrollingProgrammatically, ++ isScrolling, + runAfterProgrammaticScroll, + schedulePendingSort, + clearPendingSort, + doSort, ++ getLastScrollTime, ++ ]); ++ const maybeDoSortOnScroll = useCallback(() => { ++ shouldSortOnNextFocusRef.current = true; ++ // Evict any stale timer from a previous scroll's drain so it can't ++ // fire mid-scroll during rapid-fire arrow nav (where `isMomentumEnd` ++ // doesn't fire between key presses). ++ clearPendingSort(); ++ if (isScrollingProgrammatically()) { ++ runAfterProgrammaticScroll(schedulePendingSort); ++ return; ++ } ++ if (isScrolling()) { ++ // Focus-induced auto-scroll-into-view: sort sync to keep DOM ++ // aligned for the next Tab. User-driven scrolls (negative Δ or Δ ++ // past the window) defer to avoid sorting mid-mousewheel. ++ const scrollSinceFocus = getLastScrollTime() - lastFocusTimeRef.current; ++ const scrollNow = scrollSinceFocus >= 0 && ++ scrollSinceFocus < FOCUS_INDUCED_SCROLL_WINDOW_MS; ++ if (scrollNow) { ++ doSort(); ++ shouldSortOnNextFocusRef.current = false; ++ return; ++ } ++ } ++ schedulePendingSort(); ++ }, [ ++ isScrollingProgrammatically, ++ isScrolling, ++ runAfterProgrammaticScroll, ++ schedulePendingSort, ++ clearPendingSort, ++ doSort, ++ getLastScrollTime, + ]); + if (Platform.OS === "web") { + // Reconcile: remove stale keys, append new keys @@ -211,9 +311,24 @@ index 8e3db51..bb64340 100644 + if (Platform.OS !== "web" || !container) { + return; + } -+ const onFocusIn = () => { ++ const onFocusIn = (e) => { ++ var _a, _b; ++ // Filter spurious focusins (recycle re-focus, mutation-phase ++ // phantoms). ++ const focused = findFocusedIndexFromMarker(e.target, containerRef.current); ++ const focusedIndex = (_a = focused === null || focused === void 0 ? void 0 : focused.index) !== null && _a !== void 0 ? _a : null; ++ const focusedDepth = (_b = focused === null || focused === void 0 ? void 0 : focused.depth) !== null && _b !== void 0 ? _b : null; ++ const isSameLogicalRow = focusedIndex !== null && ++ focusedIndex === lastFocusedIndexRef.current && ++ focusedDepth === lastFocusedDepthRef.current; ++ const isPhantomMutationFocus = e.relatedTarget === null && focusedIndex !== null; ++ if (isSameLogicalRow || isPhantomMutationFocus) { ++ return; ++ } ++ lastFocusedIndexRef.current = focusedIndex; ++ lastFocusedDepthRef.current = focusedDepth; + lastFocusTimeRef.current = Date.now(); -+ maybeDoSort(); ++ maybeDoSortOnFocus(); + }; + container.addEventListener("focusin", onFocusIn); + return () => container.removeEventListener("focusin", onFocusIn); @@ -223,12 +338,7 @@ index 8e3db51..bb64340 100644 + if (Platform.OS !== "web") { + return; + } -+ const isRecentFocus = Date.now() - lastFocusTimeRef.current < RECENT_FOCUS_WINDOW_MS; -+ if (isRecentFocus) { -+ maybeDoSort(); -+ return; -+ } -+ schedulePendingSort(); ++ maybeDoSortOnScroll(); + return clearPendingSort; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renderStack, renderId]); @@ -239,39 +349,51 @@ index 8e3db51..bb64340 100644 const item = data[index]; // Suppress separators for items in the last row to prevent // height mismatch. The last data item has no separator (no +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts +index 62d55cd..b715484 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts +@@ -24,5 +24,12 @@ export declare function useRecyclerViewController(recyclerViewManager: Recycl + computeFirstVisibleIndexForOffsetCorrection: () => void; + applyInitialScrollIndex: () => void; + handlerMethods: FlashListRef; ++ isScrollingProgrammatically: () => boolean; ++ isScrolling: () => boolean; ++ runAfterProgrammaticScroll: (cb: () => void) => void; ++ notifyProgrammaticScrollSettled: () => void; ++ notifyScrollActive: () => void; ++ notifyScrollSettled: () => void; ++ getLastScrollTime: () => number; + }; + //# sourceMappingURL=useRecyclerViewController.d.ts.map +\ No newline at end of file diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 51b6f8c..84e9be6 100644 +index 51b6f8c..c016d21 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -@@ -25,6 +25,27 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -25,6 +25,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe const isUnmounted = useUnmountFlag(); const [_, setRenderId] = useState(0); const pauseOffsetCorrection = useRef(false); -+ // True for the full duration of an in-flight programmatic scroll -+ // (`scrollToIndex` / `scrollToOffset` etc.). Cleared exactly once when the -+ // browser-native smooth scroll truly settles, via `notifyProgrammaticScrollSettled` -+ // (which `RecyclerView.onScrollHandler` invokes from `isMomentumEnd`). -+ // Backs `isScrollingProgrammatically()` so consumers can defer DOM work -+ // that would otherwise cancel the in-flight smooth scroll on web (e.g. -+ // sort-driven `insertBefore` reorderings). ++ // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in ++ // flight. Cleared exactly once on `isMomentumEnd` via ++ // `notifyProgrammaticScrollSettled`. + const isProgrammaticScrollActiveRef = useRef(false); -+ // Set by the public `queueProgrammaticScroll()` API to announce that a -+ // `scrollToIndex` call is imminent. Lets `isScrollingProgrammatically()` -+ // start reporting true before the actual scroll starts — useful for -+ // keyboard-navigation hooks that focus the new item first and call -+ // `scrollToIndex` afterwards. Cleared at `scrollToIndex` entry (handoff -+ // to `isProgrammaticScrollActiveRef`) and again in -+ // `notifyProgrammaticScrollSettled` (defensive). ++ // Set by `queueProgrammaticScroll()` to announce an imminent scroll. ++ // Handed off to `isProgrammaticScrollActiveRef` at `scrollToIndex` entry. + const isProgrammaticScrollQueuedRef = useRef(false); + // Source-agnostic "viewport in motion" flag. + const isScrollingRef = useRef(false); ++ // Timestamp of the most recent scroll event; used to correlate scroll ++ // and focus events for the focus-induced-scroll heuristic. ++ const lastScrollTimeRef = useRef(0); + // Holds at most one callback registered via `runAfterProgrammaticScroll`, + // drained from `notifyProgrammaticScrollSettled`. + const pendingAfterScrollRef = useRef(null); const pendingAndroidInvertedRafId = useRef(null); const skipNextAndroidInvertedCorrection = useRef(false); const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); -@@ -180,6 +201,39 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -180,6 +195,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe updateScrollOffsetWithCallback, computeFirstVisibleIndexForOffsetCorrection, ]); @@ -281,20 +403,12 @@ index 51b6f8c..84e9be6 100644 + const runAfterProgrammaticScroll = useCallback((cb) => { + pendingAfterScrollRef.current = cb; + }, []); -+ // Public API: announces an imminent programmatic scroll before -+ // `scrollToIndex` is called. Lets consumers signal "scroll is about to -+ // happen" so DOM-mutating side-effects gated on -+ // `isScrollingProgrammatically()` defer until the upcoming smooth scroll -+ // settles, even if the consumer focuses the target first and scrolls -+ // afterwards. ++ // Public API; see `FlashListRef#queueProgrammaticScroll`. + const queueProgrammaticScroll = useCallback(() => { + isProgrammaticScrollQueuedRef.current = true; + }, []); -+ // Invoked from `RecyclerView.onScrollHandler` inside the existing -+ // `isMomentumEnd` branch — the moment FlashList's `VelocityTracker` -+ // confirms the browser-native smooth scroll has truly settled (~100ms -+ // after the last scroll event). Drains the pending callback registered -+ // via `runAfterProgrammaticScroll`, if any. ++ // Invoked from `RecyclerView.onScrollHandler` on `isMomentumEnd` (~100ms ++ // after the last scroll event). Drains the pending callback if any. + const notifyProgrammaticScrollSettled = useCallback(() => { + isProgrammaticScrollActiveRef.current = false; + isProgrammaticScrollQueuedRef.current = false; @@ -304,14 +418,16 @@ index 51b6f8c..84e9be6 100644 + }, []); + const notifyScrollActive = useCallback(() => { + isScrollingRef.current = true; ++ lastScrollTimeRef.current = Date.now(); + }, []); + const notifyScrollSettled = useCallback(() => { + isScrollingRef.current = false; + }, []); ++ const getLastScrollTime = useCallback(() => lastScrollTimeRef.current, []); const handlerMethods = useMemo(() => { return { get props() { -@@ -271,6 +325,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -271,6 +313,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe animated, }); }, @@ -323,25 +439,19 @@ index 51b6f8c..84e9be6 100644 /** * Scrolls to a specific index in the list. * Supports viewPosition and viewOffset for precise positioning. -@@ -289,6 +348,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -289,6 +336,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe // Pause the scroll offset adjustments pauseOffsetCorrection.current = true; recyclerViewManager.setOffsetProjectionEnabled(false); -+ // Mark a programmatic scroll as in flight. Cleared in -+ // `notifyProgrammaticScrollSettled` when `isMomentumEnd` fires, -+ // not by the 200/300 ms timer below — that timer is a fixed -+ // heuristic for re-enabling offset correction and unrelated to -+ // the actual smooth-scroll completion time. -+ // -+ // Also hand the "queued" flag off to the "active" flag here so -+ // a stale `queueProgrammaticScroll()` from before this call -+ // can't keep gating sorts indefinitely. ++ // Cleared on `isMomentumEnd` via `notifyProgrammaticScrollSettled`. ++ // Hand off "queued" → "active" here so any stale queue flag ++ // can't gate sorts indefinitely. + isProgrammaticScrollQueuedRef.current = false; + isProgrammaticScrollActiveRef.current = true; const getFinalOffset = () => { const layout = recyclerViewManager.getLayout(index); const offset = horizontal ? layout.x : layout.y; -@@ -493,6 +563,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -493,6 +545,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe setTimeout, isUnmounted, updateScrollOffsetWithCallback, @@ -349,7 +459,7 @@ index 51b6f8c..84e9be6 100644 ]); const applyInitialScrollIndex = useCallback(() => { var _a, _b, _c; -@@ -547,6 +618,12 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe +@@ -547,6 +600,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, @@ -359,43 +469,8 @@ index 51b6f8c..84e9be6 100644 + notifyProgrammaticScrollSettled, + notifyScrollActive, + notifyScrollSettled, ++ getLastScrollTime, }; } //# sourceMappingURL=useRecyclerViewController.js.map \ No newline at end of file -diff --git a/node_modules/@shopify/flash-list/src/FlashListRef.ts b/node_modules/@shopify/flash-list/src/FlashListRef.ts -index 07bac2a..beb2f72 100644 ---- a/node_modules/@shopify/flash-list/src/FlashListRef.ts -+++ b/node_modules/@shopify/flash-list/src/FlashListRef.ts -@@ -181,6 +181,31 @@ export interface FlashListRef { - */ - scrollToIndex: (params: ScrollToIndexParams) => Promise; - -+ /** -+ * Announces an imminent programmatic scroll before `scrollToIndex` is -+ * actually called, so DOM-mutating side-effects gated on -+ * `isScrollingProgrammatically()` (notably the on-web sort applied by -+ * `ViewHolderCollection`) defer until the upcoming smooth scroll -+ * settles, rather than running synchronously and cancelling it. -+ * -+ * Useful when the focus assignment happens first and `scrollToIndex` -+ * follows a few ticks later — as long as the call is guaranteed to -+ * happen, queue it up front so the intervening `focusin` doesn't -+ * trigger an immediate sort that the smooth scroll would then cancel. -+ * -+ * Cleared automatically when the next `scrollToIndex` is invoked -+ * (handed off to the in-flight flag) and again when the resulting -+ * scroll's momentum ends. Safe to call multiple times. -+ * -+ * @example -+ * listRef.current?.queueProgrammaticScroll(); -+ * itemDomNode.focus(); -+ * setTimeout(() => { -+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); -+ * }, 0); -+ */ -+ queueProgrammaticScroll: () => void; -+ - /** - * Scrolls to a specific item in the list. - * From bf5a2b449d7f0f4ce491ff33b5b27d14ee3b0afc Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Thu, 7 May 2026 20:59:43 +0200 Subject: [PATCH 10/13] Update details.md --- patches/@shopify/flash-list/details.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index dc1b4eede793..780336e9c3fc 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -70,13 +70,18 @@ 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a single-slot `setTimeout` (armed by `schedulePendingSort`, handle in `pendingSortTimeoutRef`) sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. When the timer fires, it re-checks scroll state via `isScrolling()` — if any scroll is still in progress (a freshly started mousewheel, a continued momentum scroll, etc.), the timer reschedules itself rather than committing, so a long-running scroll never lets a stale timer fire in the middle of motion. The sort uses a separate, sort-only re-render trigger (`bumpSortVersion` from a `useReducer` counter) instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls. - 3. **Immediate sort on keyboard focus** (default `RECENT_FOCUS_WINDOW_MS` = 400ms): Tab navigation (and screen-reader element navigation that moves DOM focus) walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container calls `maybeDoSort` when focus enters the list, reconciling the order the moment the user tabs in. Tab itself normally doesn't scroll — but when the user tabs to a row that's currently outside the viewport, the browser auto-scrolls to bring it into view (typically centring it), and that scroll re-renders the list. A scroll-driven re-render would normally take the deferred (1s) path to let queued hover events drain; that's wrong mid-Tab, because the DOM would stay out of order while the user keeps tabbing. The sort effect therefore tracks the last `focusin` time, and when the render stack changes within `RECENT_FOCUS_WINDOW_MS` of that timestamp it also routes through `maybeDoSort` — i.e. the threshold answers "have we just tabbed?": if yes, we treat the re-render as tab-induced and sort right away rather than deferring. By keying on focus *recency* rather than focus *presence*, this distinguishes tab-triggered re-renders (sort right away) from scroll-triggered re-renders that happen while an element is still focused (defer the sort to protect hover state and any in-flight scroll). + 3. **Focus-aware sort triggering**: Tab navigation walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container resolves which logical row received focus by reading a `data-flashlist-index` DOM marker that each `ViewHolder` renders alongside its children, and routes real focus changes to `maybeDoSortOnFocus`. Spurious refocus events caused by recycling and React's mutation-phase selection-preservation are filtered out so they don't trigger a sort cascade — see [viewholder-marker-and-focus-filter.md](viewholder-marker-and-focus-filter.md) for the full filter design. Tab itself doesn't scroll, but tabbing to a row that's outside the viewport makes the browser auto-scroll to bring it into view; that scroll re-renders the list and runs a separate `maybeDoSortOnScroll` callback. The actual synchronous sort during Tab navigation happens in the scroll callback (see #4); the focus callback typically just schedules a deferred sort. - 4. **Programmatic-scroll gating via `maybeDoSort`**: Both focus-driven entry points (the `focusin` listener and the recent-focus branch of the sort effect) funnel through `maybeDoSort`, which picks one of two branches: - - *Programmatic scroll queued or in flight* (`isScrollingProgrammatically()` is true): the sort is held off via `runAfterProgrammaticScroll`. Once the scroll settles, the held callback hands off to `schedulePendingSort` so we still wait an additional `SORT_DELAY_MS` for queued pointer/focus events to land before committing. - - *Otherwise*: the sort runs synchronously. This deliberately fires even mid-scroll on web — Tab navigation walks DOM order, so during a tab-induced browser auto-scroll-into-view we want the sort to commit immediately, even at the cost of perturbing that auto-scroll, because falling back to the deferred path would leave the DOM stale while the user keeps tabbing and risk Tab landing on the wrong (or out-of-bounds) row. User-initiated scrolls without a recent focus take a different code path that *does* defer (see #2 — that branch goes directly to `schedulePendingSort` without passing through `maybeDoSort`). + 4. **Two `maybeDoSort` callbacks + programmatic-scroll gating**: The focus path and the scroll path have different decisions to make, so the original single `maybeDoSort` is split into two callbacks that cooperate via a one-shot flag (`shouldSortOnNextFocusRef`): - Every entry into `maybeDoSort` first evicts any existing pending-sort timer, so a stale timer from a previous scroll's drain cannot fire mid-motion during rapid-fire arrow-key navigation (where `isMomentumEnd` doesn't fire between key-repeats and the same `pendingSortTimeoutRef` is the only one we ever own). The "scroll has truly ended" signal driving the programmatic-defer drain is FlashList's existing `isMomentumEnd`, fired by `VelocityTracker` ~100ms after the last `scroll` event — distance-independent and naturally overlap-safe (the browser merges overlapping smooth scrolls into one). + - **`maybeDoSortOnScroll`** runs from the effect that fires on `renderStack` / `renderId` changes — i.e. whenever recycling produced a new layout. It arms `shouldSortOnNextFocusRef`, evicts any pending-sort timer (a stale timer from a previous scroll's drain cannot fire mid-motion during rapid arrow-key repeats), then picks one of three branches: + - *Programmatic scroll queued or in flight* (`isScrollingProgrammatically()` is true): hand off via `runAfterProgrammaticScroll` → `schedulePendingSort`. Once the scroll settles we still wait an additional `SORT_DELAY_MS` for queued pointer/focus events to land before committing. The flag stays armed. + - *In-motion scroll caused by a recent focus* (`isScrolling()` is true and the last `scroll` event landed within `FOCUS_INDUCED_SCROLL_WINDOW_MS` = 30 ms after the last `focusin`): call `doSort` synchronously and reset the flag. This is the browser's auto-scroll-into-view from a Tab/focus on an off-viewport row — keeping DOM order synced is critical for the next Tab to land on the right row, even at the cost of perturbing the auto-scroll. **This is the path that does the sync sort during Tab navigation.** + - *Anything else* (user mousewheel/scrollbar/touch, or a quiet list): schedule the deferred sort. The flag stays armed for the next focusin to consume. + + - **`maybeDoSortOnFocus`** runs from the `focusin` listener. It evicts any pending-sort timer; if `shouldSortOnNextFocusRef` is armed it consumes the flag and commits `doSort` synchronously; either way it then schedules a fresh deferred sort. In the common Tab → auto-scroll flow, `maybeDoSortOnScroll`'s focus-induced branch has already done the sync sort and reset the flag *before* the next focusin gets here, so the sync-sort path inside this callback is mainly a safety net for scroll-less re-renders and for the programmatic-scroll branch (where the flag was armed but no sync sort fired). + + The deferred-sort timer is provided by `useDeferredCallback`, a small inline hook that wraps a single-slot `setTimeout` with a fire-time `shouldDefer` predicate. When the timer expires it re-checks `isScrolling()` and reschedules itself if a scroll is still in progress, so a long-running scroll never lets a stale timer fire in the middle of motion. The "scroll has truly ended" signal driving the programmatic-defer drain is FlashList's existing `isMomentumEnd`, fired by `VelocityTracker` ~100 ms after the last `scroll` event — distance-independent and naturally overlap-safe (the browser merges overlapping smooth scrolls into one). 5. **Pre-scroll announcement (`queueProgrammaticScroll`)**: A new public method on `FlashListRef` lets the consumer announce an imminent programmatic scroll *before* `scrollToIndex` is actually called. It flips an "is queued" ref that `isScrollingProgrammatically()` already ORs in, so any sort triggered by an intervening event (notably the `focusin` that fires when the consumer focuses the target row first and only then calls `scrollToIndex`) is correctly held off rather than committing immediately and cancelling the upcoming smooth scroll. The queued flag is handed off to the in-flight ref at `scrollToIndex` entry and finally cleared when the scroll settles, so it cannot get stuck on. @@ -86,12 +91,12 @@ 1. **Hover/pointer state loss**: When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. Keeping the array order stable during scrolling and only committing after the list goes idle gives the browser time to drain those events before any reorder. - 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The `maybeDoSort` gating above keeps programmatic-scroll commits out of the smooth-scroll window, so a `scrollToIndex` animation lands only after it has truly ended (`isMomentumEnd`). Browser auto-scroll-into-view triggered by Tab focusing an off-viewport row is intentionally *not* gated this way (see #4 above) — Tab-navigation correctness takes priority over preserving that auto-scroll's centring. + 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The programmatic-scroll gating in both `maybeDoSort*` callbacks keeps commits out of the smooth-scroll window, so a `scrollToIndex` animation lands only after it has truly ended (`isMomentumEnd`). Browser auto-scroll-into-view triggered by Tab focusing an off-viewport row is intentionally *not* gated this way (see #4 above) — Tab-navigation correctness takes priority over preserving that auto-scroll's centring. **Platform gating:** - On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener triggers an immediate sort on tab-in, and `maybeDoSort` defers only when a programmatic scroll is queued or in flight. The deferred path itself reschedules until any scroll has settled, via `schedulePendingSort`'s timer-fire `isScrolling()` re-check. - On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. + On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener (filtered via the `data-flashlist-index` marker) routes real focus changes through `maybeDoSortOnFocus`, and `maybeDoSortOnScroll` decides per-render whether to sort synchronously, defer until momentum-end, or defer the standard `SORT_DELAY_MS`. The deferred path itself reschedules until any scroll has settled, via `useDeferredCallback`'s timer-fire `isScrolling()` re-check. + On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. The marker JSX, the focusin listener, and both `maybeDoSort*` callbacks are gated to web only. - Upstream PR/issue: https://github.com/Shopify/flash-list/issues/1955 - E/App issue: https://github.com/Expensify/App/issues/86126 From e34ffa48a256d08409a82f0d640dba178975b0c9 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Tue, 12 May 2026 12:59:16 +0200 Subject: [PATCH 11/13] Address review comments --- ...2.3.0+008+sort-for-natural-DOM-order.patch | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch index 90896b6ebb28..b12f52ccce7a 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch @@ -129,7 +129,7 @@ index c37c4f3..fd2ff94 100644 /** * Ref interface for ViewHolderCollection that exposes methods to control layout updates diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js -index 8e3db51..f3aa0d8 100644 +index 8e3db51..8e23c83 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js @@ -3,17 +3,81 @@ @@ -216,7 +216,7 @@ index 8e3db51..f3aa0d8 100644 const [renderId, setRenderId] = React.useState(0); const containerLayout = getChildContainerLayout(); const fixedContainerSize = horizontal -@@ -72,9 +136,130 @@ export const ViewHolderCollection = (props) => { +@@ -72,9 +136,126 @@ export const ViewHolderCollection = (props) => { // return `${index} => ${reactKey}`; // }) // ); @@ -228,7 +228,7 @@ index 8e3db51..f3aa0d8 100644 + const shouldSortOnNextFocusRef = useRef(false); + const renderEntriesRef = useRef(Array.from(renderStack.entries())); + const [, bumpSortVersion] = useReducer((x) => x + 1, 0); -+ const doSort = useCallback(() => { ++ const sortItems = useCallback(() => { + const entries = renderEntriesRef.current; + const direction = inverted ? -1 : 1; + const isSorted = entries.every((entry, i) => i === 0 || direction * (entries[i - 1][1].index - entry[1].index) <= 0); @@ -237,9 +237,8 @@ index 8e3db51..f3aa0d8 100644 + } + entries.sort(([, a], [, b]) => direction * (a.index - b.index)); + bumpSortVersion(); -+ // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inverted]); -+ const [schedulePendingSort, clearPendingSort] = useDeferredCallback(doSort, SORT_DELAY_MS, isScrolling); ++ const [schedulePendingSort, clearPendingSort] = useDeferredCallback(sortItems, SORT_DELAY_MS, isScrolling); + const maybeDoSortOnFocus = useCallback(() => { + clearPendingSort(); + if (isScrollingProgrammatically()) { @@ -248,17 +247,15 @@ index 8e3db51..f3aa0d8 100644 + } + if (shouldSortOnNextFocusRef.current) { + shouldSortOnNextFocusRef.current = false; -+ doSort(); ++ sortItems(); + } + schedulePendingSort(); + }, [ + isScrollingProgrammatically, -+ isScrolling, + runAfterProgrammaticScroll, + schedulePendingSort, + clearPendingSort, -+ doSort, -+ getLastScrollTime, ++ sortItems, + ]); + const maybeDoSortOnScroll = useCallback(() => { + shouldSortOnNextFocusRef.current = true; @@ -278,7 +275,7 @@ index 8e3db51..f3aa0d8 100644 + const scrollNow = scrollSinceFocus >= 0 && + scrollSinceFocus < FOCUS_INDUCED_SCROLL_WINDOW_MS; + if (scrollNow) { -+ doSort(); ++ sortItems(); + shouldSortOnNextFocusRef.current = false; + return; + } @@ -290,7 +287,7 @@ index 8e3db51..f3aa0d8 100644 + runAfterProgrammaticScroll, + schedulePendingSort, + clearPendingSort, -+ doSort, ++ sortItems, + getLastScrollTime, + ]); + if (Platform.OS === "web") { @@ -332,8 +329,7 @@ index 8e3db51..f3aa0d8 100644 + }; + container.addEventListener("focusin", onFocusIn); + return () => container.removeEventListener("focusin", onFocusIn); -+ // eslint-disable-next-line react-hooks/exhaustive-deps -+ }, []); ++ }, [maybeDoSortOnFocus]); + useEffect(() => { + if (Platform.OS !== "web") { + return; From 2b6da6d28f8dc23909fb40ac51626d640ca06413 Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Thu, 14 May 2026 19:42:51 +0200 Subject: [PATCH 12/13] Update names, add cb to other lists that use FlashList --- ...2.3.0+008+sort-for-natural-DOM-order.patch | 56 +++++++++++++++---- .../SearchList/BaseSearchList/index.tsx | 3 + .../Search/SearchList/BaseSearchList/types.ts | 4 +- .../SelectionList/BaseSelectionList.tsx | 9 ++- .../BaseSelectionListWithSections.tsx | 2 +- tests/unit/BaseSelectionListSectionsTest.tsx | 8 +-- 6 files changed, 58 insertions(+), 24 deletions(-) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch index b12f52ccce7a..7a8a3d3b25c6 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -index 08b83f3..b58e59a 100644 +index 08b83f3..05a64b1 100644 --- a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts +++ b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts @@ -167,6 +167,30 @@ export interface FlashListRef { @@ -23,13 +23,13 @@ index 08b83f3..b58e59a 100644 + * scroll's momentum ends. Safe to call multiple times. + * + * @example -+ * listRef.current?.queueProgrammaticScroll(); ++ * listRef.current?.announceProgrammaticScroll(); + * itemDomNode.focus(); + * setTimeout(() => { + * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); + * }, 0); + */ -+ queueProgrammaticScroll: () => void; ++ announceProgrammaticScroll: () => void; /** * Scrolls to a specific item in the list. * @@ -364,7 +364,7 @@ index 62d55cd..b715484 100644 //# sourceMappingURL=useRecyclerViewController.d.ts.map \ No newline at end of file diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js -index 51b6f8c..c016d21 100644 +index 51b6f8c..a081498 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js @@ -25,6 +25,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe @@ -375,7 +375,7 @@ index 51b6f8c..c016d21 100644 + // flight. Cleared exactly once on `isMomentumEnd` via + // `notifyProgrammaticScrollSettled`. + const isProgrammaticScrollActiveRef = useRef(false); -+ // Set by `queueProgrammaticScroll()` to announce an imminent scroll. ++ // Set by `announceProgrammaticScroll()` to announce an imminent scroll. + // Handed off to `isProgrammaticScrollActiveRef` at `scrollToIndex` entry. + const isProgrammaticScrollQueuedRef = useRef(false); + // Source-agnostic "viewport in motion" flag. @@ -399,8 +399,8 @@ index 51b6f8c..c016d21 100644 + const runAfterProgrammaticScroll = useCallback((cb) => { + pendingAfterScrollRef.current = cb; + }, []); -+ // Public API; see `FlashListRef#queueProgrammaticScroll`. -+ const queueProgrammaticScroll = useCallback(() => { ++ // Public API; see `FlashListRef#announceProgrammaticScroll`. ++ const announceProgrammaticScroll = useCallback(() => { + isProgrammaticScrollQueuedRef.current = true; + }, []); + // Invoked from `RecyclerView.onScrollHandler` on `isMomentumEnd` (~100ms @@ -429,9 +429,9 @@ index 51b6f8c..c016d21 100644 }, + /** + * Announces an imminent programmatic scroll. See -+ * `FlashListRef#queueProgrammaticScroll` for full semantics. ++ * `FlashListRef#announceProgrammaticScroll` for full semantics. + */ -+ queueProgrammaticScroll, ++ announceProgrammaticScroll, /** * Scrolls to a specific index in the list. * Supports viewPosition and viewOffset for precise positioning. @@ -451,7 +451,7 @@ index 51b6f8c..c016d21 100644 setTimeout, isUnmounted, updateScrollOffsetWithCallback, -+ queueProgrammaticScroll, ++ announceProgrammaticScroll, ]); const applyInitialScrollIndex = useCallback(() => { var _a, _b, _c; @@ -470,3 +470,39 @@ index 51b6f8c..c016d21 100644 } //# sourceMappingURL=useRecyclerViewController.js.map \ No newline at end of file +diff --git a/node_modules/@shopify/flash-list/src/FlashListRef.ts b/node_modules/@shopify/flash-list/src/FlashListRef.ts +index 07bac2a..af9ee7d 100644 +--- a/node_modules/@shopify/flash-list/src/FlashListRef.ts ++++ b/node_modules/@shopify/flash-list/src/FlashListRef.ts +@@ -181,6 +181,31 @@ export interface FlashListRef { + */ + scrollToIndex: (params: ScrollToIndexParams) => Promise; + ++ /** ++ * Announces an imminent programmatic scroll before `scrollToIndex` is ++ * actually called, so DOM-mutating side-effects gated on ++ * `isScrollingProgrammatically()` (notably the on-web sort applied by ++ * `ViewHolderCollection`) defer until the upcoming smooth scroll ++ * settles, rather than running synchronously and cancelling it. ++ * ++ * Useful when the focus assignment happens first and `scrollToIndex` ++ * follows a few ticks later — as long as the call is guaranteed to ++ * happen, queue it up front so the intervening `focusin` doesn't ++ * trigger an immediate sort that the smooth scroll would then cancel. ++ * ++ * Cleared automatically when the next `scrollToIndex` is invoked ++ * (handed off to the in-flight flag) and again when the resulting ++ * scroll's momentum ends. Safe to call multiple times. ++ * ++ * @example ++ * listRef.current?.announceProgrammaticScroll(); ++ * itemDomNode.focus(); ++ * setTimeout(() => { ++ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true }); ++ * }, 0); ++ */ ++ announceProgrammaticScroll: () => void; ++ + /** + * Scrolls to a specific item in the list. + * diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 45526e8a5eb2..68fb4365d285 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -54,6 +54,9 @@ function BaseSearchList({ onFocusedIndexChange: (index: number) => { scrollToIndex?.(index); }, + onArrowUpDownCallback: () => { + ref?.current?.announceProgrammaticScroll(); + }, setHasKeyBeenPressed, isFocused, captureOnInputs: false, diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index b0c05aeb9e58..ca346f95f9e9 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -1,5 +1,5 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; -import type {ForwardedRef} from 'react'; +import type {RefObject} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType, SelectedTransactions} from '@components/Search/types'; @@ -37,7 +37,7 @@ type BaseSearchListProps = Pick< onSelectRow: (item: SearchListItem) => void; /** The ref to the list */ - ref: ForwardedRef>; + ref: RefObject | null>; /** The function to scroll to an index */ scrollToIndex?: (index: number, animated?: boolean) => void; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index f9b017b3ee50..7791413335e7 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -201,10 +201,6 @@ function BaseSelectionList({ const debouncedScrollToIndex = useDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}); - const onArrowUpDownCallback = useCallback(() => { - setShouldDisableHoverStyle(true); - }, [setShouldDisableHoverStyle]); - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ initialFocusedIndex, maxIndex: data.length - 1, @@ -223,7 +219,10 @@ function BaseSelectionList({ }, setHasKeyBeenPressed, isFocused, - onArrowUpDownCallback, + onArrowUpDownCallback: () => { + setShouldDisableHoverStyle(true); + listRef.current?.announceProgrammaticScroll(); + }, }); // extraData helps FlashList detect when data changes significantly (e.g., during filtering) diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index d13bc6f59e13..a8fd86e5df0e 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -164,7 +164,7 @@ function BaseSelectionListWithSections({ isFocused: isScreenFocused, onArrowUpDownCallback: () => { setShouldDisableHoverStyle(true); - listRef.current?.queueProgrammaticScroll(); + listRef.current?.announceProgrammaticScroll(); }, }); diff --git a/tests/unit/BaseSelectionListSectionsTest.tsx b/tests/unit/BaseSelectionListSectionsTest.tsx index 0c755c59e82d..0650c885f60f 100644 --- a/tests/unit/BaseSelectionListSectionsTest.tsx +++ b/tests/unit/BaseSelectionListSectionsTest.tsx @@ -12,7 +12,6 @@ import CONST from '@src/CONST'; // Captures scrollToIndex calls so tests can assert on scroll behaviour const mockScrollToIndex = jest.fn(); -const mockQueueProgrammaticScroll = jest.fn(); // Mock FlashList jest.mock('@shopify/flash-list', () => { @@ -20,7 +19,7 @@ jest.mock('@shopify/flash-list', () => { const RN = jest.requireActual('react-native'); const FlashList = ReactLocal.forwardRef< - {scrollToIndex: (params: {index: number}) => void; queueProgrammaticScroll: () => void}, + {scrollToIndex: (params: {index: number}) => void}, Omit, 'children'> & { data?: unknown[]; renderItem?: (info: {item: unknown; index: number; target: string}) => React.ReactNode; @@ -52,10 +51,7 @@ jest.mock('@shopify/flash-list', () => { }, ref, ) => { - ReactLocal.useImperativeHandle(ref, () => ({ - scrollToIndex: mockScrollToIndex, - queueProgrammaticScroll: mockQueueProgrammaticScroll, - })); + ReactLocal.useImperativeHandle(ref, () => ({scrollToIndex: mockScrollToIndex})); return ReactLocal.createElement( RN.ScrollView, From dfa979567b6e242fa7456bd9a3299e8b2a28104a Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Mon, 18 May 2026 18:10:48 +0200 Subject: [PATCH 13/13] Change patch file name --- ...shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename patches/@shopify/flash-list/{@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch => @shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch} (100%) diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch similarity index 100% rename from patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+sort-for-natural-DOM-order.patch rename to patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch