@@ -47,6 +47,9 @@ function renderItem(item) {
4747 ) ;
4848}
4949
50+ // Trigger: Items inserted at beginning of data array. FlatList re-renders, native mounts new views at top.
51+ // Expected: Anchor view shifts downward by total height of prepended items. MVCP captures anchor's pre-mount frame,
52+ // computes delta = newFrame - oldFrame, adjusts contentOffset to keep anchor at same screen position.
5053test ( 'maintainVisibleContentPosition preserves position on prepend' , ( ) => {
5154 const root = Fantom . createRoot ( {
5255 viewportWidth : 100 ,
@@ -124,6 +127,9 @@ test('maintainVisibleContentPosition preserves position on prepend', () => {
124127 expect ( prependingLogs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
125128} ) ;
126129
130+ // Trigger: Multiple prepend operations in quick succession (no user interaction between batches).
131+ // The `pendingScrollUpdateCount` mechanism prevents render window adjustment during MVCP corrections.
132+ // Expected: Each prepend's delta applied sequentially. Anchor's final position after all prepends should be stable.
127133test ( 'maintainVisibleContentPosition handles consecutive prepends without drift' , ( ) => {
128134 const root = Fantom . createRoot ( {
129135 viewportWidth : 100 ,
@@ -186,6 +192,7 @@ test('maintainVisibleContentPosition handles consecutive prepends without drift'
186192 expect ( lastLogs . some ( log => log . includes ( 'item_19' ) ) ) . toBe ( true ) ;
187193} ) ;
188194
195+ // Ensures normal scrolling is not affected by MVCP prop being set.
189196test ( 'maintainVisibleContentPosition does not interfere with normal scroll' , ( ) => {
190197 const root = Fantom . createRoot ( {
191198 viewportWidth : 100 ,
@@ -234,6 +241,8 @@ test('maintainVisibleContentPosition does not interfere with normal scroll', ()
234241 expect ( logs . length ) . toBeGreaterThan ( 0 ) ;
235242} ) ;
236243
244+ // Trigger: ScrollView with autoscrollToTopThreshold set.
245+ // Expected: When scroll offset drops below threshold, ScrollView auto-scrolls to top. MVCP should not interfere.
237246test ( 'maintainVisibleContentPosition with autoscrollToTopThreshold triggers scroll to top' , ( ) => {
238247 const root = Fantom . createRoot ( {
239248 viewportWidth : 100 ,
@@ -292,6 +301,9 @@ test('maintainVisibleContentPosition with autoscrollToTopThreshold triggers scro
292301 expect ( prependingLogs . length ) . toBeGreaterThan ( 0 ) ;
293302} ) ;
294303
304+ // Trigger: ScrollView with maintainVisibleContentPosition={{minIndexForVisible: N}}.
305+ // Expected: Same MVCP logic as FlatList, but ScrollView has fixed set of subviews (no virtualization).
306+ // Anchor is the Nth subview whose bottom edge is below scroll offset.
295307test ( 'maintainVisibleContentPosition with minIndexForVisible > 0 skips early items' , ( ) => {
296308 const root = Fantom . createRoot ( {
297309 viewportWidth : 100 ,
@@ -346,6 +358,9 @@ test('maintainVisibleContentPosition with minIndexForVisible > 0 skips early ite
346358 expect ( logs2 . length ) . toBeGreaterThan ( 0 ) ;
347359} ) ;
348360
361+ // Trigger: Vertically inverted FlatList (inverted={true}). Items rendered in reverse order.
362+ // Expected: Inverted mode uses CSS transforms (scaleY: -1) to flip visual order. Native subview order unchanged.
363+ // MVCP finds first subview whose bottom edge is below scroll offset — the visually-topmost visible item.
349364test ( 'maintainVisibleContentPosition with inverted ScrollView preserves position on prepend' , ( ) => {
350365 const root = Fantom . createRoot ( {
351366 viewportWidth : 100 ,
@@ -406,6 +421,8 @@ test('maintainVisibleContentPosition with inverted ScrollView preserves position
406421 expect ( prependingLogs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
407422} ) ;
408423
424+ // Trigger: Multiple prepends in inverted mode.
425+ // Expected: Tag comparison safeguard must work correctly in inverted mode.
409426test ( 'maintainVisibleContentPosition with inverted ScrollView handles consecutive prepends' , ( ) => {
410427 const root = Fantom . createRoot ( {
411428 viewportWidth : 100 ,
@@ -470,6 +487,9 @@ test('maintainVisibleContentPosition with inverted ScrollView handles consecutiv
470487 expect ( lastLogs . some ( log => log . includes ( 'item_19' ) ) ) . toBe ( true ) ;
471488} ) ;
472489
490+ // Trigger: User actively dragging (touch-scrolling) when data change triggers MVCP.
491+ // Expected: MVCP correction may compete with user's scroll. Scroll skip guard on `patch/add-scrolling-guard`
492+ // branch would skip correction during user dragging, but this is NOT merged.
473493test ( 'maintainVisibleContentPosition does not interrupt scroll during prepend' , ( ) => {
474494 const root = Fantom . createRoot ( {
475495 viewportWidth : 100 ,
@@ -535,6 +555,8 @@ test('maintainVisibleContentPosition does not interrupt scroll during prepend',
535555 expect ( prependLogs . some ( log => log . includes ( 'item_10' ) ) ) . toBe ( true ) ;
536556} ) ;
537557
558+ // Trigger: Horizontally scrolling list in left-to-right layout direction.
559+ // Expected: Both iOS and Android compute deltas using frames directly, in same coordinate space as contentOffset.
538560test ( 'maintainVisibleContentPosition preserves position on horizontal prepend' , ( ) => {
539561 const root = Fantom . createRoot ( {
540562 viewportWidth : VIEWPORT_HEIGHT ,
@@ -589,6 +611,8 @@ test('maintainVisibleContentPosition preserves position on horizontal prepend',
589611 expect ( logs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
590612} ) ;
591613
614+ // Trigger: Horizontally inverted FlatList.
615+ // Expected: Same as vertical inverted — CSS transform flips visual order, native subview order unchanged.
592616test ( 'maintainVisibleContentPosition preserves position on horizontal + inverted prepend' , ( ) => {
593617 const root = Fantom . createRoot ( {
594618 viewportWidth : VIEWPORT_HEIGHT ,
@@ -643,6 +667,8 @@ test('maintainVisibleContentPosition preserves position on horizontal + inverted
643667 expect ( logs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
644668} ) ;
645669
670+ // Trigger: Items inserted at end of data array.
671+ // Expected: Appends don't shift existing items' frames, so MVCP delta is 0 and no scroll correction triggered.
646672test ( 'maintainVisibleContentPosition does not trigger correction on append' , ( ) => {
647673 const root = Fantom . createRoot ( {
648674 viewportWidth : 100 ,
@@ -696,6 +722,8 @@ test('maintainVisibleContentPosition does not trigger correction on append', ()
696722 expect ( logs . some ( log => log . includes ( 'item_20' ) ) ) . toBe ( true ) ;
697723} ) ;
698724
725+ // Trigger: Item currently at anchor position (first visible) removed from data array.
726+ // Expected: Anchor shifts to next visible item. MVCP captures new anchor's frame, computes delta, adjusts scroll.
699727test ( 'maintainVisibleContentPosition handles delete of anchor item' , ( ) => {
700728 const root = Fantom . createRoot ( {
701729 viewportWidth : 100 ,
@@ -746,6 +774,8 @@ test('maintainVisibleContentPosition handles delete of anchor item', () => {
746774 expect ( logs . some ( log => log . includes ( 'item_6' ) ) ) . toBe ( true ) ;
747775} ) ;
748776
777+ // Trigger: Item not at anchor position removed from data array.
778+ // Expected: If deleted item is above anchor, anchor shifts up. MVCP delta = newFrame - oldFrame.
749779test ( 'maintainVisibleContentPosition handles delete from middle of list' , ( ) => {
750780 const root = Fantom . createRoot ( {
751781 viewportWidth : 100 ,
@@ -796,6 +826,9 @@ test('maintainVisibleContentPosition handles delete from middle of list', () =>
796826 expect ( logs . some ( log => log . includes ( 'item_10' ) ) ) . toBe ( true ) ;
797827} ) ;
798828
829+ // Trigger: All items removed from data array. List becomes empty.
830+ // Expected: `_recomputeFirstVisibleViewForMaintainVisibleContentPosition` doesn't execute (loop doesn't run).
831+ // nil check prevents accessing frame on nil/invalid view.
799832test ( 'maintainVisibleContentPosition handles empty list gracefully' , ( ) => {
800833 const root = Fantom . createRoot ( {
801834 viewportWidth : 100 ,
@@ -842,6 +875,9 @@ test('maintainVisibleContentPosition handles empty list gracefully', () => {
842875 expect ( logs . length ) . toBeGreaterThan ( 0 ) ;
843876} ) ;
844877
878+ // Trigger: Items positioned above anchor grow in size (e.g., images load, expandable content opens).
879+ // Expected: Mathematical invariant `deltaY = newAnchorY - oldAnchorY = growth_of_items_above_anchor` holds.
880+ // Anchor's screen position remains constant. Anchor can never be pushed off-screen by sibling growth alone.
845881test ( 'maintainVisibleContentPosition handles sibling items above anchor growing' , ( ) => {
846882 const root = Fantom . createRoot ( {
847883 viewportWidth : 100 ,
@@ -907,6 +943,8 @@ test('maintainVisibleContentPosition handles sibling items above anchor growing'
907943 expect ( logs . some ( log => log . includes ( 'item_8' ) ) ) . toBe ( true ) ;
908944} ) ;
909945
946+ // Trigger: Items positioned above anchor shrink in size.
947+ // Expected: Same invariant as growth, but delta is negative. contentOffset decreases by shrinkage amount.
910948test ( 'maintainVisibleContentPosition handles sibling items above anchor shrinking' , ( ) => {
911949 const root = Fantom . createRoot ( {
912950 viewportWidth : 100 ,
@@ -972,6 +1010,10 @@ test('maintainVisibleContentPosition handles sibling items above anchor shrinkin
9721010 expect ( logs . some ( log => log . includes ( 'item_8' ) ) ) . toBe ( true ) ;
9731011} ) ;
9741012
1013+ // Trigger: setData([]) + scrollToOffset(0) clears and repopulates list.
1014+ // Expected: If old anchor key exists in new data, position maintained. Otherwise, JS computes adjustment as null
1015+ // and native side recomputes anchor from new view hierarchy.
1016+ // Two abort conditions: tag check (catches view recycling), superview check (catches view deletion).
9751017test ( 'maintainVisibleContentPosition handles data reset with entire data replacement' , ( ) => {
9761018 const root = Fantom . createRoot ( {
9771019 viewportWidth : 100 ,
@@ -1022,6 +1064,9 @@ test('maintainVisibleContentPosition handles data reset with entire data replace
10221064 expect ( logs . some ( log => log . includes ( 'item_105' ) ) ) . toBe ( true ) ;
10231065} ) ;
10241066
1067+ // Trigger: List rendered with initialScrollIndex pointing to non-first item, then items prepended.
1068+ // Expected: If initialScrollIndex refers to item pushed by prepend, scroll destination may be wrong
1069+ // because JS's initial scroll calculation doesn't account for MVCP corrections.
10251070test ( 'maintainVisibleContentPosition with initialScrollIndex + prepend after remount' , ( ) => {
10261071 const root = Fantom . createRoot ( {
10271072 viewportWidth : 100 ,
@@ -1072,6 +1117,9 @@ test('maintainVisibleContentPosition with initialScrollIndex + prepend after rem
10721117 expect ( logs . some ( log => log . includes ( 'item_20' ) ) ) . toBe ( true ) ;
10731118} ) ;
10741119
1120+ // Trigger: Horizontally scrolling list in right-to-left layout direction.
1121+ // Expected: Frame-based delta computation should work for RTL since frames are in same coordinate space as contentOffset.
1122+ // contentInset handling in RTL not explicitly tested.
10751123test ( 'maintainVisibleContentPosition preserves position on horizontal prepend in RTL' , ( ) => {
10761124 const root = Fantom . createRoot ( {
10771125 viewportWidth : VIEWPORT_HEIGHT ,
@@ -1128,6 +1176,8 @@ test('maintainVisibleContentPosition preserves position on horizontal prepend in
11281176 expect ( logs . some ( log => log . includes ( 'item_20' ) ) ) . toBe ( true ) ;
11291177} ) ;
11301178
1179+ // Trigger: Multiple mutation types in same data batch: items prepended at top, appended at bottom, deleted from middle.
1180+ // Expected: Anchor's final frame reflects ALL changes, delta correct for net effect.
11311181test ( 'maintainVisibleContentPosition handles complex concurrent mutations (prepend + append + middle delete)' , ( ) => {
11321182 const root = Fantom . createRoot ( {
11331183 viewportWidth : 100 ,
@@ -1190,6 +1240,9 @@ test('maintainVisibleContentPosition handles complex concurrent mutations (prepe
11901240 expect ( logs . some ( log => log . includes ( 'item_20' ) ) ) . toBe ( true ) ;
11911241} ) ;
11921242
1243+ // Trigger: FlatList with getItemLayout prop providing fixed item dimensions.
1244+ // Expected: Native MVCP always reads actual frames, so accurate regardless of JS metrics.
1245+ // getItemLayout doesn't affect native MVCP.
11931246test ( 'maintainVisibleContentPosition with getItemLayout prop' , ( ) => {
11941247 const root = Fantom . createRoot ( {
11951248 viewportWidth : 100 ,
@@ -1257,6 +1310,8 @@ test('maintainVisibleContentPosition with getItemLayout prop', () => {
12571310 expect ( logs . some ( log => log . includes ( 'item_7' ) ) ) . toBe ( true ) ;
12581311} ) ;
12591312
1313+ // Trigger: Content view has only spacers (placeholder views with no data binding) in visible area.
1314+ // Expected: Anchor selection incorrect. Delta computed from spacer's frame is meaningless.
12601315test ( 'maintainVisibleContentPosition handles all items culled (spacers only in viewport)' , ( ) => {
12611316 const root = Fantom . createRoot ( {
12621317 viewportWidth : 100 ,
@@ -1342,6 +1397,8 @@ test('maintainVisibleContentPosition handles all items culled (spacers only in v
13421397 expect ( logs . some ( log => log . includes ( 'item_10' ) ) ) . toBe ( true ) ;
13431398} ) ;
13441399
1400+ // Trigger: User performs pull-to-refresh which triggers data prepend.
1401+ // Expected: Pull-to-refresh typically scrolls to top, then prepends items. MVCP handles prepend delta after refresh.
13451402test ( 'maintainVisibleContentPosition simulates pull-to-refresh pattern' , ( ) => {
13461403 const root = Fantom . createRoot ( {
13471404 viewportWidth : 100 ,
@@ -1404,6 +1461,9 @@ test('maintainVisibleContentPosition simulates pull-to-refresh pattern', () => {
14041461 expect ( logs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
14051462} ) ;
14061463
1464+ // Trigger: ScrollView unmounted (user navigates away), then remounted (new screen).
1465+ // Expected: iOS Fabric: prepareForRecycle resets anchor state. Android: stop() removes UIManager listener.
1466+ // Fresh MVCP state initialization on remount.
14071467test ( 'maintainVisibleContentPosition handles unmount/remount (navigation pattern)' , ( ) => {
14081468 const root = Fantom . createRoot ( {
14091469 viewportWidth : 100 ,
@@ -1465,6 +1525,8 @@ test('maintainVisibleContentPosition handles unmount/remount (navigation pattern
14651525 expect ( remountLogs . some ( log => log . includes ( 'item_55' ) ) ) . toBe ( true ) ;
14661526} ) ;
14671527
1528+ // Trigger: Keyboard or safe area insets change, changing ScrollView's contentInset.
1529+ // Expected: Frame-based MVCP delta computation not affected by inset changes because frames are in content coordinates.
14681530test ( 'maintainVisibleContentPosition handles contentInset changes (keyboard/safe area)' , ( ) => {
14691531 const root = Fantom . createRoot ( {
14701532 viewportWidth : 100 ,
@@ -1520,6 +1582,9 @@ test('maintainVisibleContentPosition handles contentInset changes (keyboard/safe
15201582 expect ( logs . some ( log => log . includes ( 'item_8' ) ) ) . toBe ( true ) ;
15211583} ) ;
15221584
1585+ // Trigger: Items prepended at top AND removed from bottom in same data batch.
1586+ // Expected: Native side unaffected by bottom deletes since MVCP only looks at first visible view.
1587+ // TODO: detect and handle/ignore re-ordering comment at RCTScrollViewComponentView.mm:1110 explicitly unhandled.
15231588test ( 'maintainVisibleContentPosition handles prepend with delete from bottom' , ( ) => {
15241589 const root = Fantom . createRoot ( {
15251590 viewportWidth : 100 ,
@@ -1573,6 +1638,9 @@ test('maintainVisibleContentPosition handles prepend with delete from bottom', (
15731638 expect ( logs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
15741639} ) ;
15751640
1641+ // Trigger: Large number of items (50+) inserted at beginning of data array.
1642+ // Expected: Anchor view may be recycled by FlatList's view pool. Tag comparison safeguard detects recycled view
1643+ // and aborts correction. Without this check, delta computed from wrong view.
15761644test ( 'maintainVisibleContentPosition handles large prepend (50+ items)' , ( ) => {
15771645 const root = Fantom . createRoot ( {
15781646 viewportWidth : 100 ,
@@ -1626,6 +1694,9 @@ test('maintainVisibleContentPosition handles large prepend (50+ items)', () => {
16261694 expect ( logs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
16271695} ) ;
16281696
1697+ // Trigger: Very first prepend after initial list mount. Anchor state not yet initialized by prior MVCP cycle.
1698+ // Expected: On first mount, `_prepareForMaintainVisibleScrollPosition` initializes anchor state.
1699+ // First prepend should work correctly because initial mount establishes baseline.
16291700test ( 'maintainVisibleContentPosition handles first prepend after initial mount' , ( ) => {
16301701 const root = Fantom . createRoot ( {
16311702 viewportWidth : 100 ,
@@ -1673,6 +1744,10 @@ test('maintainVisibleContentPosition handles first prepend after initial mount',
16731744 expect ( logs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
16741745} ) ;
16751746
1747+ // Trigger: Items have dynamic heights (images loading, variable text). Anchor's frame size may differ between
1748+ // pre-mount capture and post-layout measurement.
1749+ // Expected: Delta formula `newFrame - oldFrame` conflates position shift from prepended items and size change of
1750+ // anchor item itself. Frame-based approach inherently correct but first correction may be inaccurate.
16761751test ( 'maintainVisibleContentPosition handles variable-height items' , ( ) => {
16771752 const root = Fantom . createRoot ( {
16781753 viewportWidth : 100 ,
@@ -1755,6 +1830,9 @@ test('maintainVisibleContentPosition handles variable-height items', () => {
17551830 expect ( logs . some ( log => log . includes ( 'item_6' ) ) ) . toBe ( true ) ;
17561831} ) ;
17571832
1833+ // Trigger: Items above anchor grow, pushing anchor off top of visible area. Culling removes off-screen views.
1834+ // Expected: On next mount cycle, `_recomputeFirstVisibleViewForMaintainVisibleContentPosition` finds new anchor.
1835+ // Tag comparison safeguard detects when anchor view was recycled (different tag) and aborts correction.
17581836test ( 'maintainVisibleContentPosition handles anchor culled (pushed off-screen)' , ( ) => {
17591837 const root = Fantom . createRoot ( {
17601838 viewportWidth : 100 ,
@@ -1808,6 +1886,8 @@ test('maintainVisibleContentPosition handles anchor culled (pushed off-screen)',
18081886 expect ( logs . some ( log => log . includes ( 'item_13' ) ) ) . toBe ( true ) ;
18091887} ) ;
18101888
1889+ // Trigger: Inverted list with culling enabled, causing view recycling during prepends.
1890+ // Expected: Tag comparison safeguard must work correctly in inverted mode. Tag check always active.
18111891test ( 'maintainVisibleContentPosition with inverted + recycling' , ( ) => {
18121892 const root = Fantom . createRoot ( {
18131893 viewportWidth : 100 ,
@@ -1863,6 +1943,9 @@ test('maintainVisibleContentPosition with inverted + recycling', () => {
18631943 expect ( logs . some ( log => log . includes ( 'item_5' ) ) ) . toBe ( true ) ;
18641944} ) ;
18651945
1946+ // Trigger: Many rapid state updates cause many re-renders in quick succession.
1947+ // Expected: If scroll events throttled, `pendingScrollUpdateCount` may not decrement promptly, blocking render
1948+ // window updates. Android scroll throttle fix ensures JS state current after MVCP adjustments.
18661949test ( 'maintainVisibleContentPosition handles rapid state updates' , ( ) => {
18671950 const root = Fantom . createRoot ( {
18681951 viewportWidth : 100 ,
@@ -1921,6 +2004,9 @@ test('maintainVisibleContentPosition handles rapid state updates', () => {
19212004 expect ( logs . some ( log => log . includes ( 'item_8' ) ) ) . toBe ( true ) ;
19222005} ) ;
19232006
2007+ // Trigger: Programmatic scrollToOffset call while MVCP is active.
2008+ // Expected: Programmatic scrollToOffset during MVCP active can cause incorrect final position.
2009+ // MVCP delta is additive, adds to whatever current scroll position is. Known open issue.
19242010test ( 'maintainVisibleContentPosition with scrollToOffset (non-animated)' , ( ) => {
19252011 const root = Fantom . createRoot ( {
19262012 viewportWidth : 100 ,
@@ -1962,6 +2048,9 @@ test('maintainVisibleContentPosition with scrollToOffset (non-animated)', () =>
19622048 expect ( logs . length ) . toBeGreaterThan ( 0 ) ;
19632049} ) ;
19642050
2051+ // Trigger: Animated scrollToOffset call in progress when MVCP correction applied.
2052+ // Expected: Animated scrollToOffset interrupted by MVCP correction because setting contentOffset directly
2053+ // (iOS) or calling scrollToPreservingMomentum (Android) replaces any ongoing animation.
19652054test ( 'maintainVisibleContentPosition with scrollToOffset (animated)' , ( ) => {
19662055 const root = Fantom . createRoot ( {
19672056 viewportWidth : 100 ,
@@ -2003,6 +2092,8 @@ test('maintainVisibleContentPosition with scrollToOffset (animated)', () => {
20032092 expect ( logs . length ) . toBeGreaterThan ( 0 ) ;
20042093} ) ;
20052094
2095+ // Trigger: Item's content changes but rendered size stays the same.
2096+ // Expected: No frame change, no delta, no scroll correction. Anchor stays at same position.
20062097test ( 'maintainVisibleContentPosition handles content change with same size' , ( ) => {
20072098 const root = Fantom . createRoot ( {
20082099 viewportWidth : 100 ,
0 commit comments