diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-maintainVisibleContentPosition-itest.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-maintainVisibleContentPosition-itest.js new file mode 100644 index 000000000000..784a92df6057 --- /dev/null +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-maintainVisibleContentPosition-itest.js @@ -0,0 +1,2066 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {createRef} from 'react'; +import {ScrollView, View} from 'react-native'; + +const ITEM_HEIGHT = 40; +const VIEWPORT_HEIGHT = 200; +const NUM_ITEMS = 20; + +function makeItems(count, startKey = 0) { + return Array.from({length: count}, (_, i) => ({ + key: String(i + startKey), + id: i + startKey, + })); +} + +function renderItem(item) { + return ( + + + + ); +} + +test('maintainVisibleContentPosition preserves position on prepend', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render initial list + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + // Verify initial mount + const initialLogs = root.takeMountingManagerLogs(); + expect(initialLogs.length).toBeGreaterThan(0); + + // Scroll to item 5 (approximately 200px down) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + // Capture scroll logs + const scrollLogs1 = root.takeMountingManagerLogs(); + expect(scrollLogs1.length).toBeGreaterThan(0); + + // Prepend 5 items at the top + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + // Simulate the native scroll correction that would happen after prepend. + // The content height increased by 5 * ITEM_HEIGHT, so the scroll offset + // should be adjusted to keep the same item visible. + const expectedContentHeight = itemsAfterPrepend.length * ITEM_HEIGHT; + Fantom.runTask(() => { + // Trigger content size change simulation + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const prependingLogs = root.takeMountingManagerLogs(); + expect(prependingLogs.length).toBeGreaterThan(0); + + // Verify that the item_5 is still in the rendered tree after prepend + // (it should have moved from index 5 to index 10, but still be visible) + expect(prependingLogs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles consecutive prepends without drift', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + // Render initial list + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to middle of the list + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + root.takeMountingManagerLogs(); + + // Perform 3 consecutive prepends + const numPrepends = 3; + const itemsPerPrepend = 3; + let lastLogs = []; + + for (let i = 0; i < numPrepends; i++) { + currentItems = [ + ...makeItems(itemsPerPrepend, currentItems.length), + ...currentItems, + ]; + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + lastLogs = root.takeMountingManagerLogs(); + expect(lastLogs.length).toBeGreaterThan(0); + } + + // The list should still contain the original items + expect(lastLogs.some(log => log.includes('item_0'))).toBe(true); + expect(lastLogs.some(log => log.includes('item_19'))).toBe(true); +}); + +test('maintainVisibleContentPosition does not interfere with normal scroll', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const items = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {items.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Normal scrolling should work as expected + Fantom.scrollTo(nodeRef, { + x: 0, + y: 0, + }); + + let logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 10, + }); + + logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); +}); + +test('maintainVisibleContentPosition with autoscrollToTopThreshold triggers scroll to top', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const items = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {items.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll near the top (within threshold) + Fantom.scrollTo(nodeRef, { + x: 0, + y: 5, + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + + // Prepend items — since we're within the threshold, scroll should go to top + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...items, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const prependingLogs = root.takeMountingManagerLogs(); + expect(prependingLogs.length).toBeGreaterThan(0); +}); + +test('maintainVisibleContentPosition with minIndexForVisible > 0 skips early items', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const items = makeItems(NUM_ITEMS); + + // Use minIndexForVisible: 5 — only maintain position for items at index 5+ + Fantom.runTask(() => { + root.render( + + {items.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 8 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + const logs1 = root.takeMountingManagerLogs(); + expect(logs1.length).toBeGreaterThan(0); + + // Prepend 3 items — item 8 becomes item 11, but minIndexForVisible: 5 + // means items 0-4 are not considered for anchor + const itemsAfterPrepend = [ + ...makeItems(3, NUM_ITEMS), + ...items, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs2 = root.takeMountingManagerLogs(); + expect(logs2.length).toBeGreaterThan(0); +}); + +test('maintainVisibleContentPosition with inverted ScrollView preserves position on prepend', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render initial list with inverted mode + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + // Verify initial mount + const initialLogs = root.takeMountingManagerLogs(); + expect(initialLogs.length).toBeGreaterThan(0); + + // Scroll to item 5 (in inverted mode, this is near the bottom) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + const scrollLogs1 = root.takeMountingManagerLogs(); + expect(scrollLogs1.length).toBeGreaterThan(0); + + // Prepend 5 items at the top + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const prependingLogs = root.takeMountingManagerLogs(); + expect(prependingLogs.length).toBeGreaterThan(0); + + // Verify that the item_5 is still in the rendered tree after prepend + expect(prependingLogs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition with inverted ScrollView handles consecutive prepends', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + // Render initial list with inverted mode + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to middle + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + root.takeMountingManagerLogs(); + + // Perform 3 consecutive prepends in inverted mode + const numPrepends = 3; + const itemsPerPrepend = 3; + let lastLogs = []; + + for (let i = 0; i < numPrepends; i++) { + currentItems = [ + ...makeItems(itemsPerPrepend, currentItems.length), + ...currentItems, + ]; + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + lastLogs = root.takeMountingManagerLogs(); + expect(lastLogs.length).toBeGreaterThan(0); + } + + // The list should still contain the original items + expect(lastLogs.some(log => log.includes('item_0'))).toBe(true); + expect(lastLogs.some(log => log.includes('item_19'))).toBe(true); +}); + +test('maintainVisibleContentPosition does not interrupt scroll during prepend', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render initial list + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 10 (simulating user dragging upward) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 10, + }); + + const dragScrollLogs = root.takeMountingManagerLogs(); + expect(dragScrollLogs.length).toBeGreaterThan(0); + + // Prepend 5 items while the scroll position is at item 10 + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const prependLogs = root.takeMountingManagerLogs(); + expect(prependLogs.length).toBeGreaterThan(0); + + // Verify that the item_10 is still visible after prepend + // (it should have moved from index 10 to index 15, but remain at the same screen position) + expect(prependLogs.some(log => log.includes('item_10'))).toBe(true); +}); + +test('maintainVisibleContentPosition preserves position on horizontal prepend', () => { + const root = Fantom.createRoot({ + viewportWidth: VIEWPORT_HEIGHT, + viewportHeight: 100, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll horizontally to item 5 + Fantom.scrollTo(nodeRef, { + x: ITEM_HEIGHT * 5, + y: 0, + }); + + root.takeMountingManagerLogs(); + + // Prepend 5 items + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + expect(logs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition preserves position on horizontal + inverted prepend', () => { + const root = Fantom.createRoot({ + viewportWidth: VIEWPORT_HEIGHT, + viewportHeight: 100, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + Fantom.scrollTo(nodeRef, { + x: ITEM_HEIGHT * 5, + y: 0, + }); + + root.takeMountingManagerLogs(); + + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + expect(logs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition does not trigger correction on append', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Append 5 items at the end (should not affect anchor position) + const itemsAfterAppend = [ + ...initialItems, + ...makeItems(5, NUM_ITEMS), + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterAppend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // Append shouldn't affect anchor — verify list is still rendered with new items + expect(logs.some(log => log.includes('item_20'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles delete of anchor item', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 (it will be the anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Delete item 5 (the anchor) + currentItems = currentItems.filter((_, i) => i !== 5); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // item_5 should be gone, item_6 should now be visible (shifted to index 5) + expect(logs.some(log => log.includes('item_6'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles delete from middle of list', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 10 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 10, + }); + + root.takeMountingManagerLogs(); + + // Delete item 3 (above anchor, should cause anchor to shift up) + currentItems = currentItems.filter((_, i) => i !== 3); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // item_10 should still be visible (now at index 9 after deletion) + expect(logs.some(log => log.includes('item_10'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles empty list gracefully', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Remove all items (empty list) + Fantom.runTask(() => { + root.render( + + {[]} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); +}); + +test('maintainVisibleContentPosition handles sibling items above anchor growing', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 8 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + root.takeMountingManagerLogs(); + + // Render with items 0-4 growing from 40px to 80px each (40px growth per item = 200px total) + Fantom.runTask(() => { + root.render( + + {initialItems.map((item, index) => + index < 5 + ? ( + + + + ) + : renderItem(item), + )} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + expect(logs.some(log => log.includes('item_8'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles sibling items above anchor shrinking', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 8 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + root.takeMountingManagerLogs(); + + // Render with items 0-4 shrinking from 40px to 20px each (20px shrink per item = 100px total) + Fantom.runTask(() => { + root.render( + + {initialItems.map((item, index) => + index < 5 + ? ( + + + + ) + : renderItem(item), + )} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + expect(logs.some(log => log.includes('item_8'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles data reset with entire data replacement', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Replace entire data with new items (different keys) + const resetItems = makeItems(NUM_ITEMS, 100); + + Fantom.runTask(() => { + root.render( + + {resetItems.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // Original items should be gone, new items should be present + expect(logs.some(log => log.includes('item_105'))).toBe(true); +}); + +test('maintainVisibleContentPosition with initialScrollIndex + prepend after remount', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render list with initialScrollIndex pointing to a non-first item + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Force remount with a different key (simulates navigation to new screen with same component) + const itemsAfterPrepend = [ + ...makeItems(3, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // After remount with prepend, items should be rendered with new keys + expect(logs.some(log => log.includes('item_20'))).toBe(true); +}); + +test('maintainVisibleContentPosition preserves position on horizontal prepend in RTL', () => { + const root = Fantom.createRoot({ + viewportWidth: VIEWPORT_HEIGHT, + viewportHeight: 100, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll horizontally to item 5 + Fantom.scrollTo(nodeRef, { + x: ITEM_HEIGHT * 5, + y: 0, + }); + + root.takeMountingManagerLogs(); + + // Prepend 5 items in RTL mode + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // Verify items are rendered after prepend in RTL mode + // (item_20 = first item after the 5 prepended items starting at key 20) + expect(logs.some(log => log.includes('item_20'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles complex concurrent mutations (prepend + append + middle delete)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 8 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + root.takeMountingManagerLogs(); + + // Apply complex mutations in a single batch: + // - Prepend 3 items at the top + // - Append 2 items at the bottom + // - Delete 2 items from the middle (indices 10 and 12 in the original array) + const itemsAfterPrepend = [ + ...makeItems(3, NUM_ITEMS), + ...currentItems, + ...makeItems(2, NUM_ITEMS + 23), + ]; + + // Delete items at original indices 10 and 12 (which are now at indices 13 and 15 after prepend) + currentItems = itemsAfterPrepend.filter((_, i) => i !== 13 && i !== 15); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The anchor item should still be visible after complex mutations + expect(logs.some(log => log.includes('item_8'))).toBe(true); + // Verify prepended items are present + expect(logs.some(log => log.includes('item_20'))).toBe(true); +}); + +test('maintainVisibleContentPosition with getItemLayout prop', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + const getItemLayout = (_: mixed, index: number) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 7 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 7, + }); + + root.takeMountingManagerLogs(); + + // Prepend 4 items + const itemsAfterPrepend = [ + ...makeItems(4, NUM_ITEMS), + ...initialItems, + ]; + + const getItemLayoutAfterPrepend = (_: mixed, index: number) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }); + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The anchor item should still be visible after prepend + expect(logs.some(log => log.includes('item_7'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles all items culled (spacers only in viewport)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render list with items that have larger heights to push more items off-screen + const LARGE_ITEM_HEIGHT = 80; + + Fantom.runTask(() => { + root.render( + + {initialItems.map((item, index) => ( + + + + ))} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 10 (anchor) — this pushes items 0-2 off-screen (culled) + Fantom.scrollTo(nodeRef, { + x: 0, + y: LARGE_ITEM_HEIGHT * 10, + }); + + root.takeMountingManagerLogs(); + + // Prepend 3 items — the culled items (0-2) are replaced by new items (20-22) + // The viewport may show spacers (culled item slots) and new data items + const itemsAfterPrepend = [ + ...makeItems(3, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map((item, index) => ( + + + + ))} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The list should still render without crashing + expect(logs.some(log => log.includes('item_10'))).toBe(true); +}); + +test('maintainVisibleContentPosition simulates pull-to-refresh pattern', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + // Render initial list + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Simulate pull-to-refresh: scroll to top first + Fantom.scrollTo(nodeRef, { + x: 0, + y: 0, + }); + + root.takeMountingManagerLogs(); + + // Refresh completes: prepend new items (simulating fresh data from server) + const itemsAfterRefresh = [ + ...makeItems(3, NUM_ITEMS), + ...currentItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterRefresh.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // Original items should still be present after refresh+prepend + expect(logs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles unmount/remount (navigation pattern)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render first list (screen 1) + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Unmount: replace with empty content (simulates navigating away) + Fantom.runTask(() => { + root.render( + , + ); + }); + + const unmountLogs = root.takeMountingManagerLogs(); + expect(unmountLogs.length).toBeGreaterThanOrEqual(0); + + // Remount: render a new list (simulates navigating to a new screen with same component) + const newItems = makeItems(NUM_ITEMS, 50); + + Fantom.runTask(() => { + root.render( + + {newItems.map(renderItem)} + , + ); + }); + + const remountLogs = root.takeMountingManagerLogs(); + expect(remountLogs.length).toBeGreaterThan(0); + // New list items should be rendered (not old ones) + expect(remountLogs.some(log => log.includes('item_55'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles contentInset changes (keyboard/safe area)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render list without contentInset + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 8 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + root.takeMountingManagerLogs(); + + // Simulate keyboard appearance: change contentInset (bottom inset increases) + const itemsAfterPrepend = [ + ...makeItems(2, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The anchor item should still be visible after contentInset change + prepend + expect(logs.some(log => log.includes('item_8'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles prepend with delete from bottom', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Prepend 1 item at top AND delete 3 from bottom in same batch + const itemsAfterMutation = [ + ...makeItems(1, NUM_ITEMS), + ...currentItems.slice(0, NUM_ITEMS - 3), + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterMutation.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The anchor item should still be visible after prepending at top and deleting from bottom + expect(logs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles large prepend (50+ items)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Prepend 50 items — this causes view recycling, tag comparison safeguard must detect it + const itemsAfterPrepend = [ + ...makeItems(50, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The list should render without crashing despite view recycling + expect(logs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles first prepend after initial mount', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render initial list — anchor state not yet initialized + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + const initialLogs = root.takeMountingManagerLogs(); + expect(initialLogs.length).toBeGreaterThan(0); + + // Prepend 5 items on the very first update (anchor state being initialized) + const itemsAfterPrepend = [ + ...makeItems(5, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // Items should be rendered correctly after first prepend + expect(logs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles variable-height items', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + // Render with variable heights (some items taller than others) + Fantom.runTask(() => { + root.render( + + {initialItems.map((item, index) => ( + + + + ))} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 6 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 6, + }); + + root.takeMountingManagerLogs(); + + // Prepend 3 variable-height items + const itemsAfterPrepend = [ + ...makeItems(3, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map((item, index) => ( + + + + ))} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The anchor item should still be visible after prepend with variable heights + expect(logs.some(log => log.includes('item_6'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles anchor culled (pushed off-screen)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 3 (anchor near top) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 3, + }); + + root.takeMountingManagerLogs(); + + // Prepend 10 items — pushes item_3 off-screen (culled), a new anchor is selected + const itemsAfterPrepend = [ + ...makeItems(10, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The list should render without crashing when anchor is culled + expect(logs.some(log => log.includes('item_13'))).toBe(true); +}); + +test('maintainVisibleContentPosition with inverted + recycling', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 (in inverted mode, near bottom) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Prepend 50 items — causes recycling in inverted mode + const itemsAfterPrepend = [ + ...makeItems(50, NUM_ITEMS), + ...initialItems, + ]; + + Fantom.runTask(() => { + root.render( + + {itemsAfterPrepend.map(renderItem)} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // The list should render without crashing in inverted + recycling mode + expect(logs.some(log => log.includes('item_5'))).toBe(true); +}); + +test('maintainVisibleContentPosition handles rapid state updates', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + let currentItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 8 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 8, + }); + + root.takeMountingManagerLogs(); + + // Perform many rapid prepends in succession (simulates many rapid state updates) + const numBatches = 5; + const itemsPerBatch = 10; + + for (let i = 0; i < numBatches; i++) { + currentItems = [ + ...makeItems(itemsPerBatch, currentItems.length), + ...currentItems, + ]; + + Fantom.runTask(() => { + root.render( + + {currentItems.map(renderItem)} + , + ); + }); + } + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // Original items should still be present after many rapid updates + expect(logs.some(log => log.includes('item_8'))).toBe(true); +}); + +test('maintainVisibleContentPosition with scrollToOffset (non-animated)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Call scrollToOffset while MVCP is active + // Programmatic scrollToOffset during MVCP active can cause incorrect final position + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 10, + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); +}); + +test('maintainVisibleContentPosition with scrollToOffset (animated)', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Call scrollToOffset while MVCP is active + // Animated scrollToOffset is interrupted by MVCP correction + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 15, + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); +}); + +test('maintainVisibleContentPosition handles content change with same size', () => { + const root = Fantom.createRoot({ + viewportWidth: 100, + viewportHeight: VIEWPORT_HEIGHT, + }); + const nodeRef = createRef(); + + const initialItems = makeItems(NUM_ITEMS); + + Fantom.runTask(() => { + root.render( + + {initialItems.map(renderItem)} + , + ); + }); + + root.takeMountingManagerLogs(); + + // Scroll to item 5 (anchor) + Fantom.scrollTo(nodeRef, { + x: 0, + y: ITEM_HEIGHT * 5, + }); + + root.takeMountingManagerLogs(); + + // Re-render with different content but same size (simulates text change, icon swap, etc.) + Fantom.runTask(() => { + root.render( + + {initialItems.map((item, index) => ( + + + + ))} + , + ); + }); + + const logs = root.takeMountingManagerLogs(); + expect(logs.length).toBeGreaterThan(0); + // No frame change, no scroll correction expected + // No frame change, no scroll correction expected (content change alone doesn't shift frames) +}); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 548987ff291d..654d712d4804 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -1092,11 +1092,27 @@ - (void)_adjustForMaintainVisibleContentPosition return; } - if (ReactNativeFeatureFlags::enableViewCulling()) { - // Abort if the first visible view has changed (different tag) - if (_firstVisibleView && _firstVisibleView.tag != _firstVisibleViewTag) { - return; - } + // Abort if no first visible view (e.g., list was empty during mount) + if (!_firstVisibleView) { + return; + } + + // Abort if the first visible view has been recycled for a different item. + // The tag was captured in _prepareForMaintainVisibleScrollPosition (before + // mounting), and RCTComponentViewRegistry assigns new tags during dequeue + // (mounting) and resets them to 0 during enqueue (unmounting). When items + // are removed and re-added, recycled views get new tags based on their + // position, so the view at position 0 may have a different tag than before. + // If the tag changed, we bail out to avoid applying the MVCP delta to the + // wrong view, which would produce incorrect scroll offsets. + if (_firstVisibleView.tag != _firstVisibleViewTag) { + return; + } + + // Abort if the first visible view was deleted during mount (not recycled) + // This prevents MVCP from applying a delta after scrollToOffset(0) during reset/clear + if (_firstVisibleView.superview != _contentView) { + return; } std::optional autoscrollThreshold = props.maintainVisibleContentPosition.value().autoscrollToTopThreshold; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt index 2bee605a15c2..3d57af1dfbc7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt @@ -18,7 +18,9 @@ import com.facebook.react.bridge.UiThreadUtil.runOnUiThread import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.common.UIManagerType +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll +import com.facebook.react.views.scroll.ReactScrollViewHelper.emitScrollEventNoThrottle import com.facebook.react.views.view.ReactViewGroup import java.lang.ref.WeakReference @@ -31,7 +33,7 @@ import java.lang.ref.WeakReference internal class MaintainVisibleScrollPositionHelper( private val scrollView: ScrollViewT, private val horizontal: Boolean, -) : UIManagerListener where ScrollViewT : HasSmoothScroll?, ScrollViewT : ViewGroup? { +) : UIManagerListener where ScrollViewT : HasScrollEventThrottle?, ScrollViewT : HasSmoothScroll?, ScrollViewT : ViewGroup? { var config: Config? = null private var firstVisibleViewRef: WeakReference? = null @@ -98,6 +100,7 @@ internal class MaintainVisibleScrollPositionHelper( val scrollX = scrollView.scrollX scrollView.scrollToPreservingMomentum(scrollX + deltaX, scrollView.scrollY) this.prevFirstVisibleFrame = newFrame + emitScrollEventNoThrottle(scrollView, 0f, 0f) if (config.autoScrollToTopThreshold != null && scrollX <= config.autoScrollToTopThreshold) { scrollView.reactSmoothScrollTo(0, scrollView.scrollY) } @@ -108,6 +111,7 @@ internal class MaintainVisibleScrollPositionHelper( val scrollY = scrollView.scrollY scrollView.scrollToPreservingMomentum(scrollView.scrollX, scrollY + deltaY) this.prevFirstVisibleFrame = newFrame + emitScrollEventNoThrottle(scrollView, 0f, 0f) if (config.autoScrollToTopThreshold != null && scrollY <= config.autoScrollToTopThreshold) { scrollView.reactSmoothScrollTo(scrollView.scrollX, 0) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt index 98b52e028f9f..4b32e35f722c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -68,7 +68,17 @@ public object ReactScrollViewHelper { @JvmStatic public fun emitScrollEvent(scrollView: T, xVelocity: Float, yVelocity: Float) where T : HasScrollEventThrottle?, T : ViewGroup { - emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity) + emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity, false) + } + + /** + * Emits a scroll event without throttling. Used by MVCP to ensure scroll position updates reach + * JS immediately when the scroll position is adjusted programmatically. + */ + @JvmStatic + public fun emitScrollEventNoThrottle(scrollView: T, xVelocity: Float, yVelocity: Float) + where T : HasScrollEventThrottle?, T : ViewGroup { + emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity, true) } @JvmStatic @@ -102,7 +112,7 @@ public object ReactScrollViewHelper { private fun emitScrollEvent(scrollView: T, scrollEventType: ScrollEventType) where T : HasScrollEventThrottle?, T : ViewGroup { - emitScrollEvent(scrollView, scrollEventType, 0f, 0f) + emitScrollEvent(scrollView, scrollEventType, 0f, 0f, false) } private fun emitScrollEvent( @@ -110,12 +120,14 @@ public object ReactScrollViewHelper { scrollEventType: ScrollEventType, xVelocity: Float, yVelocity: Float, + skipThrottle: Boolean = false, ) where T : HasScrollEventThrottle?, T : ViewGroup { val now = System.currentTimeMillis() // Throttle the scroll event if scrollEventThrottle is set to be equal or more than 17 ms. // We limit the delta to 17ms so that small throttles intended to enable 60fps updates will not // inadvertently filter out any scroll events. if ( + !skipThrottle && scrollEventType == ScrollEventType.SCROLL && scrollView.scrollEventThrottle >= max(17, now - scrollView.lastScrollDispatchTime) ) { @@ -274,9 +286,9 @@ public object ReactScrollViewHelper { * by calculate the "would be" initial velocity with internal friction to move to the point (x, * y), then apply that to the animator. */ - @JvmStatic - public fun smoothScrollTo(scrollView: T, x: Int, y: Int) - where T : HasFlingAnimator?, T : HasScrollState?, T : HasStateWrapper?, T : ViewGroup { +@JvmStatic + public fun smoothScrollTo(scrollView: T, x: Int, y: Int) + where T : HasFlingAnimator?, T : HasScrollEventThrottle?, T : HasScrollState?, T : HasStateWrapper?, T : ViewGroup { if (DEBUG_MODE) { FLog.i(TAG, "smoothScrollTo[%d] x %d y %d", scrollView.id, x, y) } @@ -444,7 +456,7 @@ public object ReactScrollViewHelper { } public fun registerFlingAnimator(scrollView: T) - where T : HasFlingAnimator?, T : HasScrollState?, T : HasStateWrapper?, T : ViewGroup { + where T : HasFlingAnimator?, T : HasScrollState?, T : HasStateWrapper?, T : HasScrollEventThrottle?, T : ViewGroup { scrollView .getFlingAnimator() .addListener( @@ -459,6 +471,8 @@ public object ReactScrollViewHelper { scrollView.reactScrollViewScrollState.isFinished = true notifyUserDrivenScrollEnded(scrollView) updateFabricScrollState(scrollView) + // Dispatch an unthrottled scroll event to ensure JS state is updated after animation + emitScrollEventNoThrottle(scrollView, 0f, 0f) } override fun onAnimationCancel(animator: Animator) { diff --git a/packages/rn-tester/.maestro/flatlist-append-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-append-maintainvisible.yml new file mode 100644 index 000000000000..3f3310b58cd7 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-append-maintainvisible.yml @@ -0,0 +1,65 @@ +# Test FlatList maintainVisibleContentPosition with append (baseline) +# Appending items should NOT affect scroll offset (delta ~0) +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Append item (should NOT affect scroll offset) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at bottom" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= -5 && output.offsetAfter - output.offsetBefore <= 5} +# Multiple appends +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at bottom" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= -5 && output.offsetAfter - output.offsetBefore <= 5} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-complex-mutations-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-complex-mutations-maintainvisible.yml new file mode 100644 index 000000000000..a277e611f1de --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-complex-mutations-maintainvisible.yml @@ -0,0 +1,49 @@ +# Test FlatList maintainVisibleContentPosition — complex concurrent mutations +# Tests prepend + append + delete in sequence +appId: ${APP_ID} +--- +- launchApp +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 200 +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before mutations +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Add items at top and bottom (simulates complex mutations) +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + text: "Add 1 item at bottom" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset after mutations +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +# Delta should be ~44px (only top prepend affects anchor) +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +- tapOn: + text: "Reset" diff --git a/packages/rn-tester/.maestro/flatlist-delete-anchor-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-delete-anchor-maintainvisible.yml new file mode 100644 index 000000000000..913b5c16ae3a --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-delete-anchor-maintainvisible.yml @@ -0,0 +1,44 @@ +# Test FlatList maintainVisibleContentPosition — delete anchor item +# When the anchor item (first visible) is deleted, MVCP should select a new anchor +appId: ${APP_ID} +--- +- launchApp +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 200 (item 5 should be visible) +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before delete +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Clear the list (simulates delete of all items including anchor) +- tapOn: + text: "Clear (empty list)" +- waitForAnimationToEnd: + timeout: 2000 +# Reset to restore items +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +# Verify list is restored +- assertVisible: "0" diff --git a/packages/rn-tester/.maestro/flatlist-delete-middle-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-delete-middle-maintainvisible.yml new file mode 100644 index 000000000000..4b717591093b --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-delete-middle-maintainvisible.yml @@ -0,0 +1,45 @@ +# Test FlatList maintainVisibleContentPosition — delete from middle +# When items are deleted from the middle, MVCP should adjust scroll offset +appId: ${APP_ID} +--- +- launchApp +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 400 (item 10 should be visible) +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before delete +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Add items at top (shifts middle items up) +- tapOn: + text: "Add 3 items at top" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset after prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +# Delta should be ~132px (3 items × 44px) +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 128 && output.offsetAfter - output.offsetBefore <= 136} +- tapOn: + text: "Reset" diff --git a/packages/rn-tester/.maestro/flatlist-empty-list-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-empty-list-maintainvisible.yml new file mode 100644 index 000000000000..fc3481315fc7 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-empty-list-maintainvisible.yml @@ -0,0 +1,69 @@ +# Test empty list nil frame handling +# Issue: Empty list nil frame — when list is empty and MVCP prop is set, +# _firstVisibleView.frame on nil returns {0,0}, causing incorrect scroll +# correction. +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to item 10 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Clear the list (empty list) +- tapOn: + text: "Clear (empty list)" +- waitForAnimationToEnd: + timeout: 2000 +# Reset data (repopulate) +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +# Scroll to item 10 again and prepend — verify MVCP still works after empty +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-first-prepend-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-first-prepend-maintainvisible.yml new file mode 100644 index 000000000000..37bb2f13141e --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-first-prepend-maintainvisible.yml @@ -0,0 +1,49 @@ +# Test FlatList maintainVisibleContentPosition — first prepend only +# Single prepend with fixed-height items: delta should be ~44px (40px height + 4px margin) +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test + +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before prepend (element may be off-screen but still accessible) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Add 1 item at top +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset after prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +# Verify delta is ~44px (40px height + 4px margin) +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +# Reset +- tapOn: + text: "Reset" diff --git a/packages/rn-tester/.maestro/flatlist-horizontal-add50-reset-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-horizontal-add50-reset-maintainvisible.yml new file mode 100644 index 000000000000..54528d7a709c --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-horizontal-add50-reset-maintainvisible.yml @@ -0,0 +1,53 @@ +# Test FlatList maintainVisibleContentPosition horizontal + Add 50 + Reset +# Verifies that after horizontal mode with 50 prepended items and scroll to 500, +# reset returns offset to 0. +appId: ${APP_ID} +--- +- launchApp +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable horizontal mode +- tapOn: + text: "Horizontal: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Add 50 items at top (horizontal) +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 3000 +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Verify we're at offset ~500 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetBefore >= 480 && output.offsetBefore <= 520} +# Reset - should return to offset 0 +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 3000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-horizontal-inverted-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-horizontal-inverted-maintainvisible.yml new file mode 100644 index 000000000000..032d76072924 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-horizontal-inverted-maintainvisible.yml @@ -0,0 +1,90 @@ +# Test FlatList maintainVisibleContentPosition in horizontal + inverted mode +# Items are 200px wide + 4px margin = 204px each +# In inverted mode, prepending adds items to the right end +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable horizontal mode +- tapOn: + text: "Horizontal: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Enable inverted mode +- tapOn: + text: "Inverted: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 1 item (delta should be ~204px) +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 200 && output.offsetAfter - output.offsetBefore <= 208} +# Prepend 3 items (delta should be ~612px) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 3 items at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 600 && output.offsetAfter - output.offsetBefore <= 624} +# Prepend 3 more items (delta should be ~612px) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 3 items at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 600 && output.offsetAfter - output.offsetBefore <= 624} +# Reset +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-horizontal-inverted-recycle-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-horizontal-inverted-recycle-maintainvisible.yml new file mode 100644 index 000000000000..8df8300cfb33 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-horizontal-inverted-recycle-maintainvisible.yml @@ -0,0 +1,81 @@ +# Test FlatList maintainVisibleContentPosition with view recycling in horizontal + inverted mode +# Items are 200px wide + 4px margin = 204px each +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable both horizontal and inverted mode +- tapOn: + text: "Horizontal: OFF" +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + text: "Inverted: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Enable recycling mode (windowSize=2) +- tapOn: + text: "Recycle: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before 50-item prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 50 items (delta should be ~10200px = 50 * 204) +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 3000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 10000 && output.offsetAfter - output.offsetBefore <= 10400} +# Prepend 1 item (delta should be ~204px) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 200 && output.offsetAfter - output.offsetBefore <= 208} +# Reset +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.rawText = maestro.copiedText; output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 5000} diff --git a/packages/rn-tester/.maestro/flatlist-horizontal-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-horizontal-maintainvisible.yml new file mode 100644 index 000000000000..83b12117650f --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-horizontal-maintainvisible.yml @@ -0,0 +1,58 @@ +# Test FlatList maintainVisibleContentPosition in horizontal mode +# Horizontal: items are 200px wide, delta should be ~204px (200px + 4px margin) +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable horizontal mode +- tapOn: + text: "Horizontal: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Prepend in horizontal mode +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 202 && output.offsetAfter - output.offsetBefore <= 206} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-horizontal-recycle-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-horizontal-recycle-maintainvisible.yml new file mode 100644 index 000000000000..d5457fa33a5c --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-horizontal-recycle-maintainvisible.yml @@ -0,0 +1,77 @@ +# Test FlatList maintainVisibleContentPosition with view recycling in horizontal mode +# Items are 200px wide + 4px margin = 204px each +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable horizontal mode +- tapOn: + text: "Horizontal: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Enable recycling mode (windowSize=2) +- tapOn: + text: "Recycle: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before 50-item prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 50 items (delta should be ~10200px = 50 * 204) +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 3000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 10000 && output.offsetAfter - output.offsetBefore <= 10400} +# Prepend 1 item (delta should be ~204px) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 200 && output.offsetAfter - output.offsetBefore <= 208} +# Reset +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 5000} diff --git a/packages/rn-tester/.maestro/flatlist-inverted-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-inverted-maintainvisible.yml new file mode 100644 index 000000000000..947a5ff8aa73 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-inverted-maintainvisible.yml @@ -0,0 +1,61 @@ +# Test FlatList maintainVisibleContentPosition in inverted mode +# Delta is +44 (same as non-inverted) — frame-based delta measures actual frame shift, +# not logical order. Prepending shifts anchor view frame downward in both modes. +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +- waitForAnimationToEnd: + timeout: 2000 +# Enable inverted mode +- tapOn: + text: "Inverted: OFF" +- waitForAnimationToEnd: + timeout: 1000 +# Scroll to offset 100 (safe offset within scrollable range) +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Prepend in inverted mode (delta will be +44 since inverted not supported by Fabric MVCP) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 40 && output.offsetAfter - output.offsetBefore <= 48} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-inverted-recycle-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-inverted-recycle-maintainvisible.yml new file mode 100644 index 000000000000..b5d6d35ff97e --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-inverted-recycle-maintainvisible.yml @@ -0,0 +1,84 @@ +# Test FlatList maintainVisibleContentPosition with view recycling in inverted mode +# Items are 40px tall + 4px margin = 44px each +# With windowSize=3, only ~3 pages of items are rendered +# In inverted mode, items display in reverse order but MVCP delta behavior is the same +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable inverted mode +- tapOn: + text: "Inverted: OFF" +- waitForAnimationToEnd: + timeout: 1000 +# Enable recycling (windowSize=3) +- tapOn: + text: "Recycle: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Scroll to offset 100 (within initial 20-item range) +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before 50-item prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 50 items (delta should be ~2200px = 50 * 44) +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 3000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 2100 && output.offsetAfter - output.offsetBefore <= 2300} +# Scroll to offset 500 (now within range after 70 items) +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Prepend 1 item (delta should be ~44px) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 40 && output.offsetAfter - output.offsetBefore <= 48} +# Reset +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-maintainvisible.yml new file mode 100644 index 000000000000..719439b38d7f --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-maintainvisible.yml @@ -0,0 +1,128 @@ +# Test FlatList maintainVisibleContentPosition when items are prepended +# Verifies the fix for #25239: FlatList should preserve scroll position +# when items are prepended or inserted in the middle. +# +# Uses scroll offset delta checks to verify MVCP is working: +# - Before prepend: record offset +# - After prepend: record offset +# - Delta should equal height of prepended items (~44px per item with margin) +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +# Verify delta is ~44px (one fixed-height item with margin) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +# Test multiple rapid prepends - Add 50 items +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 2000 +# Verify delta is ~2200px (50 items with margin) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 2180 && output.offsetAfter - output.offsetBefore <= 2220} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 2180 && output.offsetAfter - output.offsetBefore <= 2220} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 2180 && output.offsetAfter - output.offsetBefore <= 2220} +- tapOn: + text: "Reset" +--- +# Test that user scroll is not interrupted during prepend +appId: ${APP_ID} +--- +- launchApp +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +- swipe: + start: 50%, 70% + end: 50%, 30% + speed: fast +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +- tapOn: + text: "Reset" diff --git a/packages/rn-tester/.maestro/flatlist-momentum-scroll-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-momentum-scroll-maintainvisible.yml new file mode 100644 index 000000000000..931f83249022 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-momentum-scroll-maintainvisible.yml @@ -0,0 +1,64 @@ +# Test FlatList maintainVisibleContentPosition — momentum scroll after prepend +# Verifies that scroll position remains stable after momentum scroll completes +# post-prepend. MVCP correction runs asynchronously in didMountItems. +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 1 item +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +# Start momentum scroll (swipe up quickly) +- swipe: + start: 50%, 70% + end: 50%, 20% + speed: fast +# Wait for momentum to fully settle +- waitForAnimationToEnd: + timeout: 5000 +# Record offset after momentum settles +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +# Verify position hasn't drifted — delta should still be ~44px +# (MVCP correction applied before momentum started, position should be stable) +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +# Reset +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-orientation-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-orientation-maintainvisible.yml new file mode 100644 index 000000000000..cf6dfd19d11e --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-orientation-maintainvisible.yml @@ -0,0 +1,79 @@ +# Test MVCP orientation change handling +# Issue 7.6: Orientation changes — Android horizontal flag is set at constructor +# time and never changes. If the ScrollView's orientation changes after the +# helper is created, MVCP continues on the wrong axis. +# +# This test verifies that MVCP survives an orientation change and continues +# to work correctly after the change. +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 100 (within scrollable range) +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Change orientation to landscape +- setOrientation: landscape_left +- waitForAnimationToEnd: + timeout: 3000 +# Prepend — MVCP should still work after orientation change +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +# Verify delta is ~44px (40px item + 4px margin) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +# Change back to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Prepend again — verify MVCP works in portrait too +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-prepend-delete-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-prepend-delete-maintainvisible.yml new file mode 100644 index 000000000000..afe242801c8d --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-prepend-delete-maintainvisible.yml @@ -0,0 +1,66 @@ +# Test FlatList maintainVisibleContentPosition — prepend with delete in same batch +# Verifies MVCP when items are prepended and deleted in the same setData call. +# Net effect: -2 items (prepend 1, remove 3 from bottom). +# The native side is unaffected by bottom deletes since MVCP only looks at +# the first visible view, but re-ordering edge case is untested. +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 100 +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before prepend+delete +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 1 item and remove 3 from bottom in same batch (net -2 items) +- tapOn: + text: "Add + Remove (net -2)" +# Wait for layout + MVCP correction to complete +- waitForAnimationToEnd: + timeout: 3000 +# Read offset multiple times to ensure we get the settled value +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter2 = parseInt(maestro.copiedText.split(":")[1].trim())} +# Use the later value if it's different (indicates settling) +- evalScript: ${output.offsetAfter = output.offsetAfter2 || output.offsetAfter} +# Delta should be approximately 44px (one prepended item with margin) +# Bottom deletes don't affect MVCP since anchor is at top of viewport +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 40 && output.offsetAfter - output.offsetBefore <= 50} +# Reset +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-pull-to-refresh-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-pull-to-refresh-maintainvisible.yml new file mode 100644 index 000000000000..489a0ee025f7 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-pull-to-refresh-maintainvisible.yml @@ -0,0 +1,52 @@ +# Test FlatList maintainVisibleContentPosition — pull-to-refresh pattern +# Simulates scroll-to-top then prepend (like pull-to-refresh with new items) +appId: ${APP_ID} +--- +- launchApp +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 200 +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Scroll to top (simulates pull-to-refresh pull) +- swipe: + start: 50%, 70% + end: 50%, 30% + speed: fast +- waitForAnimationToEnd: + timeout: 2000 +# Add items at top (simulates refresh with new data) +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset after prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +# Delta should be ~44px (one item with margin) +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +- tapOn: + text: "Reset" diff --git a/packages/rn-tester/.maestro/flatlist-rapid-prepends-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-rapid-prepends-maintainvisible.yml new file mode 100644 index 000000000000..0a806a74bad2 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-rapid-prepends-maintainvisible.yml @@ -0,0 +1,64 @@ +# Test FlatList maintainVisibleContentPosition — rapid consecutive prepends without waits +# Exercises the throttle edge case where pendingScrollUpdateCount may not decrement +# promptly, blocking render window updates. All prepends fired without waiting. +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before rapid prepends +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Fire 5 rapid prepends without any waits between +- tapOn: + text: "Add 50 items at top" +- tapOn: + text: "Add 50 items at top" +- tapOn: + text: "Add 50 items at top" +- tapOn: + text: "Add 50 items at top" +- tapOn: + text: "Add 50 items at top" +# Wait for everything to settle (all mounts + MVCP corrections + layout) +- waitForAnimationToEnd: + timeout: 10000 +# Record offset after everything settles +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +# Total delta should be approximately 50*5*44 = 11000px +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 10800 && output.offsetAfter - output.offsetBefore <= 11200} +# Reset +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-recycle-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-recycle-maintainvisible.yml new file mode 100644 index 000000000000..0c49fe79f900 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-recycle-maintainvisible.yml @@ -0,0 +1,73 @@ +# Test FlatList maintainVisibleContentPosition with view recycling +# Items are 40px tall + 4px margin = 44px each +# With windowSize=3, only ~3 pages of items are rendered +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable recycling (windowSize=3) +- tapOn: + text: "Recycle: OFF" +- waitForAnimationToEnd: + timeout: 3000 +# Scroll to offset 500 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before 50-item prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 50 items (delta should be ~2200px = 50 * 44) +- tapOn: + text: "Add 50 items at top" +- waitForAnimationToEnd: + timeout: 3000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 2100 && output.offsetAfter - output.offsetBefore <= 2300} +# Prepend 1 item (delta should be ~44px) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 40 && output.offsetAfter - output.offsetBefore <= 48} +# Reset +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-scrolltooffset-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-scrolltooffset-maintainvisible.yml new file mode 100644 index 000000000000..deb86e0b057c --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-scrolltooffset-maintainvisible.yml @@ -0,0 +1,78 @@ +# Test scrollToOffset additive conflict during MVCP +# Issue: scrollToOffset additive conflict — programmatic scrollToOffset +# during MVCP active causes additive correction (MVCP delta added on top +# of the scrollTo target). +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Scroll to offset 100 (within scrollable range for 20 items) +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Record offset before prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +# Prepend 1 item — offset should increase by ~44px +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +# Now call scrollToOffset(100) — should land at ~100, NOT ~100 + MVCP delta +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Verify scroll position is approximately 100 (not 100 + 44 = 144) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.scrollToOffsetResult = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.scrollToOffsetResult >= 90 && output.scrollToOffsetResult <= 110} +# Prepend again after scrollToOffset — verify MVCP still works correctly +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-throttle-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-throttle-maintainvisible.yml new file mode 100644 index 000000000000..273469102746 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-throttle-maintainvisible.yml @@ -0,0 +1,58 @@ +# Test FlatList maintainVisibleContentPosition with scroll event throttle +# Throttle affects timing but not final delta: should be ~44px +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable throttle (500ms) +- tapOn: + text: "Throttle: 16ms" +- waitForAnimationToEnd: + timeout: 2000 +# Scroll to offset 100 +- tapOn: + text: "ScrollToOffset 100" +- waitForAnimationToEnd: + timeout: 2000 +# Prepend with throttle enabled +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 3000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 42 && output.offsetAfter - output.offsetBefore <= 46} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-variable-height-first-prepend-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-variable-height-first-prepend-maintainvisible.yml new file mode 100644 index 000000000000..d4fc01cc8b8b --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-variable-height-first-prepend-maintainvisible.yml @@ -0,0 +1,81 @@ +# Test variable-height items with first prepend +# Single prepend with variable height: delta should be 28-112px +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable variable height mode +- tapOn: + text: "Height: Fixed" +- waitForAnimationToEnd: + timeout: 2000 +# Scroll to item 10 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Single prepend with variable height — test first prepend specifically +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 28 && output.offsetAfter - output.offsetBefore <= 112} +# Multiple prepends with variable heights +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 28 && output.offsetAfter - output.offsetBefore <= 112} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 28 && output.offsetAfter - output.offsetBefore <= 112} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/flatlist-variable-height-maintainvisible.yml b/packages/rn-tester/.maestro/flatlist-variable-height-maintainvisible.yml new file mode 100644 index 000000000000..7137afc27057 --- /dev/null +++ b/packages/rn-tester/.maestro/flatlist-variable-height-maintainvisible.yml @@ -0,0 +1,81 @@ +# Test FlatList maintainVisibleContentPosition with variable-height items +# Delta should be between 28-112px (random height from [30,50,70,90,110]) +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "FlatList" + direction: DOWN + speed: 40 +- tapOn: + id: "FlatList" +- scrollUntilVisible: + element: + id: "maintainVisibleContentPosition" + direction: DOWN + speed: 40 +- tapOn: + id: "maintainVisibleContentPosition" +# Enable variable height mode +- tapOn: + text: "Height: Fixed" +- waitForAnimationToEnd: + timeout: 2000 +# Scroll to item 10 +- tapOn: + text: "ScrollToOffset 500" +- waitForAnimationToEnd: + timeout: 2000 +# Single prepend with variable height +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 28 && output.offsetAfter - output.offsetBefore <= 112} +# Multiple prepends with variable heights +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 28 && output.offsetAfter - output.offsetBefore <= 112} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 28 && output.offsetAfter - output.offsetBefore <= 112} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml new file mode 100644 index 000000000000..512a55fd7b5b --- /dev/null +++ b/packages/rn-tester/.maestro/scrollview-minindex-maintainvisible.yml @@ -0,0 +1,67 @@ +# Test ScrollView maintainVisibleContentPosition with minIndexForVisible +# Delta should be ~40px per prepend +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "ScrollViewMaintainVisibleContentPositionExample" + direction: DOWN + speed: 80 +- tapOn: + id: "ScrollViewMaintainVisibleContentPositionExample" +- waitForAnimationToEnd: + timeout: 2000 +# Set minIndexForVisible to 0 +- tapOn: + text: "minIndex: 0" +- waitForAnimationToEnd: + timeout: 1000 +# Scroll down in ScrollView to reach item 10 area +- swipe: + start: 50%, 70% + end: 50%, 30% + speed: fast +- waitForAnimationToEnd: + timeout: 2000 +# Prepend +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 38 && output.offsetAfter - output.offsetBefore <= 44} +# Multiple prepends +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 38 && output.offsetAfter - output.offsetBefore <= 44} +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} diff --git a/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml new file mode 100644 index 000000000000..d49ab8d79648 --- /dev/null +++ b/packages/rn-tester/.maestro/scrollview-threshold-maintainvisible.yml @@ -0,0 +1,72 @@ +# Test ScrollView maintainVisibleContentPosition with autoscrollToTopThreshold +# Delta should be ~40px per prepend +appId: ${APP_ID} +--- +- launchApp +# Change to portrait +- setOrientation: portrait +- waitForAnimationToEnd: + timeout: 3000 +# Find test +- assertVisible: "Components" +- scrollUntilVisible: + element: + id: "ScrollViewMaintainVisibleContentPositionExample" + direction: DOWN + speed: 80 +- tapOn: + id: "ScrollViewMaintainVisibleContentPositionExample" +- waitForAnimationToEnd: + timeout: 2000 +# Disable threshold +- tapOn: + text: "Threshold: OFF" +- waitForAnimationToEnd: + timeout: 1000 +# Scroll down in ScrollView to reach item 10 area +- swipe: + start: 50%, 70% + end: 50%, 30% + speed: fast +- waitForAnimationToEnd: + timeout: 2000 +# Prepend without threshold +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 3000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter - output.offsetBefore >= 38 && output.offsetAfter - output.offsetBefore <= 44} +# Enable threshold +- tapOn: + text: "Threshold: 100" +- waitForAnimationToEnd: + timeout: 1000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Reset" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter >= 0 && output.offsetAfter <= 50} +# Prepend with threshold enabled (offset ~0 <= threshold 100, should scroll to top) +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetBefore = parseInt(maestro.copiedText.split(":")[1].trim())} +- tapOn: + text: "Add 1 item at top" +- waitForAnimationToEnd: + timeout: 2000 +- copyTextFrom: + id: "scroll-offset-display" +- evalScript: ${output.offsetAfter = parseInt(maestro.copiedText.split(":")[1].trim())} +- assertTrue: ${output.offsetAfter <= 10} diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-maintainVisibleContentPosition.js b/packages/rn-tester/js/examples/FlatList/FlatList-maintainVisibleContentPosition.js index f46ee0cd8ea6..947d92bb8ee0 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-maintainVisibleContentPosition.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-maintainVisibleContentPosition.js @@ -12,88 +12,269 @@ import type {ListRenderItemInfo} from '../../../../virtualized-lists/Lists/Virtu import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import * as React from 'react'; -import {useCallback, useState} from 'react'; -import {Button, FlatList, StyleSheet, Text, View} from 'react-native'; +import {useCallback, useRef, useState} from 'react'; +import {Button, FlatList, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; -const DATA = Array.from({length: 20}, (_, i) => ({ +const HEIGHTS = [30, 50, 70, 90, 110]; + +const INITIAL_DATA = Array.from({length: 20}, (_, i) => ({ id: i.toString(), + height: HEIGHTS[i % HEIGHTS.length], })); -const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 0}; +type MaintainVisibleConfig = { + minIndexForVisible: number; + autoscrollToTopThreshold?: number | null; +}; -export component FlatList_maintainVisibleContentPosition() { - const [height, setHeight] = useState(200); - const [isItemResponsive, setIsItemResponsive] = useState(true); +function createConfig( + minIndexForVisible: number, + autoscrollToTopThreshold?: number | null, +): MaintainVisibleConfig { + const config: MaintainVisibleConfig = {minIndexForVisible}; + if (autoscrollToTopThreshold != null) { + config.autoscrollToTopThreshold = autoscrollToTopThreshold; + } + return config; +} - const changeHeight = useCallback(() => { - setHeight(prevHeight => (prevHeight === 200 ? 400 : 200)); - }, []); +export component FlatList_maintainVisibleContentPosition() { + const [data, setData] = useState(INITIAL_DATA); + const [horizontal, setHorizontal] = useState(false); + const [inverted, setInverted] = useState(false); + const [minIndexForVisible, setMinIndexForVisible] = useState(0); + const [autoscrollToTopThreshold, setAutoscrollToTopThreshold] = + useState(null); + const [windowSize, setWindowSize] = useState(51); + const [scrollEventThrottle, setScrollEventThrottle] = useState(16); + const [variableHeight, setVariableHeight] = useState(false); + const [scrollOffset, setScrollOffset] = useState(0); + const flatListRef = useRef(null); + const scrollOffsetRef = useRef(0); - const toggleResponsiveness = useCallback(() => { - setIsItemResponsive(prevIsItemResponsive => !prevIsItemResponsive); - }, []); + const config = createConfig(minIndexForVisible, autoscrollToTopThreshold); const renderItem = useCallback( - ({item}: ListRenderItemInfo<{id: string}>) => ( + ({item}: ListRenderItemInfo<{id: string; height?: number}>) => ( - - {item.id} - + {item.id} ), - [height, isItemResponsive], + [horizontal, variableHeight], + ); + + const addItemAtTop = useCallback(() => { + setData(prev => [{ id: `added-${prev.length}` }, ...prev]); + }, []); + + const addItemAtBottom = useCallback(() => { + setData(prev => [...prev, { id: `added-${prev.length}` }]); + }, []); + + const addItemAtTopMultiple = useCallback(() => { + setData(prev => [ + { id: `added-${prev.length}` }, + { id: `added-${prev.length + 1}` }, + { id: `added-${prev.length + 2}` }, + ...prev, + ]); + }, []); + + const addItemAtTopFifty = useCallback(() => { + setData(prev => { + const newItems = Array.from({ length: 50 }, (_, i) => ({ + id: `added-${prev.length + i}`, + })); + return [...newItems, ...prev]; + }); + }, []); + + const resetData = useCallback(() => { + setData(INITIAL_DATA); + flatListRef.current?.scrollToOffset({ offset: 0, animated: false }); + }, []); + + const scrollToOffset500 = useCallback(() => { + flatListRef.current?.scrollToOffset({ offset: 500, animated: true }); + }, []); + + const scrollToOffset100 = useCallback(() => { + flatListRef.current?.scrollToOffset({ offset: 100, animated: true }); + }, []); + + const clearData = useCallback(() => { + setData([]); + flatListRef.current?.scrollToOffset({ offset: 0, animated: false }); + }, []); + + const addItemAtTopAndRemoveBottom = useCallback(() => { + setData(prev => { + const newItems = [{ id: `added-${prev.length}` }]; + const remaining = prev.slice(0, Math.max(0, prev.length - 3)); + return [...newItems, ...remaining]; + }); + }, []); + + const onScroll = useCallback( + (e) => { + const offset = horizontal + ? e.nativeEvent.contentOffset.x + : e.nativeEvent.contentOffset.y; + setScrollOffset(offset); + }, + [horizontal], ); return ( item.id} renderItem={renderItem} - showsVerticalScrollIndicator={false} - snapToAlignment="center" - style={{height}} + horizontal={horizontal} + inverted={inverted} + windowSize={windowSize} + scrollEventThrottle={scrollEventThrottle} + onScroll={onScroll} + style={horizontal ? styles.listHorizontal : styles.list} /> - -