Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +15 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these specific numbers used?

Copy link
Copy Markdown
Contributor Author

@sharabai sharabai Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VickyStash The approach is described in more detail in details.md, but for a quick overview - we sort the DOM nodes FlashList is using underneath. The SORT_DELAY_MS is the delay before the we do the sort of the DOM nodes. It's needed exactly to avoid stale hover states that were reported here.
the question then becomes how long do we wait before sorting?

Long enough so that hover states get their mouseleave event fired. With momentum scrolling on Macs for example you have to wait about that time so that the correct element gets the hover.
Example of a late hover (it took about 500ms after scroll completely stopped for hover to change)

late.hover.example.mp4

And it can happen to be even longer, so 1000ms was kind of a good spot to stop to avoid most of state hover states.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VickyStash
The second TAB_SCROLL_THRESHOLD_MS is used when user navigates using tabs or screen readers that focus elements. In that case we need to immediately sort the DOM nodes (because tabbing works on DOM node order in web).
This var is used to control the following behavior: we are in the list, we are tabbing - tabbing normally means that we don't scroll, but when we reach an element that is outside of a viewport (browser sees we're focusing on an element that it outside of the FlashList's viewport), the list scrolls so that element is in the middle.
Normally we would defer sorting because it'd introduce late hovers, but since we need it ASAP for navigation, we don't defer scroll.
The TAB_SCROLL_THRESHOLD_MS is used to answer the question: when have we last tabbed? If it was more recent than this threshold - do DOM sort immediately because we're likely causing scroll by browser focusing on an element outside of the viewport, meaning we're using tab navigation - so sort immediately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timing itself was chosen so that it works well enough under most conditions.

/**
* 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for inverted mode in DOM order sort

The web reorder logic still hardcodes ascending index order (a.index - b.index), so any FlashList rendered with inverted will get DOM order opposite to its visual order and keyboard/screen-reader traversal will remain backwards. Fresh evidence in this revision is that the comparator is still fixed to ascending and no inverted (or equivalent) flag is read before sorting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was covered here already.
#85825 (comment)

+ 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
31 changes: 29 additions & 2 deletions patches/@shopify/flash-list/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading