@rific/scroll-view 0.2.0 → 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.
@@ -1,16 +1,17 @@
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, { createAnimatedComponent } 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
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'> & {
16
17
  chipProps?: ChipProps
@@ -34,6 +35,29 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
34
35
  const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props as RNSectionListProps<ItemT, SectionT>
35
36
 
36
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])
37
61
 
38
62
  const usesCustomSticky = stickySectionHeadersEnabled && renderSectionHeader != null
39
63
  const { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions } = useStickyHeaders(headerFixed, usesCustomSticky)
@@ -70,6 +94,8 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
70
94
  }
71
95
  }, [usesCustomSticky, renderSectionHeader, sections, measureHeader])
72
96
 
97
+ useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
98
+
73
99
  const scrollTo = useCallback((offset: number, animated: boolean) => {
74
100
  scrollView.current?.getScrollResponder()?.scrollTo({ y: offset, animated })
75
101
  }, [])
@@ -120,12 +146,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
120
146
 
121
147
  const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
122
148
 
123
- const onPullSearchZoneEnter = useCallback(() => {
124
- if (!pullSearchHeight) return
125
- scrollTo(-contentInset.top + pullSearchHeight, true)
126
- }, [contentInset.top, pullSearchHeight, scrollTo])
127
-
128
- const handleScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
149
+ const handleScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, listGeneration })
129
150
 
130
151
  const handleScrollToTop = useCallback(() => {
131
152
  const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
@@ -147,7 +168,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
147
168
  const content = (
148
169
  <View style={containerStyle}>
149
170
  <AnimatedSectionList
150
- key={headerHeight > 0 ? 1 : 0}
171
+ key={headerHeight !== null && headerHeight > 0 ? 1 : 0}
151
172
  {...passThroughProps}
152
173
  contentContainerStyle={contentContainerStyle}
153
174
  contentInset={contentInset}
@@ -2,7 +2,34 @@ import React from 'react'
2
2
 
3
3
  const stub = ({ children }: { children?: React.ReactNode }) => children ?? null
4
4
 
5
- export const useSharedValue = (init: number) => ({ value: init })
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
+ })
@@ -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
+ })
@@ -0,0 +1,251 @@
1
+ import { act, renderHook } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import { ScrollViewContext, type ScrollViewContextType } from '../ScrollViewContext'
5
+ import { useScrollInit } from '../useScrollInit'
6
+
7
+ const HEADER_HEIGHT = 80
8
+ const PULL_SEARCH_HEIGHT = 40
9
+ const REMOUNT_SYNC_MAX_ATTEMPTS = 8
10
+ const SNAP_VERIFY_MAX_ATTEMPTS = 5
11
+ const SNAP_VERIFY_INITIAL_DELAY_MS = 400
12
+ const SNAP_VERIFY_RETRY_DELAY_MS = 150
13
+
14
+ const buildContextValue = (headerHeight: number | null): ScrollViewContextType =>
15
+ ({
16
+ blur: true,
17
+ footerHeight: 0,
18
+ footerHeightShared: { value: 0 },
19
+ footerFixed: false,
20
+ footerOffset: { value: 0 },
21
+ headerHeight,
22
+ headerHeightShared: { value: headerHeight ?? 0 },
23
+ headerFixed: false,
24
+ headerOffset: { value: 0 },
25
+ listGeneration: { value: 0 },
26
+ onListUnmount: jest.fn(),
27
+ progress: null,
28
+ progressing: false,
29
+ pullSearchHeightShared: { value: 0 },
30
+ scrollHeight: 0,
31
+ scrollPosition: { value: 0 },
32
+ setFooterHeight: jest.fn(),
33
+ setHeaderHeight: jest.fn(),
34
+ setProgress: jest.fn(),
35
+ setProgressing: jest.fn(),
36
+ snapBackFooterShared: { value: false },
37
+ snapBackHeaderShared: { value: false },
38
+ tabBarHeight: 60
39
+ }) as unknown as ScrollViewContextType
40
+
41
+ const renderInit = (headerHeight: number | null, pullSearchHeight?: number) => {
42
+ const contextValue = buildContextValue(headerHeight)
43
+ const scrollTo = jest.fn()
44
+ const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(ScrollViewContext.Provider, { value: contextValue }, children)
45
+ const rendered = renderHook(() => useScrollInit({ pullSearchHeight, scrollTo }), { wrapper })
46
+ return { ...rendered, contextValue, scrollTo }
47
+ }
48
+
49
+ describe('useScrollInit remount sync', () => {
50
+ it('true first mount (headerHeight null) skips remount-sync and starts ready', () => {
51
+ const { result, unmount } = renderInit(null)
52
+ expect(result.current.scrollReady).toBe(true)
53
+ expect(result.current.remountSyncTarget.value).toBeNull()
54
+ unmount()
55
+ })
56
+
57
+ it('remount (headerHeight already known at mount) hides the list and targets showY', () => {
58
+ const { result, scrollTo, unmount } = renderInit(HEADER_HEIGHT)
59
+ expect(result.current.scrollReady).toBe(false)
60
+ expect(result.current.remountSyncTarget.value).toBe(-HEADER_HEIGHT)
61
+ // Primes a corrective scrollTo immediately, rather than assuming native honored the initial contentOffset.
62
+ expect(scrollTo).toHaveBeenCalledWith(-HEADER_HEIGHT, false)
63
+ unmount()
64
+ })
65
+
66
+ it('remount with pullSearchHeight targets hideY directly (skips the 2-phase first-mount dance)', () => {
67
+ const { result, unmount } = renderInit(HEADER_HEIGHT, 40)
68
+ expect(result.current.remountSyncTarget.value).toBe(-HEADER_HEIGHT + 40)
69
+ unmount()
70
+ })
71
+
72
+ it('retries the corrective scrollTo when an onScroll event reports the wrong position, and stays hidden', () => {
73
+ const { result, scrollTo, unmount } = renderInit(HEADER_HEIGHT)
74
+ scrollTo.mockClear()
75
+ act(() => {
76
+ result.current.onRemountSyncRetry(0) // a spurious post-remount event reporting y=0
77
+ })
78
+ expect(scrollTo).toHaveBeenCalledWith(-HEADER_HEIGHT, false)
79
+ expect(result.current.scrollReady).toBe(false)
80
+ unmount()
81
+ })
82
+
83
+ it('reveals once a confirmed on-target event arrives', () => {
84
+ const { result, contextValue, unmount } = renderInit(HEADER_HEIGHT)
85
+ act(() => {
86
+ result.current.onRemountSynced(-HEADER_HEIGHT)
87
+ })
88
+ expect(result.current.scrollReady).toBe(true)
89
+ expect(contextValue.scrollPosition.value).toBe(-HEADER_HEIGHT)
90
+ unmount()
91
+ })
92
+
93
+ it('gives up and reveals anyway after exceeding the max retry attempts, rather than hiding forever', () => {
94
+ const { result, unmount } = renderInit(HEADER_HEIGHT)
95
+ act(() => {
96
+ for (let i = 0; i < REMOUNT_SYNC_MAX_ATTEMPTS + 2; i += 1) result.current.onRemountSyncRetry(0)
97
+ })
98
+ expect(result.current.scrollReady).toBe(true)
99
+ expect(result.current.remountSyncTarget.value).toBeNull()
100
+ unmount()
101
+ })
102
+ })
103
+
104
+ const dragEvent = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as never
105
+
106
+ // A true first mount (not a remount): headerHeight starts null, then a rerender simulates the
107
+ // header's async measurement landing, mirroring the real production flow. Using a non-null
108
+ // headerHeight from the start (like the remount-sync tests above) would make useScrollInit treat
109
+ // this as a remount and pull in unrelated machinery — the priming scrollTo, the 40-frame RAF
110
+ // oscillation, and the REMOUNT_SYNC_FALLBACK_MS timer — none of which is relevant to the pull-search
111
+ // snap logic under test here.
112
+ const renderPullSearchInit = (pullSearchHeight: number) => {
113
+ const scrollTo = jest.fn()
114
+ let contextValue = buildContextValue(null)
115
+ const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(ScrollViewContext.Provider, { value: contextValue }, children)
116
+ const rendered = renderHook(() => useScrollInit({ pullSearchHeight, scrollTo }), { wrapper })
117
+ act(() => {
118
+ contextValue = buildContextValue(HEADER_HEIGHT)
119
+ rendered.rerender()
120
+ })
121
+ scrollTo.mockClear() // drop Effect A's insurance call and Effect B's showY->hideY push
122
+ return { ...rendered, contextValue, scrollTo }
123
+ }
124
+
125
+ describe('useScrollInit pull-search snap', () => {
126
+ const showY = -HEADER_HEIGHT
127
+ const hideY = -HEADER_HEIGHT + PULL_SEARCH_HEIGHT
128
+
129
+ beforeEach(() => jest.useFakeTimers())
130
+ afterEach(() => jest.useRealTimers())
131
+
132
+ it('opens toward showY when dragged down past half the pull-search height', () => {
133
+ const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
134
+ act(() => {
135
+ result.current.handleScrollBeginDrag(dragEvent(hideY))
136
+ result.current.handleScrollEndDrag(dragEvent(showY))
137
+ })
138
+ expect(scrollTo).toHaveBeenCalledWith(showY, true)
139
+ unmount()
140
+ })
141
+
142
+ it('closes toward hideY when released without pulling past the half threshold', () => {
143
+ const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
144
+ act(() => {
145
+ result.current.handleScrollBeginDrag(dragEvent(hideY))
146
+ result.current.handleScrollEndDrag(dragEvent(hideY - 10))
147
+ })
148
+ expect(scrollTo).toHaveBeenCalledWith(hideY, true)
149
+ unmount()
150
+ })
151
+
152
+ it('does nothing when the release lands outside the pull-search zone entirely', () => {
153
+ const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
154
+ act(() => {
155
+ result.current.handleScrollBeginDrag(dragEvent(0))
156
+ result.current.handleScrollEndDrag(dragEvent(0))
157
+ })
158
+ expect(scrollTo).not.toHaveBeenCalled()
159
+ unmount()
160
+ })
161
+
162
+ it('keeps retrying (non-animated) until scrollPosition actually confirms the target, then stops', () => {
163
+ const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
164
+ contextValue.scrollPosition.value = hideY
165
+ act(() => {
166
+ result.current.handleScrollBeginDrag(dragEvent(hideY))
167
+ result.current.handleScrollEndDrag(dragEvent(showY))
168
+ })
169
+ expect(scrollTo).toHaveBeenCalledTimes(1) // the initial animated call
170
+
171
+ act(() => {
172
+ jest.advanceTimersByTime(SNAP_VERIFY_INITIAL_DELAY_MS)
173
+ })
174
+ expect(scrollTo).toHaveBeenLastCalledWith(showY, false) // first non-animated retry, still short of target
175
+
176
+ // The retry "lands" — simulate onScroll confirming the real position.
177
+ contextValue.scrollPosition.value = showY
178
+ act(() => {
179
+ jest.advanceTimersByTime(SNAP_VERIFY_RETRY_DELAY_MS)
180
+ })
181
+ scrollTo.mockClear()
182
+ act(() => {
183
+ jest.advanceTimersByTime(SNAP_VERIFY_RETRY_DELAY_MS * 5)
184
+ })
185
+ expect(scrollTo).not.toHaveBeenCalled() // verification stopped once confirmed
186
+ unmount()
187
+ })
188
+
189
+ it('gives up after exceeding the max retry attempts rather than retrying forever', () => {
190
+ const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
191
+ contextValue.scrollPosition.value = hideY // never reaches showY, no matter how many retries
192
+ act(() => {
193
+ result.current.handleScrollBeginDrag(dragEvent(hideY))
194
+ result.current.handleScrollEndDrag(dragEvent(showY))
195
+ })
196
+ act(() => {
197
+ jest.advanceTimersByTime(SNAP_VERIFY_INITIAL_DELAY_MS + SNAP_VERIFY_RETRY_DELAY_MS * (SNAP_VERIFY_MAX_ATTEMPTS + 1))
198
+ })
199
+ // 1 initial animated call + SNAP_VERIFY_MAX_ATTEMPTS non-animated retries, then it gives up.
200
+ expect(scrollTo).toHaveBeenCalledTimes(1 + SNAP_VERIFY_MAX_ATTEMPTS)
201
+
202
+ scrollTo.mockClear()
203
+ act(() => {
204
+ jest.advanceTimersByTime(SNAP_VERIFY_RETRY_DELAY_MS * 3)
205
+ })
206
+ expect(scrollTo).not.toHaveBeenCalled()
207
+ unmount()
208
+ })
209
+
210
+ it('cancels a pending verification when a new drag begins', () => {
211
+ const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
212
+ contextValue.scrollPosition.value = hideY
213
+ act(() => {
214
+ result.current.handleScrollBeginDrag(dragEvent(hideY))
215
+ result.current.handleScrollEndDrag(dragEvent(showY))
216
+ })
217
+ scrollTo.mockClear()
218
+ act(() => {
219
+ result.current.handleScrollBeginDrag(dragEvent(showY))
220
+ })
221
+ act(() => {
222
+ jest.advanceTimersByTime(SNAP_VERIFY_INITIAL_DELAY_MS * 3)
223
+ })
224
+ expect(scrollTo).not.toHaveBeenCalled()
225
+ unmount()
226
+ })
227
+
228
+ it('handleMomentumScrollEnd auto-closes when momentum alone carried it into the zone with no drag-committed target', () => {
229
+ const { result, scrollTo, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
230
+ act(() => {
231
+ result.current.handleMomentumScrollEnd(dragEvent(hideY - 10))
232
+ })
233
+ expect(scrollTo).toHaveBeenCalledWith(hideY, true)
234
+ unmount()
235
+ })
236
+
237
+ it('handleMomentumScrollEnd defers to an already-pending drag-committed target instead of double-correcting', () => {
238
+ const { result, scrollTo, contextValue, unmount } = renderPullSearchInit(PULL_SEARCH_HEIGHT)
239
+ contextValue.scrollPosition.value = hideY
240
+ act(() => {
241
+ result.current.handleScrollBeginDrag(dragEvent(hideY))
242
+ result.current.handleScrollEndDrag(dragEvent(showY))
243
+ })
244
+ scrollTo.mockClear()
245
+ act(() => {
246
+ result.current.handleMomentumScrollEnd(dragEvent(hideY - 10))
247
+ })
248
+ expect(scrollTo).not.toHaveBeenCalled()
249
+ unmount()
250
+ })
251
+ })