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..7aaa462eaf404 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+sort-for-natural-DOM-order.patch @@ -0,0 +1,86 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js +index 8e3db51..702c59b 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,64 @@ 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 isSorted = entries.every((entry, i) => i === 0 || entries[i - 1][1].index <= entry[1].index); ++ if (isSorted) { ++ return; ++ } ++ entries.sort(([, a], [, b]) => a.index - b.index); ++ setSortId((prev) => prev + 1); ++ }, []); ++ 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 6012a1c00a87c..14d2742dfa456 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -16,7 +16,34 @@ 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 + +### [@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. + + **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 \ No newline at end of file