From 88cf3506036bd7fd3f026480c5e1ef12d7268566 Mon Sep 17 00:00:00 2001 From: Vit Horacek <36083550+mountiny@users.noreply.github.com> Date: Tue, 19 May 2026 13:56:17 +0200 Subject: [PATCH] Revert "Fix: force FlashList to use natural DOM order on web" --- ...2.3.0+009+sort-for-natural-DOM-order.patch | 508 ------------------ patches/@shopify/flash-list/details.md | 49 +- .../SearchList/BaseSearchList/index.tsx | 3 - .../Search/SearchList/BaseSearchList/types.ts | 4 +- .../SelectionList/BaseSelectionList.tsx | 9 +- .../BaseSelectionListWithSections.tsx | 5 +- 6 files changed, 10 insertions(+), 568 deletions(-) delete mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch deleted file mode 100644 index 7a8a3d3b25c6..000000000000 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch +++ /dev/null @@ -1,508 +0,0 @@ -diff --git a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts -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 { - * }); - */ - 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/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js -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) => { - 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, 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,19 @@ const RecyclerViewComponent = (props, ref) => { - return; - } - if (isMomentumEnd) { -+ notifyScrollSettled(); -+ // Drain BEFORE the early return below so the drain still -+ // fires while offset projection is still disabled. -+ notifyProgrammaticScrollSettled(); - computeFirstVisibleIndexForOffsetCorrection(); - if (!recyclerViewManager.isOffsetProjectionEnabled) { - return; - } - recyclerViewManager.resetVelocityCompute(); - } -+ else { -+ notifyScrollActive(); -+ } - // Update scroll position and trigger re-render if needed - if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) { - setRenderId((prev) => prev + 1); -@@ -266,6 +273,9 @@ const RecyclerViewComponent = (props, ref) => { - computeFirstVisibleIndexForOffsetCorrection, - horizontal, - isHorizontalRTL, -+ notifyProgrammaticScrollSettled, -+ notifyScrollActive, -+ notifyScrollSettled, - recyclerViewManager, - velocityTracker, - ]); -@@ -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, 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..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 @@ - * 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, 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; -+// 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()` -+ * 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]; -+} -+/** -+ * 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 - * and handles layout updates for the entire collection - * @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, isScrolling, runAfterProgrammaticScroll, getLastScrollTime, } = props; - const [renderId, setRenderId] = React.useState(0); - const containerLayout = getChildContainerLayout(); - const fixedContainerSize = horizontal -@@ -72,9 +136,126 @@ 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 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); -+ if (isSorted) { -+ return; -+ } -+ entries.sort(([, a], [, b]) => direction * (a.index - b.index)); -+ bumpSortVersion(); -+ }, [inverted]); -+ const [schedulePendingSort, clearPendingSort] = useDeferredCallback(sortItems, SORT_DELAY_MS, isScrolling); -+ const maybeDoSortOnFocus = useCallback(() => { -+ clearPendingSort(); -+ if (isScrollingProgrammatically()) { -+ runAfterProgrammaticScroll(schedulePendingSort); -+ return; -+ } -+ if (shouldSortOnNextFocusRef.current) { -+ shouldSortOnNextFocusRef.current = false; -+ sortItems(); -+ } -+ schedulePendingSort(); -+ }, [ -+ isScrollingProgrammatically, -+ runAfterProgrammaticScroll, -+ schedulePendingSort, -+ clearPendingSort, -+ sortItems, -+ ]); -+ 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) { -+ sortItems(); -+ shouldSortOnNextFocusRef.current = false; -+ return; -+ } -+ } -+ schedulePendingSort(); -+ }, [ -+ isScrollingProgrammatically, -+ isScrolling, -+ runAfterProgrammaticScroll, -+ schedulePendingSort, -+ clearPendingSort, -+ sortItems, -+ getLastScrollTime, -+ ]); -+ 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(() => { -+ const container = containerRef.current; -+ if (Platform.OS !== "web" || !container) { -+ return; -+ } -+ 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(); -+ maybeDoSortOnFocus(); -+ }; -+ container.addEventListener("focusin", onFocusIn); -+ return () => container.removeEventListener("focusin", onFocusIn); -+ }, [maybeDoSortOnFocus]); -+ useEffect(() => { -+ if (Platform.OS !== "web") { -+ return; -+ } -+ maybeDoSortOnScroll(); -+ return clearPendingSort; -+ // 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/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..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 - const isUnmounted = useUnmountFlag(); - const [_, setRenderId] = useState(0); - const pauseOffsetCorrection = useRef(false); -+ // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in -+ // flight. Cleared exactly once on `isMomentumEnd` via -+ // `notifyProgrammaticScrollSettled`. -+ const isProgrammaticScrollActiveRef = useRef(false); -+ // 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. -+ 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 +195,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - updateScrollOffsetWithCallback, - computeFirstVisibleIndexForOffsetCorrection, - ]); -+ const isScrollingProgrammatically = useCallback(() => isProgrammaticScrollActiveRef.current || -+ isProgrammaticScrollQueuedRef.current, []); -+ const isScrolling = useCallback(() => isScrollingRef.current, []); -+ const runAfterProgrammaticScroll = useCallback((cb) => { -+ pendingAfterScrollRef.current = cb; -+ }, []); -+ // Public API; see `FlashListRef#announceProgrammaticScroll`. -+ const announceProgrammaticScroll = useCallback(() => { -+ isProgrammaticScrollQueuedRef.current = true; -+ }, []); -+ // 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; -+ const cb = pendingAfterScrollRef.current; -+ pendingAfterScrollRef.current = null; -+ cb === null || cb === void 0 ? void 0 : cb(); -+ }, []); -+ 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 +313,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - animated, - }); - }, -+ /** -+ * Announces an imminent programmatic scroll. See -+ * `FlashListRef#announceProgrammaticScroll` for full semantics. -+ */ -+ announceProgrammaticScroll, - /** - * Scrolls to a specific index in the list. - * Supports viewPosition and viewOffset for precise positioning. -@@ -289,6 +336,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - // Pause the scroll offset adjustments - pauseOffsetCorrection.current = true; - recyclerViewManager.setOffsetProjectionEnabled(false); -+ // 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 +545,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - setTimeout, - isUnmounted, - updateScrollOffsetWithCallback, -+ announceProgrammaticScroll, - ]); - const applyInitialScrollIndex = useCallback(() => { - var _a, _b, _c; -@@ -547,6 +600,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe - computeFirstVisibleIndexForOffsetCorrection, - applyInitialScrollIndex, - handlerMethods, -+ isScrollingProgrammatically, -+ isScrolling, -+ runAfterProgrammaticScroll, -+ 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..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/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 8d1111c23d85..dd145f525594 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -16,7 +16,8 @@ 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. -- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2231 +- 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 - E/App issue: https://github.com/Expensify/App/issues/83976 - PR introducing patch: https://github.com/Expensify/App/pull/84887 @@ -63,49 +64,3 @@ - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/89768 - PR introducing patch: https://github.com/Expensify/App/pull/90218 - -### [@shopify+flash-list+2.3.0+009+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+009+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 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. **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. **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`): - - - **`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. - - **Why the deferred approach is necessary:** - - 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**: 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 (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 -- PR introducing patch: https://github.com/Expensify/App/pull/85825 diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index d71c45e3d070..8af495aaacb5 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -58,9 +58,6 @@ 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 63f60b5da280..98a95be7c065 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 {RefObject} from 'react'; +import type {ForwardedRef} 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: RefObject | null>; + ref: ForwardedRef>; /** 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 2cd6810124ad..da7f172888a3 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -204,6 +204,10 @@ 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, @@ -222,10 +226,7 @@ function BaseSelectionList({ }, setHasKeyBeenPressed, isFocused, - onArrowUpDownCallback: () => { - setShouldDisableHoverStyle(true); - listRef.current?.announceProgrammaticScroll(); - }, + onArrowUpDownCallback, }); // Keep the cursor on the restored row so keyboard nav continues from there, but don't scroll to it on the way back. diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 342ffc10c15a..7bc8720ace24 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -164,10 +164,7 @@ function BaseSelectionListWithSections({ }, setHasKeyBeenPressed, isFocused: isScreenFocused, - onArrowUpDownCallback: () => { - setShouldDisableHoverStyle(true); - listRef.current?.announceProgrammaticScroll(); - }, + onArrowUpDownCallback: () => setShouldDisableHoverStyle(true), }); // Move the cursor, and skip the scroll the move would otherwise trigger when the index actually changes.