@rific/scroll-view 0.1.1 → 0.3.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.
- package/README.md +46 -7
- package/dist/index.d.mts +31 -10
- package/dist/index.d.ts +31 -10
- package/dist/index.js +663 -335
- package/dist/index.mjs +600 -272
- package/package.json +3 -3
- package/src/CustomList.tsx +38 -11
- package/src/FlatList.tsx +107 -22
- package/src/PullSearch.tsx +4 -3
- package/src/ScrollView.tsx +34 -11
- package/src/ScrollViewContext.ts +17 -4
- package/src/ScrollViewHeader.tsx +34 -31
- package/src/ScrollViewProvider.tsx +22 -6
- package/src/ScrollViewSettingsProvider.tsx +3 -3
- package/src/SectionList.tsx +37 -13
- package/src/__mocks__/auto-paper.ts +1 -0
- package/src/__mocks__/react-native-reanimated.ts +32 -1
- package/src/__tests__/FlatList.test.tsx +83 -0
- package/src/__tests__/ScrollViewProvider.test.tsx +2 -2
- package/src/__tests__/useScrollHandlerJS.test.ts +119 -0
- package/src/__tests__/useScrollInit.test.ts +251 -0
- package/src/index.ts +1 -0
- package/src/internal/ScrollViewChip.tsx +24 -8
- package/src/internal/useScrollHandler.ts +22 -23
- package/src/internal/useScrollHandlerJS.ts +141 -0
- package/src/internal/useScrollList.ts +16 -11
- package/src/useScrollInit.ts +200 -19
- package/src/out.txt +0 -61
|
@@ -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(
|
|
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
|
}
|
|
@@ -5,13 +5,13 @@ import { defaultScrollViewSettings, type ScrollViewSettings, ScrollViewSettingsC
|
|
|
5
5
|
export type ScrollViewSettingsProviderProps = {
|
|
6
6
|
children: ReactNode
|
|
7
7
|
onChange?: (settings: ScrollViewSettings) => void
|
|
8
|
-
|
|
8
|
+
initialValue?: Partial<ScrollViewSettings>
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export const ScrollViewSettingsProvider = ({ children, onChange,
|
|
11
|
+
export const ScrollViewSettingsProvider = ({ children, onChange, initialValue }: ScrollViewSettingsProviderProps) => {
|
|
12
12
|
const [settings, setSettings] = useState<ScrollViewSettings>(() => ({
|
|
13
13
|
...defaultScrollViewSettings,
|
|
14
|
-
...
|
|
14
|
+
...initialValue
|
|
15
15
|
}))
|
|
16
16
|
|
|
17
17
|
const set = useCallback(
|
package/src/SectionList.tsx
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
import { memo, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
|
1
|
+
import { memo, type ReactNode, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
|
2
2
|
import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, View } from 'react-native'
|
|
3
3
|
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
|
|
4
|
-
import Animated, {
|
|
4
|
+
import Animated, { runOnUI, useSharedValue } from 'react-native-reanimated'
|
|
5
5
|
|
|
6
6
|
import { RefreshControl } from './internal/RefreshControl'
|
|
7
|
-
import { ScrollViewChip } from './internal/ScrollViewChip'
|
|
7
|
+
import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
|
|
8
8
|
import { useScrollHandler } from './internal/useScrollHandler'
|
|
9
9
|
import { useScrollList } from './internal/useScrollList'
|
|
10
10
|
import { useStickyHeaders } from './internal/useStickyHeaders'
|
|
11
|
+
import { ScrollViewContext } from './ScrollViewContext'
|
|
11
12
|
import { useScrollInit } from './useScrollInit'
|
|
12
13
|
|
|
13
|
-
const AnimatedSectionList = createAnimatedComponent(RNSectionList) as unknown as typeof RNSectionList
|
|
14
|
+
const AnimatedSectionList = Animated.createAnimatedComponent(RNSectionList) as unknown as typeof RNSectionList
|
|
14
15
|
|
|
15
16
|
export type SectionListProps<ItemT, SectionT extends { data: ReadonlyArray<ItemT> } = { data: ReadonlyArray<ItemT> }> = Omit<RNSectionListProps<ItemT, SectionT>, 'horizontal'> & {
|
|
17
|
+
chipProps?: ChipProps
|
|
18
|
+
chipThreshold?: number
|
|
16
19
|
renderFilters?: ReactNode
|
|
17
20
|
footerFixed?: boolean
|
|
18
21
|
gesture?: GestureType
|
|
19
22
|
headerFixed?: boolean
|
|
20
23
|
keyboardAware?: boolean
|
|
24
|
+
onChipPress?: () => void
|
|
21
25
|
onMomentumScrollEnd?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
22
26
|
onScrollBeginDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
23
27
|
onScrollEndDrag?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
|
|
@@ -25,12 +29,35 @@ export type SectionListProps<ItemT, SectionT extends { data: ReadonlyArray<ItemT
|
|
|
25
29
|
ref?: RefObject<RNSectionList<ItemT, SectionT> | null>
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> } = { data: ReadonlyArray<ItemT> }>({ contentContainerStyle: externalContentContainerStyle, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onContentSizeChange, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, refreshing, removeClippedSubviews = false, sections, showsVerticalScrollIndicator, style, ref: externalRef, windowSize = 100, ...props }: SectionListProps<ItemT, SectionT>) => {
|
|
32
|
+
const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> } = { data: ReadonlyArray<ItemT> }>({ chipProps, chipThreshold, contentContainerStyle: externalContentContainerStyle, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onContentSizeChange, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, refreshing, removeClippedSubviews = false, sections, showsVerticalScrollIndicator, style, ref: externalRef, windowSize = 100, ...props }: SectionListProps<ItemT, SectionT>) => {
|
|
29
33
|
const scrollView = useRef<RNSectionList<ItemT, SectionT>>(null)
|
|
30
34
|
|
|
31
35
|
const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props as RNSectionListProps<ItemT, SectionT>
|
|
32
36
|
|
|
33
37
|
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
|
|
38
|
+
const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
|
|
39
|
+
// -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
|
|
40
|
+
// corrects it before any scroll event could observe the placeholder.
|
|
41
|
+
const capturedGeneration = useSharedValue(-1)
|
|
42
|
+
// Layout effect (not passive effect): React guarantees all layout-effect cleanups for fibers
|
|
43
|
+
// removed in a commit run before any layout-effect setups for fibers added in that same commit,
|
|
44
|
+
// but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
|
|
45
|
+
// instance's unmount and the incoming instance's mount land in the same commit — with passive
|
|
46
|
+
// effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
|
|
47
|
+
// capturing a stale generation and silently disabling this instance's onScroll handling forever.
|
|
48
|
+
// Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
|
|
49
|
+
// on the UI thread, and a plain JS-thread write to a SharedValue is not guaranteed to be visible
|
|
50
|
+
// there before a scroll event the UI thread schedules shortly after (e.g. useScrollInit's mount-time
|
|
51
|
+
// imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
|
|
52
|
+
// own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
|
|
53
|
+
// only be queued after this component finishes mounting.
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const generation = listGeneration.value
|
|
56
|
+
runOnUI((gen: number) => {
|
|
57
|
+
'worklet'
|
|
58
|
+
capturedGeneration.value = gen
|
|
59
|
+
})(generation)
|
|
60
|
+
}, [capturedGeneration, listGeneration])
|
|
34
61
|
|
|
35
62
|
const usesCustomSticky = stickySectionHeadersEnabled && renderSectionHeader != null
|
|
36
63
|
const { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions } = useStickyHeaders(headerFixed, usesCustomSticky)
|
|
@@ -67,6 +94,8 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
67
94
|
}
|
|
68
95
|
}, [usesCustomSticky, renderSectionHeader, sections, measureHeader])
|
|
69
96
|
|
|
97
|
+
useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
|
|
98
|
+
|
|
70
99
|
const scrollTo = useCallback((offset: number, animated: boolean) => {
|
|
71
100
|
scrollView.current?.getScrollResponder()?.scrollTo({ y: offset, animated })
|
|
72
101
|
}, [])
|
|
@@ -117,12 +146,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
117
146
|
|
|
118
147
|
const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
|
|
119
148
|
|
|
120
|
-
const
|
|
121
|
-
if (!pullSearchHeight) return
|
|
122
|
-
scrollTo(-contentInset.top + pullSearchHeight, true)
|
|
123
|
-
}, [contentInset.top, pullSearchHeight, scrollTo])
|
|
124
|
-
|
|
125
|
-
const handleScroll = useScrollHandler({ chipHidden, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
|
|
149
|
+
const handleScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, listGeneration })
|
|
126
150
|
|
|
127
151
|
const handleScrollToTop = useCallback(() => {
|
|
128
152
|
const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
|
|
@@ -144,7 +168,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
144
168
|
const content = (
|
|
145
169
|
<View style={containerStyle}>
|
|
146
170
|
<AnimatedSectionList
|
|
147
|
-
key={headerHeight > 0 ? 1 : 0}
|
|
171
|
+
key={headerHeight !== null && headerHeight > 0 ? 1 : 0}
|
|
148
172
|
{...passThroughProps}
|
|
149
173
|
contentContainerStyle={contentContainerStyle}
|
|
150
174
|
contentInset={contentInset}
|
|
@@ -175,7 +199,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
175
199
|
{hiddenHeader}
|
|
176
200
|
</View>
|
|
177
201
|
)}
|
|
178
|
-
<ScrollViewChip onPress={handleScrollToTop} style={chipStyle} />
|
|
202
|
+
<ScrollViewChip chipProps={chipProps} onChipPress={onChipPress} onPress={handleScrollToTop} style={chipStyle} />
|
|
179
203
|
{usesCustomSticky && (
|
|
180
204
|
<Animated.View pointerEvents='none' style={[styles.stickyClip, clipStyle]}>
|
|
181
205
|
{activeIndex >= 0 && activeIndex < sections.length && <Animated.View style={[styles.stickyOverlay, overlayStyle]}>{renderSectionHeader({ section: sections[activeIndex] })}</Animated.View>}
|
|
@@ -3,6 +3,7 @@ import React from 'react'
|
|
|
3
3
|
const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
|
|
4
4
|
|
|
5
5
|
export const BlurView = jest.fn(stub)
|
|
6
|
+
export const Chip = jest.fn(stub)
|
|
6
7
|
export const FAB = jest.fn(stub)
|
|
7
8
|
export const usePaperDefaults = () => ({ FAB: {} })
|
|
8
9
|
export const useBlur = (blur?: boolean) => blur ?? false
|
|
@@ -2,7 +2,34 @@ import React from 'react'
|
|
|
2
2
|
|
|
3
3
|
const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Real Reanimated shared values persist their `.value` across re-renders (like useRef) — the
|
|
6
|
+
// `init` argument is only honored on the first call. A naive `{ value: init }` per render would
|
|
7
|
+
// silently discard mutations made during a previous render every time the owning component re-renders.
|
|
8
|
+
export const useSharedValue = <T,>(init: T) => {
|
|
9
|
+
const ref = React.useRef<{ value: T } | null>(null)
|
|
10
|
+
if (!ref.current) ref.current = { value: init }
|
|
11
|
+
return ref.current
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Reanimated's useAnimatedRef returns a stable callable that also exposes `.current`, so
|
|
15
|
+
// consumers can either pass it directly as a `ref` prop or read/assign `.current` imperatively.
|
|
16
|
+
export const useAnimatedRef = <T,>() => {
|
|
17
|
+
const holder = React.useRef<((node: T | null) => void) & { current: T | null }>(undefined as unknown as ((node: T | null) => void) & { current: T | null })
|
|
18
|
+
if (!holder.current) {
|
|
19
|
+
let current: T | null = null
|
|
20
|
+
const fn = ((node: T | null) => {
|
|
21
|
+
current = node
|
|
22
|
+
}) as ((node: T | null) => void) & { current: T | null }
|
|
23
|
+
Object.defineProperty(fn, 'current', {
|
|
24
|
+
get: () => current,
|
|
25
|
+
set: (node: T | null) => {
|
|
26
|
+
current = node
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
holder.current = fn
|
|
30
|
+
}
|
|
31
|
+
return holder.current
|
|
32
|
+
}
|
|
6
33
|
export const useAnimatedStyle = (_factory: () => object) => ({})
|
|
7
34
|
export const useAnimatedProps = (_factory: () => object) => ({})
|
|
8
35
|
export const useAnimatedScrollHandler = (_handler: unknown) => jest.fn()
|
|
@@ -14,6 +41,10 @@ export const runOnJS =
|
|
|
14
41
|
<T extends (...args: unknown[]) => unknown>(fn: T) =>
|
|
15
42
|
(...args: Parameters<T>) =>
|
|
16
43
|
fn(...args)
|
|
44
|
+
export const runOnUI =
|
|
45
|
+
<T extends (...args: unknown[]) => unknown>(fn: T) =>
|
|
46
|
+
(...args: Parameters<T>) =>
|
|
47
|
+
fn(...args)
|
|
17
48
|
|
|
18
49
|
export const createAnimatedComponent = (C: React.ComponentType) => C
|
|
19
50
|
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { render } from '@testing-library/react'
|
|
2
2
|
import React from 'react'
|
|
3
|
+
import { FlatList as RNFlatList } from 'react-native'
|
|
3
4
|
|
|
4
5
|
import { FlatList } from '../FlatList'
|
|
5
6
|
import { ScrollViewProvider } from '../ScrollViewProvider'
|
|
6
7
|
|
|
7
8
|
const wrapper = ({ children }: { children: React.ReactNode }) => <ScrollViewProvider>{children}</ScrollViewProvider>
|
|
8
9
|
|
|
10
|
+
type MockFlatListProps = {
|
|
11
|
+
data?: unknown
|
|
12
|
+
keyExtractor?: (item: never, index: number) => string
|
|
13
|
+
numColumns?: number
|
|
14
|
+
renderItem?: (info: unknown) => React.ReactNode
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const lastCallProps = () => {
|
|
19
|
+
const calls = (RNFlatList as unknown as jest.Mock).mock.calls
|
|
20
|
+
return calls[calls.length - 1]?.[0] as MockFlatListProps | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
describe('FlatList', () => {
|
|
10
24
|
it('renders without crashing', () => {
|
|
11
25
|
render(<FlatList data={[]} renderItem={() => null} />, { wrapper })
|
|
@@ -50,3 +64,72 @@ describe('FlatList', () => {
|
|
|
50
64
|
render(<FlatList data={[]} renderItem={() => null} keyboardAware />, { wrapper })
|
|
51
65
|
})
|
|
52
66
|
})
|
|
67
|
+
|
|
68
|
+
describe('FlatList numColumns row-chunking', () => {
|
|
69
|
+
beforeEach(() => (RNFlatList as unknown as jest.Mock).mockClear())
|
|
70
|
+
|
|
71
|
+
it('passes data through unchanged when numColumns is omitted', () => {
|
|
72
|
+
const data = ['a', 'b', 'c']
|
|
73
|
+
render(<FlatList data={data} renderItem={() => null} keyExtractor={(item) => item} />, { wrapper })
|
|
74
|
+
expect(lastCallProps()?.data).toBe(data)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('passes data through unchanged when numColumns is 1', () => {
|
|
78
|
+
const data = ['a', 'b', 'c']
|
|
79
|
+
render(<FlatList data={data} renderItem={() => null} keyExtractor={(item) => item} numColumns={1} />, { wrapper })
|
|
80
|
+
expect(lastCallProps()?.data).toBe(data)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('never forwards the numColumns prop itself to the underlying RNFlatList', () => {
|
|
84
|
+
render(<FlatList data={['a', 'b', 'c']} renderItem={() => null} keyExtractor={(item) => item} numColumns={3} />, { wrapper })
|
|
85
|
+
expect(lastCallProps()?.numColumns).toBeUndefined()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('chunks data into rows of numColumns, with a partial last row', () => {
|
|
89
|
+
render(<FlatList data={['a', 'b', 'c', 'd', 'e']} renderItem={() => null} keyExtractor={(item) => item} numColumns={2} />, { wrapper })
|
|
90
|
+
expect(lastCallProps()?.data).toEqual([['a', 'b'], ['c', 'd'], ['e']])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('row keyExtractor joins per-item keys with a colon', () => {
|
|
94
|
+
render(<FlatList data={['a', 'b', 'c', 'd']} renderItem={() => null} keyExtractor={(item) => item} numColumns={2} />, { wrapper })
|
|
95
|
+
const rowKeyExtractor = lastCallProps()?.keyExtractor
|
|
96
|
+
expect(rowKeyExtractor?.(['a', 'b'] as never, 0)).toBe('a:b')
|
|
97
|
+
expect(rowKeyExtractor?.(['c', 'd'] as never, 1)).toBe('c:d')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('row renderItem renders every item in the row via the original renderItem', () => {
|
|
101
|
+
render(
|
|
102
|
+
<FlatList
|
|
103
|
+
data={['fig', 'kiwi', 'plum']}
|
|
104
|
+
renderItem={({ item }) => <>{item}</>}
|
|
105
|
+
keyExtractor={(item) => item}
|
|
106
|
+
numColumns={2}
|
|
107
|
+
/>,
|
|
108
|
+
{ wrapper }
|
|
109
|
+
)
|
|
110
|
+
const rowRenderItem = lastCallProps()?.renderItem
|
|
111
|
+
const { container } = render(rowRenderItem?.({ index: 0, item: ['fig', 'kiwi'], separators: {} as never }) as React.ReactElement)
|
|
112
|
+
expect(container.textContent).toContain('fig')
|
|
113
|
+
expect(container.textContent).toContain('kiwi')
|
|
114
|
+
expect(container.textContent).not.toContain('plum')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renderItem indexes items by their position across the whole flattened list, not just within the row', () => {
|
|
118
|
+
const indices: number[] = []
|
|
119
|
+
render(
|
|
120
|
+
<FlatList
|
|
121
|
+
data={['a', 'b', 'c', 'd', 'e']}
|
|
122
|
+
renderItem={({ index }) => {
|
|
123
|
+
indices.push(index)
|
|
124
|
+
return null
|
|
125
|
+
}}
|
|
126
|
+
keyExtractor={(item) => item}
|
|
127
|
+
numColumns={2}
|
|
128
|
+
/>,
|
|
129
|
+
{ wrapper }
|
|
130
|
+
)
|
|
131
|
+
const rowRenderItem = lastCallProps()?.renderItem
|
|
132
|
+
render(rowRenderItem?.({ index: 2, item: ['e'], separators: {} as never }) as React.ReactElement)
|
|
133
|
+
expect(indices).toEqual([4]) // row index 2 * numColumns 2 + column 0
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render } from '@testing-library/react'
|
|
1
|
+
import { act, render } from '@testing-library/react'
|
|
2
2
|
import React, { useContext } from 'react'
|
|
3
3
|
|
|
4
4
|
import { ScrollViewContext } from '../ScrollViewContext'
|
|
@@ -54,7 +54,7 @@ describe('ScrollViewProvider', () => {
|
|
|
54
54
|
<ContextReader onRead={(v) => { capturedValue = v }} />
|
|
55
55
|
</ScrollViewProvider>
|
|
56
56
|
)
|
|
57
|
-
capturedValue?.setProgress(0.5)
|
|
57
|
+
act(() => { capturedValue?.setProgress(0.5) })
|
|
58
58
|
rerender(
|
|
59
59
|
<ScrollViewProvider>
|
|
60
60
|
<ContextReader onRead={(v) => { capturedValue = v }} />
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import type { SharedValue } from 'react-native-reanimated'
|
|
4
|
+
|
|
5
|
+
import { useScrollHandlerJS } from '../internal/useScrollHandlerJS'
|
|
6
|
+
import { ScrollViewContext, type ScrollViewContextType } from '../ScrollViewContext'
|
|
7
|
+
|
|
8
|
+
const REMOUNT_RETRY_MAX_ATTEMPTS = 8
|
|
9
|
+
|
|
10
|
+
const buildContextValue = (): ScrollViewContextType =>
|
|
11
|
+
({
|
|
12
|
+
footerHeightShared: { value: 0 },
|
|
13
|
+
footerOffset: { value: 0 },
|
|
14
|
+
headerHeightShared: { value: 0 },
|
|
15
|
+
headerOffset: { value: 0 },
|
|
16
|
+
pullSearchHeightShared: { value: 0 },
|
|
17
|
+
scrollPosition: { value: 0 },
|
|
18
|
+
snapBackFooterShared: { value: false },
|
|
19
|
+
snapBackHeaderShared: { value: false }
|
|
20
|
+
}) as unknown as ScrollViewContextType
|
|
21
|
+
|
|
22
|
+
const scrollEvent = (y: number) =>
|
|
23
|
+
({
|
|
24
|
+
nativeEvent: {
|
|
25
|
+
contentOffset: { x: 0, y },
|
|
26
|
+
contentSize: { height: 1000, width: 0 },
|
|
27
|
+
layoutMeasurement: { height: 500, width: 0 }
|
|
28
|
+
}
|
|
29
|
+
}) as never
|
|
30
|
+
|
|
31
|
+
const renderScrollHandlerJS = (overrides: Partial<Parameters<typeof useScrollHandlerJS>[0]> = {}) => {
|
|
32
|
+
const contextValue = buildContextValue()
|
|
33
|
+
const chipHidden = { value: 0 } as unknown as SharedValue<number>
|
|
34
|
+
const capturedGeneration = { current: 0 }
|
|
35
|
+
const jsListGeneration = { current: 0 }
|
|
36
|
+
const scrollTo = jest.fn()
|
|
37
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(ScrollViewContext.Provider, { value: contextValue }, children)
|
|
38
|
+
const rendered = renderHook(
|
|
39
|
+
() =>
|
|
40
|
+
useScrollHandlerJS({
|
|
41
|
+
capturedGeneration,
|
|
42
|
+
chipHidden,
|
|
43
|
+
footerFixed: false,
|
|
44
|
+
headerFixed: false,
|
|
45
|
+
jsListGeneration,
|
|
46
|
+
scrollTo,
|
|
47
|
+
...overrides
|
|
48
|
+
}),
|
|
49
|
+
{ wrapper }
|
|
50
|
+
)
|
|
51
|
+
return { ...rendered, capturedGeneration, chipHidden, contextValue, jsListGeneration, scrollTo }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('useScrollHandlerJS generation guard', () => {
|
|
55
|
+
it('processes onScroll and updates scrollPosition/chipHidden when the generation matches', () => {
|
|
56
|
+
const { result, contextValue, chipHidden } = renderScrollHandlerJS({ chipThreshold: 100 })
|
|
57
|
+
result.current.onScroll(scrollEvent(50))
|
|
58
|
+
expect(contextValue.scrollPosition.value).toBe(50)
|
|
59
|
+
expect(chipHidden.value).toBe(1) // below threshold
|
|
60
|
+
|
|
61
|
+
result.current.onScroll(scrollEvent(150))
|
|
62
|
+
expect(contextValue.scrollPosition.value).toBe(150)
|
|
63
|
+
expect(chipHidden.value).toBe(0) // at/above threshold
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('drops onScroll from a zombie handler once jsListGeneration no longer matches its capturedGeneration', () => {
|
|
67
|
+
const { result, contextValue, capturedGeneration, jsListGeneration } = renderScrollHandlerJS()
|
|
68
|
+
result.current.onScroll(scrollEvent(10))
|
|
69
|
+
expect(contextValue.scrollPosition.value).toBe(10)
|
|
70
|
+
|
|
71
|
+
// Another list instance mounted/unmounted elsewhere and bumped the shared generation counter —
|
|
72
|
+
// this handler's own capturedGeneration (frozen at mount) is now stale.
|
|
73
|
+
jsListGeneration.current = 1
|
|
74
|
+
expect(capturedGeneration.current).toBe(0)
|
|
75
|
+
|
|
76
|
+
result.current.onScroll(scrollEvent(999))
|
|
77
|
+
expect(contextValue.scrollPosition.value).toBe(10) // unchanged — the stale event was ignored
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('lets a fresh instance keep working even while a stale sibling is guarded out', () => {
|
|
81
|
+
const jsListGeneration = { current: 1 }
|
|
82
|
+
const { result, contextValue } = renderScrollHandlerJS({ capturedGeneration: { current: 1 }, jsListGeneration })
|
|
83
|
+
result.current.onScroll(scrollEvent(42))
|
|
84
|
+
expect(contextValue.scrollPosition.value).toBe(42)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('useScrollHandlerJS remount retry', () => {
|
|
89
|
+
it('retries with a non-animated scrollTo until an onScroll event confirms the target position', () => {
|
|
90
|
+
const { result, scrollTo, contextValue } = renderScrollHandlerJS({ remountTarget: -40 })
|
|
91
|
+
result.current.onScroll(scrollEvent(0)) // native reports the wrong position
|
|
92
|
+
expect(scrollTo).toHaveBeenCalledWith(-40, false)
|
|
93
|
+
expect(contextValue.scrollPosition.value).toBe(0) // not yet treated as a real scroll position
|
|
94
|
+
|
|
95
|
+
scrollTo.mockClear()
|
|
96
|
+
result.current.onScroll(scrollEvent(-40)) // confirmed
|
|
97
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
98
|
+
expect(contextValue.scrollPosition.value).toBe(-40) // now processed normally
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('gives up after REMOUNT_RETRY_MAX_ATTEMPTS and falls through to normal processing rather than blocking forever', () => {
|
|
102
|
+
const { result, scrollTo, contextValue } = renderScrollHandlerJS({ remountTarget: -40 })
|
|
103
|
+
for (let i = 0; i < REMOUNT_RETRY_MAX_ATTEMPTS; i += 1) result.current.onScroll(scrollEvent(0))
|
|
104
|
+
expect(scrollTo).toHaveBeenCalledTimes(REMOUNT_RETRY_MAX_ATTEMPTS)
|
|
105
|
+
|
|
106
|
+
scrollTo.mockClear()
|
|
107
|
+
result.current.onScroll(scrollEvent(0)) // one more, past the max — gives up
|
|
108
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
109
|
+
expect(contextValue.scrollPosition.value).toBe(0) // accepted as-is instead of retrying forever
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('the generation guard takes precedence over an in-progress remount retry', () => {
|
|
113
|
+
const { result, scrollTo, contextValue, jsListGeneration } = renderScrollHandlerJS({ remountTarget: -40 })
|
|
114
|
+
jsListGeneration.current = 1 // stale before the remount retry ever gets a chance to run
|
|
115
|
+
result.current.onScroll(scrollEvent(0))
|
|
116
|
+
expect(scrollTo).not.toHaveBeenCalled()
|
|
117
|
+
expect(contextValue.scrollPosition.value).toBe(0) // untouched — onScroll bailed out immediately
|
|
118
|
+
})
|
|
119
|
+
})
|