@rific/scroll-view 0.2.0 → 0.4.0

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 (35) hide show
  1. package/README.md +2 -0
  2. package/dist/index.d.mts +23 -17
  3. package/dist/index.d.ts +23 -17
  4. package/dist/index.js +720 -1960
  5. package/dist/index.mjs +697 -1939
  6. package/package.json +10 -10
  7. package/src/CustomList.tsx +32 -8
  8. package/src/FlatList.tsx +102 -20
  9. package/src/PullSearch.tsx +4 -3
  10. package/src/ScrollView.tsx +28 -8
  11. package/src/ScrollViewContext.ts +17 -4
  12. package/src/ScrollViewHeader.tsx +34 -31
  13. package/src/ScrollViewProvider.tsx +22 -6
  14. package/src/SectionList.tsx +31 -10
  15. package/src/internal/useScrollHandler.ts +19 -21
  16. package/src/internal/useScrollHandlerJS.ts +141 -0
  17. package/src/internal/useScrollList.ts +16 -11
  18. package/src/redux/scrollViewSlice.ts +19 -11
  19. package/src/useScrollInit.ts +200 -19
  20. package/src/__mocks__/auto-paper.ts +0 -9
  21. package/src/__mocks__/flash-list.ts +0 -20
  22. package/src/__mocks__/react-native-gesture-handler.ts +0 -13
  23. package/src/__mocks__/react-native-keyboard-controller.ts +0 -11
  24. package/src/__mocks__/react-native-paper.ts +0 -22
  25. package/src/__mocks__/react-native-reanimated.ts +0 -30
  26. package/src/__mocks__/react-native-safe-area-context.ts +0 -2
  27. package/src/__mocks__/react-native.ts +0 -30
  28. package/src/__tests__/CustomList.test.tsx +0 -61
  29. package/src/__tests__/FlatList.test.tsx +0 -52
  30. package/src/__tests__/PullSearch.test.tsx +0 -38
  31. package/src/__tests__/ScrollView.test.tsx +0 -23
  32. package/src/__tests__/ScrollViewHeader.test.tsx +0 -28
  33. package/src/__tests__/ScrollViewProvider.test.tsx +0 -65
  34. package/src/__tests__/SectionList.test.tsx +0 -88
  35. package/src/out.txt +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/scroll-view",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Blur styled scroll system for React Native. Floating headers/footers, keyboard awareness, and scroll-away chrome",
5
5
  "keywords": [
6
6
  "react-native",
@@ -39,15 +39,15 @@
39
39
  "types": "dist/index.d.ts",
40
40
  "files": [
41
41
  "dist",
42
- "src"
42
+ "src",
43
+ "!src/__tests__",
44
+ "!src/__mocks__"
43
45
  ],
44
46
  "scripts": {
45
47
  "build": "tsup src/index.ts --format cjs,esm --dts --clean",
46
- "dev": "tsup src/index.ts --format cjs,esm --dts --clean --watch",
47
48
  "fix": "eslint --fix",
48
49
  "lint": "eslint",
49
50
  "prepublishOnly": "npm run build",
50
- "push": "yalc publish --push",
51
51
  "release": "git push --follow-tags",
52
52
  "release:major": "npm version major && git push --follow-tags",
53
53
  "release:minor": "npm version minor && git push --follow-tags",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@reduxjs/toolkit": "^2.12.0",
62
- "@rific/auto-paper": "^0.3.1",
62
+ "@rific/auto-paper": "^0.6.0",
63
63
  "@testing-library/dom": "^10.4.1",
64
64
  "@testing-library/react": "^16.3.2",
65
65
  "@types/jest": "^30.0.0",
@@ -90,14 +90,14 @@
90
90
  "typescript-eslint": "^8.59.3"
91
91
  },
92
92
  "peerDependencies": {
93
- "@rific/auto-paper": "^0.3.1",
93
+ "@rific/auto-paper": "^0.6.0",
94
94
  "react": ">=18.0.0",
95
- "react-native": ">=0.73.0",
96
- "react-native-gesture-handler": "*",
97
- "react-native-keyboard-controller": "*",
95
+ "react-native": ">=0.76.0",
96
+ "react-native-gesture-handler": ">=2.0.0",
97
+ "react-native-keyboard-controller": ">=1.0.0",
98
98
  "react-native-paper": ">=5.0.0",
99
99
  "react-native-reanimated": ">=3.0.0",
100
- "react-native-safe-area-context": "*"
100
+ "react-native-safe-area-context": ">=5.0.0"
101
101
  },
102
102
  "publishConfig": {
103
103
  "access": "public"
@@ -1,11 +1,13 @@
1
- import { type ComponentType, memo, type ReactElement, type ReactNode, type RefObject, useCallback, useMemo, useRef } from 'react'
1
+ import { type ComponentType, memo, type ReactElement, type ReactNode, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
2
  import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, type StyleProp, View, type ViewStyle } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
+ import { runOnUI, useSharedValue } from 'react-native-reanimated'
4
5
 
5
6
  import { RefreshControl } from './internal/RefreshControl'
6
7
  import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
7
8
  import { useScrollHandler } from './internal/useScrollHandler'
8
9
  import { useScrollList } from './internal/useScrollList'
10
+ import { ScrollViewContext } from './ScrollViewContext'
9
11
  import { useScrollInit } from './useScrollInit'
10
12
 
11
13
  type ScrollToOffsetRef = { scrollToOffset: (params: { animated?: boolean; offset: number }) => void }
@@ -34,6 +36,30 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
34
36
  const { ListHeaderComponent: listHeaderComponent, contentContainerStyle: externalContentContainerStyle, ...restProps } = props as Record<string, unknown>
35
37
 
36
38
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
39
+ const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
40
+ // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
41
+ // corrects it before any scroll event could observe the placeholder.
42
+ const capturedGeneration = useSharedValue(-1)
43
+ // Layout effects (not passive effects): React guarantees all layout-effect cleanups for fibers
44
+ // removed in a commit run before any layout-effect setups for fibers added in that same commit,
45
+ // but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
46
+ // instance's unmount and the incoming instance's mount land in the same commit — with passive
47
+ // effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
48
+ // capturing a stale generation and silently disabling this instance's onScroll handling forever.
49
+ // Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
50
+ // on the UI thread, and a plain JS-thread write to a SharedValue is not guaranteed to be visible
51
+ // there before a scroll event the UI thread schedules shortly after (e.g. useScrollInit's mount-time
52
+ // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
53
+ // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
54
+ // only be queued after this component finishes mounting.
55
+ useLayoutEffect(() => {
56
+ const generation = listGeneration.value
57
+ runOnUI((gen: number) => {
58
+ 'worklet'
59
+ capturedGeneration.value = gen
60
+ })(generation)
61
+ }, [capturedGeneration, listGeneration])
62
+ useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
37
63
 
38
64
  const scrollViewInternal = useRef<ScrollToOffsetRef | null>(null)
39
65
 
@@ -55,7 +81,10 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
55
81
  handleScrollBeginDrag,
56
82
  handleScrollEndDrag,
57
83
  hiddenHeader,
58
- pullSearchMinHeight
84
+ onRemountSyncRetry,
85
+ onRemountSynced,
86
+ pullSearchMinHeight,
87
+ remountSyncTarget
59
88
  } = useScrollInit({
60
89
  listHeaderComponent: listHeaderComponent as ComponentType<object> | ReactElement | null | undefined,
61
90
  onMomentumScrollEnd: externalMomentumScrollEnd,
@@ -79,12 +108,7 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
79
108
 
80
109
  const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
81
110
 
82
- const onPullSearchZoneEnter = useCallback(() => {
83
- if (!pullSearchHeight) return
84
- scrollViewInternal.current?.scrollToOffset({ offset: -contentInset.top + pullSearchHeight, animated: true })
85
- }, [contentInset.top, pullSearchHeight])
86
-
87
- const onScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
111
+ const onScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget })
88
112
 
89
113
  const handleScrollToTop = useCallback(() => {
90
114
  const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
package/src/FlatList.tsx CHANGED
@@ -1,13 +1,13 @@
1
- import { memo, type ReactNode, type RefObject, useCallback, useMemo, useRef } from 'react'
2
- import { Dimensions, FlatList as RNFlatList, type FlatListProps as RNFlatListProps, type NativeScrollEvent, type NativeSyntheticEvent, View } from 'react-native'
1
+ import { Fragment, memo, type ReactNode, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
+ import { Dimensions, FlatList as RNFlatList, type FlatListProps as RNFlatListProps, type ListRenderItem, type NativeScrollEvent, type NativeSyntheticEvent, type StyleProp, View, type ViewStyle } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
- import Animated from 'react-native-reanimated'
5
4
 
6
5
  import { HorizontalDots } from './internal/HorizontalDots'
7
6
  import { RefreshControl } from './internal/RefreshControl'
8
7
  import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
9
- import { useScrollHandler } from './internal/useScrollHandler'
8
+ import { useScrollHandlerJS } from './internal/useScrollHandlerJS'
10
9
  import { useScrollList } from './internal/useScrollList'
10
+ import { ScrollViewContext } from './ScrollViewContext'
11
11
  import { useScrollInit } from './useScrollInit'
12
12
 
13
13
  export type FlatListProps<T> = Omit<RNFlatListProps<T>, 'onRefresh' | 'refreshing'> & {
@@ -27,12 +27,65 @@ export type FlatListProps<T> = Omit<RNFlatListProps<T>, 'onRefresh' | 'refreshin
27
27
  ref?: RefObject<RNFlatList | null>
28
28
  }
29
29
 
30
- const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: externalContentContainerStyle, data, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pagingEnabled, pullSearchHeight, removeClippedSubviews = false, showsHorizontalScrollIndicator, showsVerticalScrollIndicator, style, onContentSizeChange, ref: externalRef, windowSize = 100, ...props }: FlatListProps<T>) => {
31
- const scrollView = useRef<RNFlatList>(null)
30
+ const FlatListInner = <T,>({ chipProps, chipThreshold, columnWrapperStyle, contentContainerStyle: externalContentContainerStyle, data, initialNumToRender = 20, keyExtractor, maxToRenderPerBatch = 50, numColumns, renderFilters, renderItem, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pagingEnabled, pullSearchHeight, removeClippedSubviews = false, showsHorizontalScrollIndicator, showsVerticalScrollIndicator, style, onContentSizeChange, ref: externalRef, windowSize = 100, ...props }: FlatListProps<T>) => {
31
+ // Plain (non-Animated) FlatList: react-native-reanimated's Animated.FlatList does not reliably
32
+ // honor the initial `contentOffset` prop on a key-forced remount — the header/list can briefly
33
+ // render at the wrong position. A plain FlatList honors it correctly, so scroll position is driven
34
+ // by a regular JS onScroll callback (useScrollHandlerJS) instead of a Reanimated worklet — which
35
+ // also avoids the "WorkletEventHandler" crash that a worklet handler triggers when attached to a
36
+ // non-Animated component.
37
+ const scrollView = useRef<RNFlatList<T>>(null)
32
38
  const isHorizontal = horizontal === true
33
39
  const showDots = isHorizontal && pagingEnabled === true && (data?.length ?? 0) > 1
34
40
 
41
+ // RN's own FlatList implements numColumns by chunking `data` into rows internally, but throws if
42
+ // numColumns ever changes without the caller changing `key` to force a full remount. On iOS,
43
+ // Fabric pools/recycles native ScrollView instances by component type across such a remount, and
44
+ // the recycled instance's onScroll can silently stop firing for the new React instance entirely —
45
+ // a native event-routing issue, not something a JS-side retry loop can detect or correct. Doing
46
+ // the row-chunking ourselves here and always passing numColumns={1} through to the underlying
47
+ // RNFlatList means numColumns never "changes" from RN's perspective — only `data`/`renderItem`
48
+ // change, which RNFlatList treats as an ordinary prop update with no remount at all, so callers
49
+ // can toggle numColumns freely (e.g. switching a list to a grid) without ever hitting this class
50
+ // of bug.
51
+ const cols = numColumns && numColumns > 1 ? numColumns : 1
52
+ const rowData = useMemo(() => {
53
+ if (cols <= 1 || !data) return data
54
+ const items = Array.from(data)
55
+ const rows: T[][] = []
56
+ for (let i = 0; i < items.length; i += cols) rows.push(items.slice(i, i + cols))
57
+ return rows as unknown as readonly T[]
58
+ }, [cols, data])
59
+ const rowKeyExtractor = useCallback(
60
+ (row: T | T[], rowIndex: number) => {
61
+ if (cols <= 1) return keyExtractor ? keyExtractor(row as T, rowIndex) : defaultKeyExtractor(row as T, rowIndex)
62
+ return (row as T[]).map((item, kk) => (keyExtractor ? keyExtractor(item, rowIndex * cols + kk) : defaultKeyExtractor(item, rowIndex * cols + kk))).join(':')
63
+ },
64
+ [cols, keyExtractor]
65
+ )
66
+ const rowRenderItem = useCallback<ListRenderItem<T>>(
67
+ (info) => {
68
+ if (cols <= 1) return renderItem ? renderItem(info) : null
69
+ const row = info.item as unknown as T[]
70
+ return (
71
+ <View style={[styles.row, columnWrapperStyle] as StyleProp<ViewStyle>}>
72
+ {row.map((item, kk) => (
73
+ <Fragment key={kk}>{renderItem ? renderItem({ index: info.index * cols + kk, item, separators: info.separators }) : null}</Fragment>
74
+ ))}
75
+ </View>
76
+ )
77
+ },
78
+ [cols, columnWrapperStyle, renderItem]
79
+ )
80
+
35
81
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style })
82
+ const { jsListGeneration, onJsListUnmount } = useContext(ScrollViewContext)
83
+ // Plain ref, not a SharedValue: see the comment on ScrollViewContextType.jsListGeneration for why.
84
+ const capturedGeneration = useRef(jsListGeneration.current)
85
+ useLayoutEffect(() => {
86
+ capturedGeneration.current = jsListGeneration.current
87
+ }, [jsListGeneration])
88
+ useLayoutEffect(() => () => onJsListUnmount(), [onJsListUnmount])
36
89
 
37
90
  const scrollTo = useCallback((offset: number, animated: boolean) => {
38
91
  scrollView.current?.scrollToOffset({ offset, animated })
@@ -44,7 +97,8 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
44
97
  handleScrollBeginDrag,
45
98
  handleScrollEndDrag,
46
99
  hiddenHeader,
47
- pullSearchMinHeight
100
+ pullSearchMinHeight,
101
+ remountTarget
48
102
  } = useScrollInit({
49
103
  listHeaderComponent: externalListHeaderComponent,
50
104
  onMomentumScrollEnd: externalMomentumScrollEnd,
@@ -68,12 +122,31 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
68
122
 
69
123
  const contentContainerStyle = useMemo(() => (isHorizontal ? externalContentContainerStyle : [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle]), [isHorizontal, contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
70
124
 
71
- const onPullSearchZoneEnter = useCallback(() => {
72
- if (!pullSearchHeight) return
73
- scrollView.current?.scrollToOffset({ offset: -contentInset.top + pullSearchHeight, animated: true })
74
- }, [contentInset.top, pullSearchHeight])
125
+ const scrollHandler = useScrollHandlerJS({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, jsListGeneration, remountTarget, scrollTo })
126
+
127
+ const onScrollBeginDrag = useCallback(
128
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
129
+ scrollHandler.onScrollBeginDrag(e)
130
+ handleScrollBeginDrag(e)
131
+ },
132
+ [scrollHandler, handleScrollBeginDrag]
133
+ )
134
+
135
+ const onScrollEndDrag = useCallback(
136
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
137
+ scrollHandler.onScrollEndDrag(e)
138
+ handleScrollEndDrag(e)
139
+ },
140
+ [scrollHandler, handleScrollEndDrag]
141
+ )
75
142
 
76
- const handleScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
143
+ const onMomentumScrollEnd = useCallback(
144
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
145
+ scrollHandler.onMomentumScrollEnd(e)
146
+ handleMomentumScrollEnd(e)
147
+ },
148
+ [scrollHandler, handleMomentumScrollEnd]
149
+ )
77
150
 
78
151
  const handleScrollToTop = useCallback(() => {
79
152
  if (isHorizontal) {
@@ -98,23 +171,27 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
98
171
 
99
172
  const content = (
100
173
  <View style={containerStyle}>
101
- <Animated.FlatList
174
+ <RNFlatList
102
175
  {...(props as Omit<RNFlatListProps<T>, 'CellRendererComponent'>)}
103
176
  contentContainerStyle={contentContainerStyle}
104
177
  contentInset={contentInset}
178
+ contentInsetAdjustmentBehavior='never'
105
179
  contentOffset={contentOffset}
106
- data={data}
180
+ data={cols > 1 ? rowData : data}
107
181
  horizontal={isHorizontal}
108
182
  initialNumToRender={initialNumToRender}
183
+ keyExtractor={cols > 1 ? rowKeyExtractor : keyExtractor}
109
184
  ListHeaderComponent={activeListHeader}
110
185
  maxToRenderPerBatch={maxToRenderPerBatch}
111
186
  onContentSizeChange={handleContentSizeChange}
112
- onMomentumScrollEnd={handleMomentumScrollEnd}
113
- onScroll={handleScroll}
114
- onScrollBeginDrag={handleScrollBeginDrag}
115
- onScrollEndDrag={handleScrollEndDrag}
187
+ onMomentumScrollBegin={scrollHandler.onMomentumScrollBegin}
188
+ onMomentumScrollEnd={onMomentumScrollEnd}
189
+ onScroll={scrollHandler.onScroll}
190
+ onScrollBeginDrag={onScrollBeginDrag}
191
+ onScrollEndDrag={onScrollEndDrag}
116
192
  pagingEnabled={pagingEnabled}
117
- ref={(el: RNFlatList | null) => {
193
+ renderItem={cols > 1 ? rowRenderItem : renderItem}
194
+ ref={(el: RNFlatList<T> | null) => {
118
195
  if (externalRef) externalRef.current = el
119
196
  scrollView.current = el
120
197
  }}
@@ -137,6 +214,11 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, contentContainerStyle: ex
137
214
  return detectorGesture ? <GestureDetector gesture={detectorGesture}>{content}</GestureDetector> : content
138
215
  }
139
216
 
140
- const styles = { measureContainer: { left: 0, opacity: 0, position: 'absolute' as const, right: 0, top: -9999 } }
217
+ const defaultKeyExtractor = <T,>(item: T, index: number): string => {
218
+ const key = (item as { id?: string; key?: string } | null)?.key ?? (item as { id?: string; key?: string } | null)?.id
219
+ return key != null ? String(key) : String(index)
220
+ }
221
+
222
+ const styles = { measureContainer: { left: 0, opacity: 0, position: 'absolute' as const, right: 0, top: -9999 }, row: { flexDirection: 'row' as const } }
141
223
 
142
224
  export const FlatList = memo(FlatListInner) as typeof FlatListInner
@@ -42,7 +42,7 @@ export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function
42
42
  useAnimatedReaction(
43
43
  () => {
44
44
  if (barHeight === 0) return false
45
- return scrollPosition.value >= -headerHeight + barHeight
45
+ return scrollPosition.value >= -(headerHeight ?? 0) + barHeight
46
46
  },
47
47
  (isHidden, wasHidden) => {
48
48
  if (isHidden && !wasHidden) runOnJS(blurInput)()
@@ -81,8 +81,9 @@ export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function
81
81
  // hideY = -headerHeight + barHeight: bar scrolled behind header, opacity 0.
82
82
  const animatedStyle = useAnimatedStyle(() => {
83
83
  if (barHeight === 0) return { opacity: 0 }
84
- const showY = -headerHeight
85
- const hideY = -headerHeight + barHeight
84
+ const h = headerHeight ?? 0
85
+ const showY = -h
86
+ const hideY = -h + barHeight
86
87
  return { opacity: interpolate(scrollPosition.value, [showY, hideY], [1, 0], Extrapolation.CLAMP) }
87
88
  }, [barHeight, headerHeight])
88
89
 
@@ -1,12 +1,13 @@
1
- import { memo, type RefObject, useCallback, useMemo, useRef } from 'react'
1
+ import { memo, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
2
  import { type NativeScrollEvent, type NativeSyntheticEvent, ScrollView as RNScrollView, type ScrollViewProps as RNScrollViewProps, View } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
- import Animated from 'react-native-reanimated'
4
+ import Animated, { runOnUI, useSharedValue } from 'react-native-reanimated'
5
5
 
6
6
  import { RefreshControl } from './internal/RefreshControl'
7
7
  import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
8
8
  import { useScrollHandler } from './internal/useScrollHandler'
9
9
  import { useScrollList } from './internal/useScrollList'
10
+ import { ScrollViewContext } from './ScrollViewContext'
10
11
  import { useScrollInit } from './useScrollInit'
11
12
 
12
13
  export type ScrollViewProps = RNScrollViewProps & {
@@ -30,6 +31,30 @@ const ScrollViewInner = ({ chipProps, chipThreshold, children, footerFixed: foot
30
31
  const isHorizontal = horizontal === true
31
32
 
32
33
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style })
34
+ const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
35
+ // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
36
+ // corrects it before any scroll event could observe the placeholder.
37
+ const capturedGeneration = useSharedValue(-1)
38
+ // Layout effects (not passive effects): React guarantees all layout-effect cleanups for fibers
39
+ // removed in a commit run before any layout-effect setups for fibers added in that same commit,
40
+ // but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
41
+ // instance's unmount and the incoming instance's mount land in the same commit — with passive
42
+ // effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
43
+ // capturing a stale generation and silently disabling this instance's onScroll handling forever.
44
+ // Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
45
+ // on the UI thread, and a plain JS-thread write to a SharedValue is not guaranteed to be visible
46
+ // there before a scroll event the UI thread schedules shortly after (e.g. useScrollInit's mount-time
47
+ // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
48
+ // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
49
+ // only be queued after this component finishes mounting.
50
+ useLayoutEffect(() => {
51
+ const generation = listGeneration.value
52
+ runOnUI((gen: number) => {
53
+ 'worklet'
54
+ capturedGeneration.value = gen
55
+ })(generation)
56
+ }, [capturedGeneration, listGeneration])
57
+ useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
33
58
 
34
59
  const scrollTo = useCallback((offset: number, animated: boolean) => {
35
60
  scrollView.current?.scrollTo({ y: offset, animated })
@@ -43,12 +68,7 @@ const ScrollViewInner = ({ chipProps, chipThreshold, children, footerFixed: foot
43
68
  scrollTo
44
69
  })
45
70
 
46
- const onPullSearchZoneEnter = useCallback(() => {
47
- if (!pullSearchHeight) return
48
- scrollTo(-contentInset.top + pullSearchHeight, true)
49
- }, [contentInset.top, pullSearchHeight, scrollTo])
50
-
51
- const handleScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
71
+ const handleScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, isHorizontal, listGeneration })
52
72
 
53
73
  const handleScrollToTop = useCallback(() => {
54
74
  if (isHorizontal) {
@@ -1,4 +1,4 @@
1
- import { createContext } from 'react'
1
+ import { createContext, type RefObject } from 'react'
2
2
  import { SharedValue } from 'react-native-reanimated'
3
3
 
4
4
  export type ScrollViewContextType = {
@@ -7,10 +7,19 @@ export type ScrollViewContextType = {
7
7
  footerHeightShared: SharedValue<number>
8
8
  footerFixed: boolean
9
9
  footerOffset: SharedValue<number>
10
- headerHeight: number
10
+ headerHeight: number | null
11
11
  headerHeightShared: SharedValue<number>
12
12
  headerFixed: boolean
13
13
  headerOffset: SharedValue<number>
14
+ // Plain (non-Reanimated) counter for JS-thread-only scroll handlers (FlatList's useScrollHandlerJS).
15
+ // A SharedValue is deliberately NOT used here: mutating `.value` from inside a React commit-phase
16
+ // callback (a layout effect) is not synchronously visible even to an immediate subsequent read in
17
+ // the same callback — Reanimated defers the actual flush — which silently broke the "did another
18
+ // list instance just unmount" check this counter exists for. A plain ref has no such batching.
19
+ jsListGeneration: RefObject<number>
20
+ listGeneration: SharedValue<number>
21
+ onJsListUnmount: () => void
22
+ onListUnmount: () => void
14
23
  progress: number | null
15
24
  pullSearchHeightShared: SharedValue<number>
16
25
  snapBackFooterShared: SharedValue<boolean>
@@ -19,7 +28,7 @@ export type ScrollViewContextType = {
19
28
  scrollHeight: number
20
29
  scrollPosition: SharedValue<number>
21
30
  setFooterHeight: (h: number) => void
22
- setHeaderHeight: (h: number) => void
31
+ setHeaderHeight: (h: number | null) => void
23
32
  setProgress: (p: number | null) => void
24
33
  setProgressing: (b: boolean) => void
25
34
  tabBarHeight: number
@@ -31,10 +40,14 @@ export const ScrollViewContext = createContext<ScrollViewContextType>({
31
40
  footerHeightShared: null as unknown as SharedValue<number>,
32
41
  footerFixed: false,
33
42
  footerOffset: null as unknown as SharedValue<number>,
34
- headerHeight: 0,
43
+ headerHeight: null,
35
44
  headerHeightShared: null as unknown as SharedValue<number>,
36
45
  headerFixed: false,
37
46
  headerOffset: null as unknown as SharedValue<number>,
47
+ jsListGeneration: { current: 0 },
48
+ listGeneration: null as unknown as SharedValue<number>,
49
+ onJsListUnmount: () => {},
50
+ onListUnmount: () => {},
38
51
  progress: null,
39
52
  progressing: false,
40
53
  pullSearchHeightShared: null as unknown as SharedValue<number>,
@@ -56,20 +56,22 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
56
56
  const translateStyle = useAnimatedStyle(() => {
57
57
  if (headerFixed) return { transform: [{ translateY: 0 }] }
58
58
  if (snapBackHeaderShared.value) return { transform: [{ translateY: headerOffset.value }] }
59
- const effective = scrollPosition.value + headerHeight - pullSearchHeightShared.value
59
+ const effective = scrollPosition.value + (headerHeight ?? 0) - pullSearchHeightShared.value
60
60
  if (effective <= 0) return { transform: [{ translateY: 0 }] }
61
61
  return { transform: [{ translateY: -effective }] }
62
62
  }, [headerFixed, headerHeight])
63
63
  const blurStyle = useAnimatedStyle(() => {
64
64
  if (!headerHeight) return { height: 0 }
65
65
  if (headerFixed) return { height: headerHeight }
66
- const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
67
- return { height: Math.max(headerHeight + translateY, top) }
66
+ const h = headerHeight ?? 0
67
+ const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
68
+ return { height: Math.max(h + translateY, top) }
68
69
  }, [headerFixed, headerHeight, top])
69
70
  const progressStyle = useAnimatedStyle(() => {
70
- if (headerFixed) return { top: headerHeight }
71
- const slide = snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
72
- return { top: Math.max(headerHeight - slide, top) }
71
+ const h = headerHeight ?? 0
72
+ if (headerFixed) return { top: h }
73
+ const slide = snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
74
+ return { top: Math.max(h - slide, top) }
73
75
  }, [headerFixed, headerHeight, top])
74
76
  const actionMargin = 4
75
77
  const contentMinHeight = actionSize + 2 * actionMargin
@@ -81,33 +83,34 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
81
83
  return (
82
84
  <>
83
85
  <Animated.View pointerEvents='none' style={[styles.blur, blurStyle]}>
84
- {headerHeight > 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight }]} />}
86
+ {headerHeight !== null && headerHeight > 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight }]} />}
85
87
  </Animated.View>
86
- <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === 0 ? styles.headerInit : styles.header, translateStyle]}>
88
+ <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === null ? styles.headerInit : styles.header, translateStyle]}>
87
89
  <View style={[{ paddingTop: top }, style]}>
88
- <View style={[styles.content, { minHeight: contentMinHeight }]}>
89
- <View style={styles.side} />
90
- <View style={styles.spacer} />
91
- <View style={styles.side}>{children}</View>
92
- {(centerContent || title || caption) && (
93
- <View style={[StyleSheet.absoluteFill, styles.titleContainer]} pointerEvents={centerContent ? 'box-none' : 'none'}>
94
- {centerContent ?? (
95
- <>
96
- {title && (
97
- <Text numberOfLines={1} style={[styles.title, { color: theme.colors.onSurface }]}>
98
- {title}
99
- </Text>
100
- )}
101
- {caption && (
102
- <Text numberOfLines={1} style={[styles.caption, { color: theme.colors.onSurfaceVariant }]}>
103
- {caption}
104
- </Text>
105
- )}
106
- </>
107
- )}
108
- </View>
109
- )}
110
- </View>
90
+ {children ?? (
91
+ <View style={[styles.content, { minHeight: contentMinHeight }]}>
92
+ <View style={styles.side} />
93
+ <View style={styles.spacer} />
94
+ {(centerContent || title || caption) && (
95
+ <View style={[StyleSheet.absoluteFill, styles.titleContainer]} pointerEvents={centerContent ? 'box-none' : 'none'}>
96
+ {centerContent ?? (
97
+ <>
98
+ {title && (
99
+ <Text numberOfLines={1} style={[styles.title, { color: theme.colors.onSurface }]}>
100
+ {title}
101
+ </Text>
102
+ )}
103
+ {caption && (
104
+ <Text numberOfLines={1} style={[styles.caption, { color: theme.colors.onSurfaceVariant }]}>
105
+ {caption}
106
+ </Text>
107
+ )}
108
+ </>
109
+ )}
110
+ </View>
111
+ )}
112
+ </View>
113
+ )}
111
114
  </View>
112
115
  {!blur && <View style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />}
113
116
  </Animated.View>
@@ -1,5 +1,5 @@
1
1
  import { useBlur } from '@rific/auto-paper'
2
- import { type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
2
+ import { type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
3
3
  import { Dimensions } from 'react-native'
4
4
  import { useSharedValue } from 'react-native-reanimated'
5
5
 
@@ -24,11 +24,13 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
24
24
  const effectiveSnapBack = snapBack ?? settings.snapBack
25
25
  const effectiveSnapBackHeader = snapBackHeader ?? settings.snapBackHeader
26
26
  const effectiveSnapBackFooter = snapBackFooter ?? settings.snapBackFooter
27
- const [headerHeight, setHeaderHeightState] = useState(0)
27
+ const [headerHeight, setHeaderHeightState] = useState<number | null>(null)
28
28
  const [footerHeight, setFooterHeightState] = useState(0)
29
29
  const [progress, setProgress] = useState<number | null>(null)
30
30
  const [progressing, setProgressing] = useState(false)
31
31
  const scrollPosition = useSharedValue(0)
32
+ const listGeneration = useSharedValue(0)
33
+ const jsListGeneration = useRef(0)
32
34
  const headerHeightShared = useSharedValue(0)
33
35
  const footerHeightShared = useSharedValue(0)
34
36
  const headerOffset = useSharedValue(0)
@@ -41,12 +43,26 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
41
43
  snapBackFooterShared.value = effectiveSnapBackFooter ?? effectiveSnapBack
42
44
  }, [effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared])
43
45
  const setHeaderHeight = useCallback(
44
- (h: number) => {
46
+ (h: number | null) => {
45
47
  setHeaderHeightState(h)
46
- headerHeightShared.value = h
48
+ headerHeightShared.value = h ?? 0
47
49
  },
48
50
  [headerHeightShared]
49
51
  )
52
+ const onListUnmount = useCallback(() => {
53
+ listGeneration.value += 1
54
+ }, [listGeneration])
55
+ const onJsListUnmount = useCallback(() => {
56
+ jsListGeneration.current += 1
57
+ }, [])
58
+ useEffect(() => {
59
+ if (!__DEV__ || headerHeight !== null) return
60
+ const timeout = setTimeout(() => {
61
+ // eslint-disable-next-line no-console
62
+ console.warn('[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider — scroll content stays hidden (opacity: 0) until one does. Render a <ScrollViewHeader> (it can be empty / zero height) inside this provider.')
63
+ }, 3000)
64
+ return () => clearTimeout(timeout)
65
+ }, [headerHeight])
50
66
  const setFooterHeight = useCallback(
51
67
  (h: number) => {
52
68
  setFooterHeightState(h)
@@ -54,9 +70,9 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
54
70
  },
55
71
  [footerHeightShared]
56
72
  )
57
- const scrollHeight = useMemo(() => Dimensions.get('window').height - headerHeight - footerHeight, [headerHeight, footerHeight])
73
+ const scrollHeight = useMemo(() => Dimensions.get('window').height - (headerHeight ?? 0) - footerHeight, [headerHeight, footerHeight])
58
74
  const effectiveHeaderFixed = fixed || (headerFixed ?? settings.headerFixed)
59
75
  const effectiveFooterFixed = fixed || (footerFixed ?? settings.footerFixed)
60
- const value = useMemo(() => ({ blur: effectiveBlur, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight])
76
+ const value = useMemo(() => ({ blur: effectiveBlur, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight])
61
77
  return <ScrollViewContext.Provider value={value}>{children}</ScrollViewContext.Provider>
62
78
  }