From 864036074d47dc04e0b15179a99013b25ead0acc Mon Sep 17 00:00:00 2001 From: Sergei Sharabai <105950333+sharabai@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:05:40 +0100 Subject: [PATCH] Add @shopify/flash-list patch to use natural DOM order --- ...2.3.0+003+sort-for-natural-DOM-order.patch | 47 +++++++++++++++++++ patches/@shopify/flash-list/details.md | 19 +++++++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+sort-for-natural-DOM-order.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+sort-for-natural-DOM-order.patch new file mode 100644 index 0000000000000..9e1feb43cb890 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+sort-for-natural-DOM-order.patch @@ -0,0 +1,47 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +index 8e3db51..df7ba53 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +@@ -3,7 +3,8 @@ + * 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, { useEffect, useImperativeHandle, useLayoutEffect, useState, } from "react"; ++import { Platform } from "react-native"; + import { ViewHolder } from "./ViewHolder"; + import { CompatView } from "./components/CompatView"; + import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; +@@ -15,6 +16,7 @@ import { useRecyclerViewContext } from "./RecyclerViewContextProvider"; + 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 [renderId, setRenderId] = React.useState(0); ++ const [renderEntries, setRenderEntries] = useState(Array.from(renderStack.entries())); + const containerLayout = getChildContainerLayout(); + const fixedContainerSize = horizontal + ? containerLayout === null || containerLayout === void 0 ? void 0 : containerLayout.height +@@ -52,6 +54,15 @@ export const ViewHolderCollection = (props) => { + setRenderId((prev) => prev + 1); + }, + }), [setRenderId]); ++ useEffect(() => { ++ if (Platform.OS !== "web") ++ return; ++ const timeoutId = setTimeout(() => { ++ const sorted = Array.from(renderStack.entries()).sort(([, a], [, b]) => a.index - b.index); ++ setRenderEntries(sorted); ++ }, 50); ++ return () => clearTimeout(timeoutId); ++ }, [renderStack, renderId]); + const hasData = data && data.length > 0; + const containerStyle = { + width: horizontal ? containerLayout === null || containerLayout === void 0 ? void 0 : containerLayout.width : undefined, +@@ -74,7 +85,7 @@ export const ViewHolderCollection = (props) => { + // ); + return (React.createElement(CompatView, { style: hasData && containerStyle }, containerLayout && + hasData && +- Array.from(renderStack.entries(), ([reactKey, { index }]) => { ++ renderEntries.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 6012a1c00a87c..a3e566524952e 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -16,7 +16,24 @@ 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 - E/App issue: https://github.com/Expensify/App/issues/83976 - PR introducing patch: https://github.com/Expensify/App/pull/84887 + +### [@shopify+flash-list+2.3.0+003+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+003+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. + + The fix sorts `renderStack` entries by data index before rendering, using a deferred two-phase approach to preserve browser hover tracking: + 1. **First render** (immediate): Items render in unsorted Map insertion order. React updates existing DOM nodes in place without calling `insertBefore`, allowing the browser to correctly detect CSS position changes and fire `mouseleave` events for elements that moved out from under the pointer. + 2. **Second render** (after 50ms `setTimeout`): A `useEffect` triggers a re-render with entries sorted by index. React calls `insertBefore` to reorder DOM nodes into the correct accessibility order. By this point, the browser has already processed hover state cleanup from the first render. + + The deferred approach is necessary because `insertBefore` (which React uses internally to reorder keyed children) disrupts browser hover tracking — browsers do not fire `mouseleave` events for DOM structural moves, only for actual pointer movement. Without the delay, recycled items retain stale hover/tooltip states during fast scrolling. The `setTimeout` cleanup on re-render ensures that during continuous scrolling, only the final position triggers the sort, debouncing unnecessary intermediate reorders. + + Gated behind `Platform.OS === "web"` — no effect on native platforms. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/86126 +- PR introducing patch: https://github.com/Expensify/App/pull/85825