@shopify/flash-list 2.2.1 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/FlashListProps.d.ts +33 -8
  2. package/dist/FlashListProps.d.ts.map +1 -1
  3. package/dist/recyclerview/RecyclerView.d.ts.map +1 -1
  4. package/dist/recyclerview/RecyclerView.js +15 -12
  5. package/dist/recyclerview/RecyclerView.js.map +1 -1
  6. package/dist/recyclerview/RecyclerViewManager.d.ts +2 -0
  7. package/dist/recyclerview/RecyclerViewManager.d.ts.map +1 -1
  8. package/dist/recyclerview/RecyclerViewManager.js +8 -1
  9. package/dist/recyclerview/RecyclerViewManager.js.map +1 -1
  10. package/dist/recyclerview/ViewHolderCollection.d.ts +2 -0
  11. package/dist/recyclerview/ViewHolderCollection.d.ts.map +1 -1
  12. package/dist/recyclerview/ViewHolderCollection.js +5 -2
  13. package/dist/recyclerview/ViewHolderCollection.js.map +1 -1
  14. package/dist/recyclerview/hooks/useBoundDetection.js +1 -1
  15. package/dist/recyclerview/hooks/useBoundDetection.js.map +1 -1
  16. package/dist/recyclerview/hooks/useRecyclerViewController.d.ts.map +1 -1
  17. package/dist/recyclerview/hooks/useRecyclerViewController.js +2 -3
  18. package/dist/recyclerview/hooks/useRecyclerViewController.js.map +1 -1
  19. package/dist/recyclerview/hooks/useSecondaryProps.d.ts +1 -1
  20. package/dist/recyclerview/hooks/useSecondaryProps.d.ts.map +1 -1
  21. package/dist/recyclerview/hooks/useSecondaryProps.js +7 -3
  22. package/dist/recyclerview/hooks/useSecondaryProps.js.map +1 -1
  23. package/dist/recyclerview/layout-managers/GridLayoutManager.d.ts +5 -0
  24. package/dist/recyclerview/layout-managers/GridLayoutManager.d.ts.map +1 -1
  25. package/dist/recyclerview/layout-managers/GridLayoutManager.js +12 -0
  26. package/dist/recyclerview/layout-managers/GridLayoutManager.js.map +1 -1
  27. package/dist/recyclerview/layout-managers/LayoutManager.d.ts +11 -0
  28. package/dist/recyclerview/layout-managers/LayoutManager.d.ts.map +1 -1
  29. package/dist/recyclerview/layout-managers/LayoutManager.js +17 -0
  30. package/dist/recyclerview/layout-managers/LayoutManager.js.map +1 -1
  31. package/dist/recyclerview/layout-managers/MasonryLayoutManager.d.ts.map +1 -1
  32. package/dist/recyclerview/layout-managers/MasonryLayoutManager.js.map +1 -1
  33. package/dist/recyclerview/utils/componentUtils.d.ts +12 -3
  34. package/dist/recyclerview/utils/componentUtils.d.ts.map +1 -1
  35. package/dist/recyclerview/utils/componentUtils.js +16 -3
  36. package/dist/recyclerview/utils/componentUtils.js.map +1 -1
  37. package/dist/recyclerview/utils/measureLayout.d.ts +9 -5
  38. package/dist/recyclerview/utils/measureLayout.d.ts.map +1 -1
  39. package/dist/recyclerview/utils/measureLayout.js +6 -5
  40. package/dist/recyclerview/utils/measureLayout.js.map +1 -1
  41. package/dist/recyclerview/utils/measureLayout.web.d.ts +6 -2
  42. package/dist/recyclerview/utils/measureLayout.web.d.ts.map +1 -1
  43. package/dist/recyclerview/utils/measureLayout.web.js +1 -3
  44. package/dist/recyclerview/utils/measureLayout.web.js.map +1 -1
  45. package/dist/recyclerview/viewability/ViewabilityManager.d.ts +1 -1
  46. package/dist/recyclerview/viewability/ViewabilityManager.d.ts.map +1 -1
  47. package/dist/recyclerview/viewability/ViewabilityManager.js +1 -2
  48. package/dist/recyclerview/viewability/ViewabilityManager.js.map +1 -1
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/dist/utils/AverageWindow.d.ts.map +1 -1
  51. package/dist/utils/AverageWindow.js +2 -3
  52. package/dist/utils/AverageWindow.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/FlashListProps.ts +40 -3
  55. package/src/recyclerview/RecyclerView.tsx +15 -11
  56. package/src/recyclerview/RecyclerViewManager.ts +8 -1
  57. package/src/recyclerview/ViewHolderCollection.tsx +10 -3
  58. package/src/recyclerview/hooks/useBoundDetection.ts +1 -1
  59. package/src/recyclerview/hooks/useRecyclerViewController.tsx +2 -3
  60. package/src/recyclerview/hooks/useSecondaryProps.tsx +12 -6
  61. package/src/recyclerview/layout-managers/GridLayoutManager.ts +13 -0
  62. package/src/recyclerview/layout-managers/LayoutManager.ts +18 -0
  63. package/src/recyclerview/layout-managers/MasonryLayoutManager.ts +4 -0
  64. package/src/recyclerview/utils/componentUtils.ts +27 -5
  65. package/src/recyclerview/utils/measureLayout.ts +12 -6
  66. package/src/recyclerview/utils/measureLayout.web.ts +7 -4
  67. package/src/recyclerview/viewability/ViewabilityManager.ts +1 -3
  68. package/src/utils/AverageWindow.ts +4 -2
@@ -54,6 +54,8 @@ export interface ViewHolderCollectionProps<TItem> {
54
54
  currentStickyIndex: number;
55
55
  /** Whether the cell associated with an active sticky header is hidden */
56
56
  hideStickyHeaderRelatedCell: boolean;
57
+ /** Returns whether the item at the given index is in the last row of the layout */
58
+ isInLastRow: (index: number) => boolean;
57
59
  }
58
60
 
59
61
  /**
@@ -90,6 +92,7 @@ export const ViewHolderCollection = <TItem,>(
90
92
  getAdjustmentMargin,
91
93
  currentStickyIndex,
92
94
  hideStickyHeaderRelatedCell,
95
+ isInLastRow,
93
96
  } = props;
94
97
 
95
98
  const [renderId, setRenderId] = React.useState(0);
@@ -171,9 +174,13 @@ export const ViewHolderCollection = <TItem,>(
171
174
  hasData &&
172
175
  Array.from(renderStack.entries(), ([reactKey, { index }]) => {
173
176
  const item = data[index];
174
- const trailingItem = ItemSeparatorComponent
175
- ? data[index + 1]
176
- : undefined;
177
+ // Suppress separators for items in the last row to prevent
178
+ // height mismatch. The last data item has no separator (no
179
+ // trailingItem), so all items sharing its row must match.
180
+ const trailingItem =
181
+ ItemSeparatorComponent && !isInLastRow(index)
182
+ ? data[index + 1]
183
+ : undefined;
177
184
 
178
185
  return (
179
186
  <ViewHolder
@@ -140,7 +140,7 @@ export function useBoundDetection<T>(
140
140
  recyclerViewManager.props.maintainVisibleContentPosition
141
141
  ?.animateAutoScrollToBottom ?? true;
142
142
  scrollViewRef.current?.scrollToEnd({
143
- animated: shouldAnimate,
143
+ animated: shouldAnimate && !recyclerViewManager.ignoreScrollEvents,
144
144
  });
145
145
  });
146
146
  }
@@ -51,7 +51,6 @@ export function useRecyclerViewController<T>(
51
51
  const isUnmounted = useUnmountFlag();
52
52
  const [_, setRenderId] = useState(0);
53
53
  const pauseOffsetCorrection = useRef(false);
54
- const initialScrollCompletedRef = useRef(false);
55
54
  const lastDataLengthRef = useRef(recyclerViewManager.getDataLength());
56
55
  const { setTimeout } = useUnmountAwareTimeout();
57
56
 
@@ -573,12 +572,12 @@ export function useRecyclerViewController<T>(
573
572
  if (
574
573
  initialScrollIndex >= 0 &&
575
574
  initialScrollIndex < dataLength &&
576
- !initialScrollCompletedRef.current &&
575
+ !recyclerViewManager.isInitialScrollComplete &&
577
576
  recyclerViewManager.getIsFirstLayoutComplete()
578
577
  ) {
579
578
  // Use setTimeout to ensure that we keep trying to scroll on first few renders
580
579
  setTimeout(() => {
581
- initialScrollCompletedRef.current = true;
580
+ recyclerViewManager.isInitialScrollComplete = true;
582
581
  pauseOffsetCorrection.current = false;
583
582
  }, 100);
584
583
 
@@ -2,7 +2,7 @@ import { Animated, RefreshControl } from "react-native";
2
2
  import React, { useMemo } from "react";
3
3
 
4
4
  import { RecyclerViewProps } from "../RecyclerViewProps";
5
- import { getValidComponent } from "../utils/componentUtils";
5
+ import { getValidComponent, isComponentClass } from "../utils/componentUtils";
6
6
  import { CompatView } from "../components/CompatView";
7
7
  import { CompatAnimatedScroller } from "../components/CompatScroller";
8
8
 
@@ -121,16 +121,22 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
121
121
  * If no custom component is provided, uses the default CompatAnimatedScroller.
122
122
  */
123
123
  const CompatScrollView = useMemo(() => {
124
- let scrollComponent = CompatAnimatedScroller;
125
- if (typeof renderScrollComponent === "function") {
124
+ let scrollComponent: React.ComponentType<any> = CompatAnimatedScroller;
125
+ if (
126
+ typeof renderScrollComponent === "function" &&
127
+ !isComponentClass(renderScrollComponent)
128
+ ) {
126
129
  // Create a forwarded ref wrapper for the custom scroll component
127
130
  const ForwardedScrollComponent = React.forwardRef((_props, ref) =>
128
- (renderScrollComponent as any)({ ..._props, ref } as any)
131
+ (renderScrollComponent as (...args: unknown[]) => React.ReactNode)({
132
+ ..._props,
133
+ ref,
134
+ })
129
135
  );
130
136
  ForwardedScrollComponent.displayName = "CustomScrollView";
131
- scrollComponent = ForwardedScrollComponent as any;
137
+ scrollComponent = ForwardedScrollComponent as React.ComponentType<any>;
132
138
  } else if (renderScrollComponent) {
133
- scrollComponent = renderScrollComponent;
139
+ scrollComponent = renderScrollComponent as React.ComponentType<any>;
134
140
  }
135
141
  // Wrap the scroll component with Animated.createAnimatedComponent
136
142
  return Animated.createAnimatedComponent(scrollComponent);
@@ -253,4 +253,17 @@ export class RVGridLayoutManagerImpl extends RVLayoutManager {
253
253
  }
254
254
  return Math.max(i, 0);
255
255
  }
256
+
257
+ /**
258
+ * Returns whether the item is in the same row as the last item.
259
+ * Items in the same row share the same y-coordinate, regardless of column spans.
260
+ */
261
+ isInLastRow(index: number): boolean {
262
+ if (this.layouts.length === 0) return false;
263
+ const lastIndex = this.layouts.length - 1;
264
+ return (
265
+ index === lastIndex ||
266
+ this.layouts[index]?.y === this.layouts[lastIndex]?.y
267
+ );
268
+ }
256
269
  }
@@ -182,6 +182,10 @@ export abstract class RVLayoutManager {
182
182
  this.layouts.length = totalItemCount;
183
183
  this.spanTracker.length = totalItemCount;
184
184
  minRecomputeIndex = totalItemCount - 1; // <0 gets skipped so it's safe to set to totalItemCount - 1
185
+ // layoutInfo may contain stale indices from ViewHolders that were rendered
186
+ // before the data shrunk. Filter out any indices that are now out of bounds.
187
+ // eslint-disable-next-line no-param-reassign
188
+ layoutInfo = layoutInfo.filter((info) => info.index < totalItemCount);
185
189
  }
186
190
  // update average windows
187
191
  minRecomputeIndex = Math.min(
@@ -260,6 +264,20 @@ export abstract class RVLayoutManager {
260
264
  return this.layouts.length;
261
265
  }
262
266
 
267
+ /**
268
+ * Returns whether the item at the given index is in the last row of the layout.
269
+ * Used to suppress separators for all items in the last row, preventing
270
+ * height mismatch when the last data item has no separator.
271
+ *
272
+ * Base implementation returns false — only layouts that normalize heights
273
+ * across multiple items (e.g., grid) need to override this.
274
+ * @param index Index of the item
275
+ * @returns True if the item is in the last row
276
+ */
277
+ isInLastRow(index: number): boolean {
278
+ return false;
279
+ }
280
+
263
281
  /**
264
282
  * Abstract method to recompute layouts for items in the given range.
265
283
  * @param startIndex Starting index of items to recompute
@@ -320,4 +320,8 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
320
320
  }
321
321
  }
322
322
  }
323
+
324
+ // TODO: For masonry, the "last row" is the last item in each column.
325
+ // Override isInLastRow if ItemSeparatorComponent support is needed
326
+ // for masonry layouts.
323
327
  }
@@ -1,11 +1,31 @@
1
1
  import React from "react";
2
2
 
3
+ type RenderableComponent =
4
+ | React.ComponentType
5
+ | React.ExoticComponent
6
+ | React.ReactElement
7
+ | null
8
+ | undefined;
9
+
10
+ /**
11
+ * Returns true if the value is a React class component.
12
+ * Class components set `prototype.isReactComponent` per React convention,
13
+ * which distinguishes them from plain functions and render props.
14
+ */
15
+ export const isComponentClass = (value: unknown): boolean =>
16
+ typeof value === "function" &&
17
+ Boolean(
18
+ (value as { prototype?: { isReactComponent?: unknown } }).prototype
19
+ ?.isReactComponent
20
+ );
21
+
3
22
  /**
4
23
  * Helper function to handle both React components and React elements.
5
24
  * This utility ensures proper rendering of components whether they are passed as
6
- * component types or pre-rendered elements.
25
+ * component types or pre-rendered elements. Supports function components, class
26
+ * components, React.memo, React.forwardRef, and pre-rendered elements.
7
27
  *
8
- * @param component - Can be a React component type, React element, null, or undefined
28
+ * @param component - Can be a React component type, exotic component, React element, null, or undefined
9
29
  * @returns A valid React element if the input is valid, null otherwise
10
30
  *
11
31
  * @example
@@ -17,12 +37,14 @@ import React from "react";
17
37
  * getValidComponent(<MyComponent />)
18
38
  */
19
39
  export const getValidComponent = (
20
- component: React.ComponentType | React.ReactElement | null | undefined
40
+ component: RenderableComponent
21
41
  ): React.ReactElement | null => {
22
42
  if (React.isValidElement(component)) {
23
43
  return component;
24
- } else if (typeof component === "function") {
25
- return React.createElement(component);
44
+ } else if (component != null) {
45
+ // Cast needed: React.createElement's type overloads don't include ExoticComponent,
46
+ // but it handles memo/forwardRef/lazy correctly at runtime.
47
+ return React.createElement(component as React.ComponentType);
26
48
  }
27
49
  return null;
28
50
  };
@@ -7,6 +7,11 @@ interface Layout {
7
7
  height: number;
8
8
  }
9
9
 
10
+ interface Size {
11
+ width: number;
12
+ height: number;
13
+ }
14
+
10
15
  /**
11
16
  * Measures the layout of a view relative to itselft.
12
17
  * Using measure wasn't returing accurate values but this workaround does.
@@ -89,14 +94,15 @@ export function roundOffPixel(value: number): number {
89
94
  }
90
95
 
91
96
  /**
92
- * Specific method for easier mocking
93
- * Measures the layout of parent of RecyclerView
94
- * Returns the x, y coordinates and dimensions of the view.
97
+ * Measures the size of the RecyclerView's outer container.
98
+ * Uses a self-relative measureLayout call to get width/height synchronously.
99
+ *
95
100
  * @param view - The React Native View component to measure
96
- * @returns An object containing x, y, width, and height measurements
101
+ * @returns An object containing width and height
97
102
  */
98
- export function measureParentSize(view: View): Layout {
99
- return measureLayout(view, undefined);
103
+ export function measureParentSize(view: View): Size {
104
+ const layout = measureLayout(view, undefined);
105
+ return { width: layout.width, height: layout.height };
100
106
  }
101
107
 
102
108
  /**
@@ -5,6 +5,11 @@ interface Layout {
5
5
  height: number;
6
6
  }
7
7
 
8
+ interface Size {
9
+ width: number;
10
+ height: number;
11
+ }
12
+
8
13
  /**
9
14
  * Gets scroll offsets from up to 3 parent elements
10
15
  */
@@ -43,12 +48,10 @@ export function roundOffPixel(value: number): number {
43
48
  }
44
49
 
45
50
  /**
46
- * Measures the layout of parent of RecyclerView
51
+ * Measures the size of the RecyclerView's outer container.
47
52
  */
48
- export function measureParentSize(view: Element): Layout {
53
+ export function measureParentSize(view: Element): Size {
49
54
  return {
50
- x: 0,
51
- y: 0,
52
55
  width: view.clientWidth,
53
56
  height: view.clientHeight,
54
57
  };
@@ -89,12 +89,10 @@ export default class ViewabilityManager<T> {
89
89
  });
90
90
  };
91
91
 
92
- public recomputeViewableItems = () => {
92
+ public clearLastReportedViewableIndices = () => {
93
93
  this.viewabilityHelpers.forEach((viewabilityHelper) =>
94
94
  viewabilityHelper.clearLastReportedViewableIndices()
95
95
  );
96
-
97
- this.updateViewableItems();
98
96
  };
99
97
 
100
98
  /**
@@ -33,9 +33,11 @@ export class AverageWindow {
33
33
 
34
34
  this.inputValues[target] = value;
35
35
 
36
- this.currentAverage =
36
+ this.currentAverage = Math.max(
37
+ 0,
37
38
  this.currentAverage * (this.currentCount / newCount) +
38
- (value - (oldValue ?? 0)) / newCount;
39
+ (value - (oldValue ?? 0)) / newCount
40
+ );
39
41
 
40
42
  this.currentCount = newCount;
41
43
  }