@shopify/flash-list 2.0.0-alpha.10 → 2.0.0-alpha.11

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 (119) hide show
  1. package/README.md +6 -2
  2. package/dist/AnimatedFlashList.d.ts.map +1 -1
  3. package/dist/AnimatedFlashList.js +3 -3
  4. package/dist/AnimatedFlashList.js.map +1 -1
  5. package/dist/FlashList.d.ts +9 -0
  6. package/dist/FlashList.d.ts.map +1 -1
  7. package/dist/FlashList.js +20 -0
  8. package/dist/FlashList.js.map +1 -1
  9. package/dist/FlashListProps.d.ts +13 -6
  10. package/dist/FlashListProps.d.ts.map +1 -1
  11. package/dist/FlashListProps.js.map +1 -1
  12. package/dist/FlashListRef.d.ts +295 -0
  13. package/dist/FlashListRef.d.ts.map +1 -0
  14. package/dist/FlashListRef.js +3 -0
  15. package/dist/FlashListRef.js.map +1 -0
  16. package/dist/__tests__/RecyclerView.test.js +62 -27
  17. package/dist/__tests__/RecyclerView.test.js.map +1 -1
  18. package/dist/__tests__/RenderStackManager.test.d.ts +2 -0
  19. package/dist/__tests__/RenderStackManager.test.d.ts.map +1 -0
  20. package/dist/__tests__/RenderStackManager.test.js +405 -0
  21. package/dist/__tests__/RenderStackManager.test.js.map +1 -0
  22. package/dist/__tests__/useUnmountAwareCallbacks.test.js +1 -1
  23. package/dist/__tests__/useUnmountAwareCallbacks.test.js.map +1 -1
  24. package/dist/benchmark/useFlatListBenchmark.js +8 -7
  25. package/dist/benchmark/useFlatListBenchmark.js.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/recyclerview/RecyclerView.d.ts +2 -1
  30. package/dist/recyclerview/RecyclerView.d.ts.map +1 -1
  31. package/dist/recyclerview/RecyclerView.js +33 -14
  32. package/dist/recyclerview/RecyclerView.js.map +1 -1
  33. package/dist/recyclerview/RecyclerViewContextProvider.d.ts +6 -5
  34. package/dist/recyclerview/RecyclerViewContextProvider.d.ts.map +1 -1
  35. package/dist/recyclerview/RecyclerViewContextProvider.js.map +1 -1
  36. package/dist/recyclerview/RecyclerViewManager.d.ts +11 -7
  37. package/dist/recyclerview/RecyclerViewManager.d.ts.map +1 -1
  38. package/dist/recyclerview/RecyclerViewManager.js +57 -102
  39. package/dist/recyclerview/RecyclerViewManager.js.map +1 -1
  40. package/dist/recyclerview/RenderStackManager.d.ts +85 -0
  41. package/dist/recyclerview/RenderStackManager.d.ts.map +1 -0
  42. package/dist/recyclerview/RenderStackManager.js +261 -0
  43. package/dist/recyclerview/RenderStackManager.js.map +1 -0
  44. package/dist/recyclerview/ViewHolder.d.ts.map +1 -1
  45. package/dist/recyclerview/ViewHolder.js +5 -3
  46. package/dist/recyclerview/ViewHolder.js.map +1 -1
  47. package/dist/recyclerview/ViewHolderCollection.d.ts +3 -1
  48. package/dist/recyclerview/ViewHolderCollection.d.ts.map +1 -1
  49. package/dist/recyclerview/ViewHolderCollection.js +19 -3
  50. package/dist/recyclerview/ViewHolderCollection.js.map +1 -1
  51. package/dist/recyclerview/components/ScrollAnchor.d.ts.map +1 -1
  52. package/dist/recyclerview/components/ScrollAnchor.js +1 -1
  53. package/dist/recyclerview/components/ScrollAnchor.js.map +1 -1
  54. package/dist/recyclerview/components/StickyHeaders.d.ts.map +1 -1
  55. package/dist/recyclerview/components/StickyHeaders.js +44 -17
  56. package/dist/recyclerview/components/StickyHeaders.js.map +1 -1
  57. package/dist/recyclerview/hooks/useBoundDetection.d.ts +1 -2
  58. package/dist/recyclerview/hooks/useBoundDetection.d.ts.map +1 -1
  59. package/dist/recyclerview/hooks/useBoundDetection.js +19 -16
  60. package/dist/recyclerview/hooks/useBoundDetection.js.map +1 -1
  61. package/dist/recyclerview/hooks/useOnLoad.d.ts.map +1 -1
  62. package/dist/recyclerview/hooks/useOnLoad.js +4 -6
  63. package/dist/recyclerview/hooks/useOnLoad.js.map +1 -1
  64. package/dist/recyclerview/hooks/useRecyclerViewController.d.ts +3 -48
  65. package/dist/recyclerview/hooks/useRecyclerViewController.d.ts.map +1 -1
  66. package/dist/recyclerview/hooks/useRecyclerViewController.js +93 -71
  67. package/dist/recyclerview/hooks/useRecyclerViewController.js.map +1 -1
  68. package/dist/recyclerview/hooks/useRecyclerViewManager.d.ts.map +1 -1
  69. package/dist/recyclerview/hooks/useRecyclerViewManager.js +6 -0
  70. package/dist/recyclerview/hooks/useRecyclerViewManager.js.map +1 -1
  71. package/dist/recyclerview/hooks/useSecondaryProps.js +1 -1
  72. package/dist/recyclerview/hooks/useUnmountAwareCallbacks.d.ts +10 -3
  73. package/dist/recyclerview/hooks/useUnmountAwareCallbacks.d.ts.map +1 -1
  74. package/dist/recyclerview/hooks/useUnmountAwareCallbacks.js +33 -4
  75. package/dist/recyclerview/hooks/useUnmountAwareCallbacks.js.map +1 -1
  76. package/dist/recyclerview/layout-managers/GridLayoutManager.d.ts +6 -0
  77. package/dist/recyclerview/layout-managers/GridLayoutManager.d.ts.map +1 -1
  78. package/dist/recyclerview/layout-managers/GridLayoutManager.js +27 -5
  79. package/dist/recyclerview/layout-managers/GridLayoutManager.js.map +1 -1
  80. package/dist/recyclerview/layout-managers/LayoutManager.d.ts +2 -2
  81. package/dist/recyclerview/layout-managers/LayoutManager.js +2 -2
  82. package/dist/tsconfig.tsbuildinfo +1 -1
  83. package/jestSetup.js +30 -11
  84. package/package.json +1 -1
  85. package/src/AnimatedFlashList.ts +3 -2
  86. package/src/FlashList.tsx +24 -0
  87. package/src/FlashListProps.ts +16 -7
  88. package/src/FlashListRef.ts +309 -0
  89. package/src/__tests__/RecyclerView.test.tsx +83 -29
  90. package/src/__tests__/RenderStackManager.test.ts +488 -0
  91. package/src/__tests__/useUnmountAwareCallbacks.test.tsx +12 -12
  92. package/src/benchmark/useFlatListBenchmark.ts +2 -2
  93. package/src/index.ts +1 -0
  94. package/src/recyclerview/RecyclerView.tsx +38 -23
  95. package/src/recyclerview/RecyclerViewContextProvider.ts +12 -6
  96. package/src/recyclerview/RecyclerViewManager.ts +73 -88
  97. package/src/recyclerview/RenderStackManager.ts +265 -0
  98. package/src/recyclerview/ViewHolder.tsx +5 -3
  99. package/src/recyclerview/ViewHolderCollection.tsx +29 -8
  100. package/src/recyclerview/components/ScrollAnchor.tsx +9 -5
  101. package/src/recyclerview/components/StickyHeaders.tsx +57 -19
  102. package/src/recyclerview/hooks/useBoundDetection.ts +25 -18
  103. package/src/recyclerview/hooks/useOnLoad.ts +4 -6
  104. package/src/recyclerview/hooks/useRecyclerViewController.tsx +104 -125
  105. package/src/recyclerview/hooks/useRecyclerViewManager.ts +6 -0
  106. package/src/recyclerview/hooks/useSecondaryProps.tsx +1 -1
  107. package/src/recyclerview/hooks/useUnmountAwareCallbacks.ts +39 -3
  108. package/src/recyclerview/layout-managers/GridLayoutManager.ts +30 -7
  109. package/src/recyclerview/layout-managers/LayoutManager.ts +2 -2
  110. package/dist/__tests__/RecycleKeyManager.test.d.ts +0 -2
  111. package/dist/__tests__/RecycleKeyManager.test.d.ts.map +0 -1
  112. package/dist/__tests__/RecycleKeyManager.test.js +0 -210
  113. package/dist/__tests__/RecycleKeyManager.test.js.map +0 -1
  114. package/dist/recyclerview/RecycleKeyManager.d.ts +0 -82
  115. package/dist/recyclerview/RecycleKeyManager.d.ts.map +0 -1
  116. package/dist/recyclerview/RecycleKeyManager.js +0 -135
  117. package/dist/recyclerview/RecycleKeyManager.js.map +0 -1
  118. package/src/__tests__/RecycleKeyManager.test.ts +0 -254
  119. package/src/recyclerview/RecycleKeyManager.ts +0 -185
@@ -0,0 +1,265 @@
1
+ import { ConsecutiveNumbers } from "./helpers/ConsecutiveNumbers";
2
+
3
+ /**
4
+ * Manages the recycling of rendered items in a virtualized list.
5
+ * This class handles tracking, recycling, and reusing item keys to optimize
6
+ * rendering performance by minimizing creation/destruction of components.
7
+ */
8
+ export class RenderStackManager {
9
+ public disableRecycling = false;
10
+
11
+ // Maximum number of items that can be in the recycle pool
12
+ private maxItemsInRecyclePool: number;
13
+
14
+ // Stores pools of recycled keys for each item type
15
+ private recycleKeyPools: Map<string, Set<string>>;
16
+
17
+ // Maps active keys to their metadata (item type and stable ID)
18
+ private keyMap: Map<
19
+ string,
20
+ { itemType: string; index: number; stableId: string }
21
+ >;
22
+
23
+ // Maps stable IDs to their corresponding keys for quick lookups
24
+ private stableIdMap: Map<string, string>;
25
+
26
+ // Counter for generating unique sequential keys
27
+ private keyCounter: number;
28
+
29
+ /**
30
+ * @param maxItemsInRecyclePool - Maximum number of items that can be in the recycle pool
31
+ */
32
+ constructor(maxItemsInRecyclePool: number = Number.MAX_SAFE_INTEGER) {
33
+ this.maxItemsInRecyclePool = maxItemsInRecyclePool;
34
+ this.recycleKeyPools = new Map();
35
+ this.keyMap = new Map();
36
+ this.stableIdMap = new Map();
37
+ this.keyCounter = 0;
38
+ }
39
+
40
+ /**
41
+ * Synchronizes the render stack with the current state of data.
42
+ * This method is the core orchestrator that:
43
+ * 1. Recycles keys for items that are no longer valid
44
+ * 2. Updates existing keys for items that remain visible
45
+ * 3. Assigns new keys for newly visible items
46
+ * 4. Cleans up excess items to maintain the recycling pool size
47
+ *
48
+ * @param getStableId - Function to get a stable identifier for an item at a specific index
49
+ * @param getItemType - Function to get the type of an item at a specific index
50
+ * @param engagedIndices - Collection of indices that are currently visible or engaged
51
+ * @param dataLength - Total length of the data set
52
+ */
53
+ public sync(
54
+ getStableId: (index: number) => string,
55
+ getItemType: (index: number) => string,
56
+ engagedIndices: ConsecutiveNumbers,
57
+ dataLength: number
58
+ ) {
59
+ if (this.disableRecycling) {
60
+ this.clearRecyclePool();
61
+ }
62
+ // Recycle keys for items that are no longer valid or visible
63
+ this.keyMap.forEach((keyInfo, key) => {
64
+ const { index, stableId, itemType } = keyInfo;
65
+ if (index >= dataLength) {
66
+ this.recycleKey(key);
67
+ return;
68
+ }
69
+ const newStableId = getStableId(index);
70
+ const newItemType = getItemType(index);
71
+ if (stableId !== newStableId || itemType !== newItemType) {
72
+ this.recycleKey(key);
73
+ return;
74
+ }
75
+ if (!engagedIndices.includes(index)) {
76
+ this.recycleKey(key);
77
+ }
78
+ });
79
+
80
+ // First pass: process items that already have optimized keys
81
+ for (const index of engagedIndices) {
82
+ if (this.hasOptimizedKey(getStableId(index))) {
83
+ this.syncItem(index, getItemType(index), getStableId(index));
84
+ }
85
+ }
86
+
87
+ // Second pass: process remaining items that need new keys
88
+ for (const index of engagedIndices) {
89
+ if (!this.hasOptimizedKey(getStableId(index))) {
90
+ this.syncItem(index, getItemType(index), getStableId(index));
91
+ }
92
+ }
93
+
94
+ // Clean up stale items and manage the recycle pool size
95
+ this.cleanup(getStableId, engagedIndices, dataLength);
96
+ }
97
+
98
+ /**
99
+ * Checks if a stable ID already has an assigned key
100
+ */
101
+ private hasOptimizedKey(stableId: string): boolean {
102
+ return this.stableIdMap.has(stableId);
103
+ }
104
+
105
+ /**
106
+ * Cleans up stale keys and manages the recycle pool size.
107
+ * This ensures we don't maintain references to items that are no longer in the dataset,
108
+ * and limits the number of recycled items to avoid excessive memory usage.
109
+ */
110
+ private cleanup(
111
+ getStableId: (index: number) => string,
112
+ engagedIndices: ConsecutiveNumbers,
113
+ dataLength: number
114
+ ) {
115
+ const itemsToDelete = new Array<string>();
116
+
117
+ // Remove items that are no longer in the dataset
118
+ for (const [key, keyInfo] of this.keyMap.entries()) {
119
+ const { index, itemType, stableId } = keyInfo;
120
+ if (index >= dataLength || getStableId(index) !== stableId) {
121
+ this.deleteKeyFromRecyclePool(itemType, key);
122
+ this.stableIdMap.delete(stableId);
123
+ itemsToDelete.push(key);
124
+ }
125
+ }
126
+
127
+ for (const key of itemsToDelete) {
128
+ this.keyMap.delete(key);
129
+ }
130
+
131
+ // Limit the size of the recycle pool
132
+ const itemsRenderedForRecycling = this.keyMap.size - engagedIndices.length;
133
+ if (itemsRenderedForRecycling > this.maxItemsInRecyclePool) {
134
+ const deleteCount =
135
+ itemsRenderedForRecycling - this.maxItemsInRecyclePool;
136
+ let deleted = 0;
137
+
138
+ // Use a for loop so we can break early once we've deleted enough items
139
+ const entries = Array.from(this.keyMap.entries()).reverse();
140
+ for (let i = 0; i < entries.length && deleted < deleteCount; i++) {
141
+ const [key, keyInfo] = entries[i];
142
+ const { index, itemType, stableId } = keyInfo;
143
+
144
+ if (!engagedIndices.includes(index)) {
145
+ this.deleteKeyFromRecyclePool(itemType, key);
146
+ this.stableIdMap.delete(stableId);
147
+ this.keyMap.delete(key);
148
+ deleted++;
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Places a key back into its type-specific recycle pool for future reuse
156
+ */
157
+ private recycleKey(key: string): void {
158
+ if (this.disableRecycling) {
159
+ return;
160
+ }
161
+ const keyInfo = this.keyMap.get(key);
162
+
163
+ if (!keyInfo) {
164
+ return;
165
+ }
166
+
167
+ const { itemType } = keyInfo;
168
+
169
+ // Add key back to its type's pool
170
+ const pool = this.getRecyclePoolForType(itemType);
171
+
172
+ pool.add(key);
173
+ }
174
+
175
+ /**
176
+ * Returns the current render stack containing all active keys and their metadata
177
+ */
178
+ public getRenderStack() {
179
+ return this.keyMap;
180
+ }
181
+
182
+ /**
183
+ * Syncs an individual item by assigning it an appropriate key.
184
+ * Will use an existing key if available, or generate a new one.
185
+ *
186
+ * @returns The key assigned to the item
187
+ */
188
+ private syncItem(index: number, itemType: string, stableId: string): string {
189
+ // Try to reuse an existing key, or get one from the recycle pool, or generate a new one
190
+ const newKey =
191
+ this.stableIdMap.get(stableId) ||
192
+ this.getKeyFromRecyclePool(itemType) ||
193
+ this.generateKey();
194
+
195
+ const keyInfo = this.keyMap.get(newKey);
196
+ if (keyInfo) {
197
+ // Update an existing key's metadata
198
+ this.deleteKeyFromRecyclePool(itemType, newKey);
199
+ this.deleteKeyFromRecyclePool(keyInfo.itemType, newKey);
200
+ this.stableIdMap.delete(keyInfo.stableId);
201
+ keyInfo.index = index;
202
+ keyInfo.itemType = itemType;
203
+ keyInfo.stableId = stableId;
204
+ } else {
205
+ // Create a new entry in the key map
206
+ this.keyMap.set(newKey, {
207
+ itemType,
208
+ index,
209
+ stableId,
210
+ });
211
+ }
212
+ this.stableIdMap.set(stableId, newKey);
213
+
214
+ return newKey;
215
+ }
216
+
217
+ /**
218
+ * Clears all recycled keys from the pool, effectively resetting the recycling system.
219
+ * This operation does not affect currently active keys.
220
+ */
221
+ private clearRecyclePool() {
222
+ this.recycleKeyPools.clear();
223
+ }
224
+
225
+ /**
226
+ * Generates a unique sequential key using an internal counter.
227
+ * @returns A unique key as a string
228
+ */
229
+ private generateKey(): string {
230
+ return (this.keyCounter++).toString();
231
+ }
232
+
233
+ /**
234
+ * Removes a specific key from its type's recycle pool
235
+ */
236
+ private deleteKeyFromRecyclePool(itemType: string, key: string) {
237
+ this.recycleKeyPools.get(itemType)?.delete(key);
238
+ }
239
+
240
+ /**
241
+ * Gets or creates a recycle pool for a specific item type
242
+ */
243
+ private getRecyclePoolForType(itemType: string) {
244
+ let pool = this.recycleKeyPools.get(itemType);
245
+ if (!pool) {
246
+ pool = new Set();
247
+ this.recycleKeyPools.set(itemType, pool);
248
+ }
249
+ return pool;
250
+ }
251
+
252
+ /**
253
+ * Retrieves and removes a key from the type's recycle pool
254
+ * @returns A recycled key or undefined if none available
255
+ */
256
+ private getKeyFromRecyclePool(itemType: string) {
257
+ const pool = this.getRecyclePoolForType(itemType);
258
+ if (pool.size > 0) {
259
+ const key = pool.values().next().value;
260
+ pool.delete(key);
261
+ return key;
262
+ }
263
+ return undefined;
264
+ }
265
+ }
@@ -88,7 +88,7 @@ const ViewHolderInternal = <TItem,>(props: ViewHolderProps<TItem>) => {
88
88
  );
89
89
 
90
90
  const separator = useMemo(() => {
91
- return ItemSeparatorComponent ? (
91
+ return ItemSeparatorComponent && trailingItem !== undefined ? (
92
92
  <ItemSeparatorComponent leadingItem={item} trailingItem={trailingItem} />
93
93
  ) : null;
94
94
  }, [ItemSeparatorComponent, item, trailingItem]);
@@ -97,7 +97,10 @@ const ViewHolderInternal = <TItem,>(props: ViewHolderProps<TItem>) => {
97
97
 
98
98
  const children = useMemo(() => {
99
99
  return renderItem?.({ item, index, extraData, target }) ?? null;
100
- }, [item, index, extraData, target, renderItem]);
100
+ // TODO: Test more thoroughly
101
+ // We don't really to re-render the children when the index changes
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ }, [item, extraData, target, renderItem]);
101
104
 
102
105
  const style = {
103
106
  flexDirection: horizontal ? "row" : "column",
@@ -110,7 +113,6 @@ const ViewHolderInternal = <TItem,>(props: ViewHolderProps<TItem>) => {
110
113
  maxWidth: layout.maxWidth,
111
114
  left: layout.x,
112
115
  top: layout.y,
113
- zIndex: 0,
114
116
  } as const;
115
117
 
116
118
  // TODO: Fix this type issue
@@ -21,7 +21,7 @@ export interface ViewHolderCollectionProps<TItem> {
21
21
  /** The data array to be rendered */
22
22
  data: FlashListProps<TItem>["data"];
23
23
  /** Map of indices to React keys for each rendered item */
24
- renderStack: Map<number, string>;
24
+ renderStack: Map<string, { index: number }>;
25
25
  /** Function to get layout information for a specific index */
26
26
  getLayout: (index: number) => RVLayout;
27
27
  /** Ref to control layout updates from parent components */
@@ -99,27 +99,37 @@ export const ViewHolderCollection = <TItem,>(
99
99
  // );
100
100
  recyclerViewContext?.layout();
101
101
  }
102
+ // we need to run this callback on when fixedContainerSize changes
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
104
  }, [fixedContainerSize]);
103
105
 
104
106
  useLayoutEffect(() => {
105
107
  if (renderId > 0) {
106
108
  onCommitLayoutEffect?.();
107
109
  }
110
+ // we need to run this callback on when renderId changes
111
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
112
  }, [renderId]);
109
113
 
110
114
  useEffect(() => {
111
115
  if (renderId > 0) {
112
116
  onCommitEffect?.();
113
117
  }
118
+ // we need to run this callback on when renderId changes
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
114
120
  }, [renderId]);
115
121
 
116
122
  // Expose forceUpdate through ref
117
- useImperativeHandle(viewHolderCollectionRef, () => ({
118
- commitLayout: () => {
119
- // This will trigger a re-render of the component
120
- setRenderId((prev) => prev + 1);
121
- },
122
- }));
123
+ useImperativeHandle(
124
+ viewHolderCollectionRef,
125
+ () => ({
126
+ commitLayout: () => {
127
+ // This will trigger a re-render of the component
128
+ setRenderId((prev) => prev + 1);
129
+ },
130
+ }),
131
+ [setRenderId]
132
+ );
123
133
 
124
134
  const hasData = data && data.length > 0;
125
135
 
@@ -128,6 +138,17 @@ export const ViewHolderCollection = <TItem,>(
128
138
  height: containerLayout?.height,
129
139
  };
130
140
 
141
+ // sort by index and log
142
+ // const sortedRenderStack = Array.from(renderStack.entries()).sort(
143
+ // ([, a], [, b]) => a.index - b.index
144
+ // );
145
+ // console.log(
146
+ // "sortedRenderStack",
147
+ // sortedRenderStack.map(([reactKey, { index }]) => {
148
+ // return `${index} => ${reactKey}`;
149
+ // })
150
+ // );
151
+
131
152
  return (
132
153
  <CompatView
133
154
  // TODO: Take care of web scroll bar here
@@ -135,7 +156,7 @@ export const ViewHolderCollection = <TItem,>(
135
156
  >
136
157
  {containerLayout &&
137
158
  hasData &&
138
- Array.from(renderStack, ([index, reactKey]) => {
159
+ Array.from(renderStack.entries(), ([reactKey, { index }]) => {
139
160
  const item = data[index];
140
161
  const trailingItem = ItemSeparatorComponent
141
162
  ? data[index + 1]
@@ -35,11 +35,15 @@ export function ScrollAnchor({ scrollAnchorRef }: ScrollAnchorProps) {
35
35
  const [scrollOffset, setScrollOffset] = useState(1000000); // TODO: Fix this value
36
36
 
37
37
  // Expose scrollBy method through ref
38
- useImperativeHandle(scrollAnchorRef, () => ({
39
- scrollBy: (offset: number) => {
40
- setScrollOffset((prev) => prev + offset);
41
- },
42
- }));
38
+ useImperativeHandle(
39
+ scrollAnchorRef,
40
+ () => ({
41
+ scrollBy: (offset: number) => {
42
+ setScrollOffset((prev) => prev + offset);
43
+ },
44
+ }),
45
+ []
46
+ );
43
47
 
44
48
  // Create an invisible anchor element that can be positioned
45
49
  const anchor = useMemo(() => {
@@ -58,25 +58,45 @@ export const StickyHeaders = <TItem,>({
58
58
  data,
59
59
  extraData,
60
60
  }: StickyHeaderProps<TItem>) => {
61
- const [stickyIndices, setStickyIndices] = useState<{
61
+ const [stickyHeaderState, setStickyHeaderState] = useState<{
62
62
  currentStickyIndex: number;
63
63
  nextStickyIndex: number;
64
- }>({ currentStickyIndex: -1, nextStickyIndex: -1 });
65
-
66
- const { currentStickyIndex, nextStickyIndex } = stickyIndices;
64
+ currentStickyHeight: number;
65
+ nextStickyY: number;
66
+ }>({
67
+ currentStickyIndex: -1,
68
+ nextStickyIndex: -1,
69
+ currentStickyHeight: 0,
70
+ nextStickyY: 0,
71
+ });
72
+
73
+ const {
74
+ currentStickyIndex,
75
+ nextStickyIndex,
76
+ nextStickyY,
77
+ currentStickyHeight,
78
+ } = stickyHeaderState;
67
79
 
68
80
  // Memoize sorted indices based on their Y positions
69
81
  const sortedIndices = useMemo(() => {
70
82
  return stickyHeaderIndices.sort((first, second) => first - second);
71
83
  }, [stickyHeaderIndices]);
72
84
 
85
+ const legthInvalid =
86
+ sortedIndices.length === 0 ||
87
+ recyclerViewManager.getDataLength() <=
88
+ sortedIndices[sortedIndices.length - 1];
89
+
73
90
  const compute = useCallback(() => {
74
- const adjustedValue = recyclerViewManager.getLastScrollOffset();
91
+ if (legthInvalid) {
92
+ return;
93
+ }
94
+ const adjustedScrollOffset = recyclerViewManager.getLastScrollOffset();
75
95
 
76
96
  // Binary search for current sticky index
77
97
  const currentIndexInArray = findCurrentStickyIndex(
78
98
  sortedIndices,
79
- adjustedValue,
99
+ adjustedScrollOffset,
80
100
  (index) => recyclerViewManager.getLayout(index).y
81
101
  );
82
102
 
@@ -87,16 +107,34 @@ export const StickyHeaders = <TItem,>({
87
107
  newNextStickyIndex = -1;
88
108
  }
89
109
 
110
+ const newNextStickyY =
111
+ (recyclerViewManager.tryGetLayout(newNextStickyIndex)?.y ?? 0) +
112
+ recyclerViewManager.firstItemOffset;
113
+ const newCurrentStickyHeight =
114
+ recyclerViewManager.tryGetLayout(newStickyIndex)?.height ?? 0;
115
+
90
116
  if (
91
117
  newStickyIndex !== currentStickyIndex ||
92
- newNextStickyIndex !== nextStickyIndex
118
+ newNextStickyIndex !== nextStickyIndex ||
119
+ newNextStickyY !== nextStickyY ||
120
+ newCurrentStickyHeight !== currentStickyHeight
93
121
  ) {
94
- setStickyIndices({
122
+ setStickyHeaderState({
95
123
  currentStickyIndex: newStickyIndex,
96
124
  nextStickyIndex: newNextStickyIndex,
125
+ nextStickyY: newNextStickyY,
126
+ currentStickyHeight: newCurrentStickyHeight,
97
127
  });
98
128
  }
99
- }, [currentStickyIndex, nextStickyIndex, recyclerViewManager, sortedIndices]);
129
+ }, [
130
+ currentStickyIndex,
131
+ nextStickyIndex,
132
+ recyclerViewManager,
133
+ sortedIndices,
134
+ legthInvalid,
135
+ nextStickyY,
136
+ currentStickyHeight,
137
+ ]);
100
138
 
101
139
  useEffect(() => {
102
140
  compute();
@@ -125,20 +163,20 @@ export const StickyHeaders = <TItem,>({
125
163
  });
126
164
  }
127
165
 
128
- const currentLayout = recyclerViewManager.getLayout(currentStickyIndex);
129
- const nextLayout = recyclerViewManager.getLayout(nextStickyIndex);
130
-
131
- const pushStartsAt = nextLayout.y - currentLayout.height;
166
+ const pushStartsAt = nextStickyY - currentStickyHeight;
132
167
 
133
168
  return scrollY.interpolate({
134
- inputRange: [
135
- pushStartsAt + recyclerViewManager.firstItemOffset,
136
- nextLayout.y + recyclerViewManager.firstItemOffset,
137
- ],
138
- outputRange: [0, -currentLayout.height],
169
+ inputRange: [pushStartsAt, nextStickyY],
170
+ outputRange: [0, -currentStickyHeight],
139
171
  extrapolate: "clamp",
140
172
  });
141
- }, [currentStickyIndex, nextStickyIndex, recyclerViewManager, scrollY]);
173
+ }, [
174
+ currentStickyHeight,
175
+ currentStickyIndex,
176
+ nextStickyIndex,
177
+ nextStickyY,
178
+ scrollY,
179
+ ]);
142
180
 
143
181
  // Memoize header content
144
182
  const headerContent = useMemo(() => {
@@ -1,9 +1,10 @@
1
1
  import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
 
3
3
  import { RecyclerViewManager } from "../RecyclerViewManager";
4
- import { RecyclerViewProps } from "../RecyclerViewProps";
5
4
  import { CompatScroller } from "../components/CompatScroller";
6
5
 
6
+ import { useUnmountAwareAnimationFrame } from "./useUnmountAwareCallbacks";
7
+
7
8
  /**
8
9
  * Hook to detect when the scroll position reaches near the start or end of the list
9
10
  * and trigger the appropriate callbacks. This hook is responsible for:
@@ -17,7 +18,6 @@ import { CompatScroller } from "../components/CompatScroller";
17
18
  */
18
19
  export function useBoundDetection<T>(
19
20
  recyclerViewManager: RecyclerViewManager<T>,
20
- props: RecyclerViewProps<T>,
21
21
  scrollViewRef: React.RefObject<CompatScroller>
22
22
  ) {
23
23
  // Track whether we've already triggered the end reached callback to prevent duplicate calls
@@ -26,22 +26,27 @@ export function useBoundDetection<T>(
26
26
  const pendingStartReached = useRef(false);
27
27
  // Track whether we should auto-scroll to bottom when new content is added
28
28
  const pendingAutoscrollToBottom = useRef(false);
29
- const { horizontal, data, maintainVisibleContentPosition } = props;
29
+ const { data } = recyclerViewManager.props;
30
+ const { requestAnimationFrame } = useUnmountAwareAnimationFrame();
30
31
 
31
32
  /**
32
33
  * Checks if the scroll position is near the start or end of the list
33
34
  * and triggers appropriate callbacks if configured.
34
35
  */
35
36
  const checkBounds = useCallback(() => {
37
+ const {
38
+ onEndReached,
39
+ onStartReached,
40
+ maintainVisibleContentPosition,
41
+ horizontal,
42
+ onEndReachedThreshold: onEndReachedThresholdProp,
43
+ onStartReachedThreshold: onStartReachedThresholdProp,
44
+ } = recyclerViewManager.props;
36
45
  // Skip all calculations if neither callback is provided and autoscroll is disabled
37
46
  const autoscrollToBottomThreshold =
38
47
  maintainVisibleContentPosition?.autoscrollToBottomThreshold ?? -1;
39
48
 
40
- if (
41
- !props.onEndReached &&
42
- !props.onStartReached &&
43
- autoscrollToBottomThreshold < 0
44
- ) {
49
+ if (!onEndReached && !onStartReached && autoscrollToBottomThreshold < 0) {
45
50
  return;
46
51
  }
47
52
 
@@ -50,7 +55,7 @@ export function useBoundDetection<T>(
50
55
  recyclerViewManager.getAbsoluteLastScrollOffset();
51
56
  const contentSize = recyclerViewManager.getChildContainerDimensions();
52
57
  const windowSize = recyclerViewManager.getWindowSize();
53
- const isHorizontal = props.horizontal === true;
58
+ const isHorizontal = horizontal === true;
54
59
 
55
60
  // Calculate dimensions based on scroll direction
56
61
  const visibleLength = isHorizontal ? windowSize.width : windowSize.height;
@@ -59,8 +64,8 @@ export function useBoundDetection<T>(
59
64
  recyclerViewManager.firstItemOffset;
60
65
 
61
66
  // Check if we're near the end of the list
62
- if (props.onEndReached) {
63
- const onEndReachedThreshold = props.onEndReachedThreshold ?? 0.5;
67
+ if (onEndReached) {
68
+ const onEndReachedThreshold = onEndReachedThresholdProp ?? 0.5;
64
69
  const endThresholdDistance = onEndReachedThreshold * visibleLength;
65
70
 
66
71
  const isNearEnd =
@@ -69,27 +74,27 @@ export function useBoundDetection<T>(
69
74
 
70
75
  if (isNearEnd && !pendingEndReached.current) {
71
76
  pendingEndReached.current = true;
72
- props.onEndReached();
77
+ onEndReached();
73
78
  }
74
79
  pendingEndReached.current = isNearEnd;
75
80
  }
76
81
 
77
82
  // Check if we're near the start of the list
78
- if (props.onStartReached) {
79
- const onStartReachedThreshold = props.onStartReachedThreshold ?? 0.2;
83
+ if (onStartReached) {
84
+ const onStartReachedThreshold = onStartReachedThresholdProp ?? 0.2;
80
85
  const startThresholdDistance = onStartReachedThreshold * visibleLength;
81
86
 
82
87
  const isNearStart = lastScrollOffset <= startThresholdDistance;
83
88
 
84
89
  if (isNearStart && !pendingStartReached.current) {
85
90
  pendingStartReached.current = true;
86
- props.onStartReached();
91
+ onStartReached();
87
92
  }
88
93
  pendingStartReached.current = isNearStart;
89
94
  }
90
95
 
91
96
  // Handle auto-scrolling to bottom for vertical lists
92
- if (!horizontal) {
97
+ if (!isHorizontal) {
93
98
  const autoscrollToBottomThresholdDistance =
94
99
  autoscrollToBottomThreshold * visibleLength;
95
100
 
@@ -104,11 +109,13 @@ export function useBoundDetection<T>(
104
109
  }
105
110
  }
106
111
  }
107
- }, [recyclerViewManager, props]);
112
+ }, [recyclerViewManager]);
108
113
 
109
114
  // Reset end reached state when data changes
110
115
  useMemo(() => {
111
116
  pendingEndReached.current = false;
117
+ // needs to run only when data changes
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
112
119
  }, [data]);
113
120
 
114
121
  // Auto-scroll to bottom when new content is added and we're near the bottom
@@ -119,7 +126,7 @@ export function useBoundDetection<T>(
119
126
  pendingAutoscrollToBottom.current = false;
120
127
  });
121
128
  }
122
- }, [data]);
129
+ }, [data, requestAnimationFrame, scrollViewRef]);
123
130
 
124
131
  return {
125
132
  checkBounds,
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
2
2
 
3
3
  import { RecyclerViewManager } from "../RecyclerViewManager";
4
4
 
5
- import { useUnmountFlag } from "./useUnmountFlag";
5
+ import { useUnmountAwareAnimationFrame } from "./useUnmountAwareCallbacks";
6
6
  // import { ToastAndroid } from "react-native";
7
7
 
8
8
  /**
@@ -22,7 +22,7 @@ export const useOnListLoad = <T>(
22
22
  const [isLoaded, setIsLoaded] = useState<boolean>(false);
23
23
  const dataLength = recyclerViewManager.getDataLength();
24
24
  // const dataCollector = useRef<number[]>([]);
25
- const isUnmounted = useUnmountFlag();
25
+ const { requestAnimationFrame } = useUnmountAwareAnimationFrame();
26
26
  // Track render cycles by collecting elapsed time on each render
27
27
  // useEffect(() => {
28
28
  // const elapsedTimeInMs = Date.now() - loadStartTimeRef.current;
@@ -48,10 +48,8 @@ export const useOnListLoad = <T>(
48
48
  // console.log("----------> dataCollector", dataCollectorString);
49
49
  // console.log("----------> FlashList v2 load in", `${elapsedTimeInMs} ms`);
50
50
  requestAnimationFrame(() => {
51
- if (!isUnmounted.current) {
52
- onLoad?.({ elapsedTimeInMs });
53
- setIsLoaded(true);
54
- }
51
+ onLoad?.({ elapsedTimeInMs });
52
+ setIsLoaded(true);
55
53
  });
56
54
  });
57
55