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}
/>
-
-
-
+
+ offset:{Math.round(scrollOffset)}
+
+
+ Add 1 item at top
+
+
+ Add 1 item at bottom
+
+
+
+
+ Add 3 items at top
+
+
+ Add 50 items at top
+
+
+
+
+ Add + Remove (net -2)
+
+
+
+
+ setHorizontal(h => !h)}>{horizontal ? 'Horizontal: ON' : 'Horizontal: OFF'}
+
+
+ setInverted(i => !i)}>{inverted ? 'Inverted: ON' : 'Inverted: OFF'}
+
+
+
+
+ setWindowSize(windowSize === 51 ? 3 : 51)}>{windowSize === 51 ? 'Recycle: OFF' : 'Recycle: ON'}
+
+
+ setVariableHeight(v => !v)}>{variableHeight ? 'Height: Variable' : 'Height: Fixed'}
+
+
+
+
+ setAutoscrollToTopThreshold(autoscrollToTopThreshold === 100 ? null : 100)}>{autoscrollToTopThreshold === 100 ? 'Threshold: 100' : 'Threshold: OFF'}
+
+
+ setScrollEventThrottle(scrollEventThrottle === 16 ? 500 : 16)}>{scrollEventThrottle === 16 ? 'Throttle: 16ms' : 'Throttle: 500ms'}
+
+
+
+
+ ScrollToOffset 100
+
+
+ ScrollToOffset 500
+
+
+
+
+ Clear (empty list)
+
+
+ Reset
+
+
);
}
const styles = StyleSheet.create({
- item: {
- alignItems: 'center',
- backgroundColor: '#4CAF50',
- borderRadius: 16,
+ root: {
flex: 1,
- justifyContent: 'center',
+ padding: 16,
},
- itemText: {
- color: '#fff',
- fontSize: 24,
+ list: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#ccc',
+ maxHeight: 400,
},
- root: {
- gap: 16,
- paddingHorizontal: 16,
+ listHorizontal: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#ccc',
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginVertical: 2,
+ },
+ smallButtonRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginVertical: 1,
+ },
+ smallButtonText: {
+ fontSize: 10,
+ paddingVertical: 2,
+ paddingHorizontal: 4,
+ textAlign: 'center',
+ },
+ smallButtonContainer: {
+ flex: 1,
+ marginHorizontal: 2,
+ },
+ info: {
+ marginTop: 4,
+ fontSize: 10,
+ color: '#666',
+ },
+ controlsContainer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ backgroundColor: '#fff',
+ padding: 8,
+ borderTopWidth: 1,
+ borderTopColor: '#ccc',
},
});
export default {
title: 'maintainVisibleContentPosition',
name: 'maintainVisibleContentPosition',
- description: 'Test maintainVisibleContentPosition prop on FlatList',
+ description:
+ 'Test maintainVisibleContentPosition prop on FlatList when items are prepended',
render: () => ,
} as RNTesterModuleExample;
diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewMaintainVisibleContentPositionExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewMaintainVisibleContentPositionExample.js
new file mode 100644
index 000000000000..2f94203a4973
--- /dev/null
+++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewMaintainVisibleContentPositionExample.js
@@ -0,0 +1,159 @@
+/**
+ * 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-local
+ * @format
+ */
+
+import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
+
+import * as React from 'react';
+import {useCallback, useRef, useState} from 'react';
+import {Button, ScrollView, StyleSheet, Text, View} from 'react-native';
+
+type MaintainVisibleConfig = {
+ minIndexForVisible: number;
+ autoscrollToTopThreshold?: number | null;
+};
+
+function createConfig(
+ minIndexForVisible: number,
+ autoscrollToTopThreshold?: number | null,
+): MaintainVisibleConfig {
+ const config: MaintainVisibleConfig = {minIndexForVisible};
+ if (autoscrollToTopThreshold != null) {
+ config.autoscrollToTopThreshold = autoscrollToTopThreshold;
+ }
+ return config;
+}
+
+function ScrollView_maintainVisibleContentPosition(): React.Node {
+ const [items, setItems] = useState(
+ Array.from({length: 20}, (_, i) => ({id: i.toString()})),
+ );
+ const [minIndexForVisible, setMinIndexForVisible] = useState(0);
+ const [autoscrollToTopThreshold, setAutoscrollToTopThreshold] =
+ useState(null);
+ const [scrollOffset, setScrollOffset] = useState(0);
+ const scrollViewRef = useRef(null);
+
+ const config = createConfig(minIndexForVisible, autoscrollToTopThreshold);
+
+ const onScroll = useCallback(
+ (e) => {
+ setScrollOffset(e.nativeEvent.contentOffset.y);
+ },
+ [],
+ );
+
+ const addItemAtTop = useCallback(() => {
+ setItems(prev => [{ id: `new-${Date.now()}` }, ...prev]);
+ }, []);
+
+ const resetItems = useCallback(() => {
+ setItems(
+ Array.from({length: 20}, (_, i) => ({id: i.toString()})),
+ );
+ scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: false });
+ }, []);
+
+ return (
+
+
+ {items.map(item => (
+
+ {item.id}
+
+ ))}
+
+
+ offset:{Math.round(scrollOffset)}
+
+
+
+
+
+
+
+ setAutoscrollToTopThreshold(null)}
+ title="Threshold: OFF"
+ />
+ setAutoscrollToTopThreshold(100)}
+ title="Threshold: 100"
+ />
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ padding: 16,
+ },
+ scrollView: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#ccc',
+ maxHeight: 400,
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginVertical: 4,
+ },
+ info: {
+ marginTop: 8,
+ fontSize: 12,
+ color: '#666',
+ },
+ controlsContainer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ backgroundColor: '#fff',
+ padding: 16,
+ borderTopWidth: 1,
+ borderTopColor: '#ccc',
+ },
+});
+
+exports.title = 'ScrollViewMaintainVisibleContentPositionExample';
+exports.category = 'Basic';
+exports.description =
+ 'Test maintainVisibleContentPosition prop on ScrollView when items are prepended';
+
+exports.examples = [
+ {
+ title: 'maintainVisibleContentPosition',
+ render: () => ,
+ },
+] as Array;
diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js
index e3c5005ea002..dd9069968353 100644
--- a/packages/rn-tester/js/utils/RNTesterList.android.js
+++ b/packages/rn-tester/js/utils/RNTesterList.android.js
@@ -86,6 +86,11 @@ const Components: Array = [
category: 'Basic',
module: require('../examples/ScrollView/ScrollViewExample'),
},
+ {
+ key: 'ScrollViewMaintainVisibleContentPositionExample',
+ category: 'Basic',
+ module: require('../examples/ScrollView/ScrollViewMaintainVisibleContentPositionExample'),
+ },
{
key: 'ScrollViewSimpleExample',
category: 'Basic',
diff --git a/packages/rn-tester/js/utils/RNTesterList.ios.js b/packages/rn-tester/js/utils/RNTesterList.ios.js
index e7ed0a40d775..7fbcb8857b39 100644
--- a/packages/rn-tester/js/utils/RNTesterList.ios.js
+++ b/packages/rn-tester/js/utils/RNTesterList.ios.js
@@ -84,6 +84,11 @@ const Components: Array = [
module: require('../examples/ScrollView/ScrollViewExample'),
category: 'Basic',
},
+ {
+ key: 'ScrollViewMaintainVisibleContentPositionExample',
+ module: require('../examples/ScrollView/ScrollViewMaintainVisibleContentPositionExample'),
+ category: 'Basic',
+ },
{
key: 'ScrollViewAnimatedExample',
module: require('../examples/ScrollView/ScrollViewAnimatedExample'),
diff --git a/packages/virtualized-lists/Lists/ListMetricsAggregator.js b/packages/virtualized-lists/Lists/ListMetricsAggregator.js
index 0fa5b419f5ad..6eeb9d904fde 100644
--- a/packages/virtualized-lists/Lists/ListMetricsAggregator.js
+++ b/packages/virtualized-lists/Lists/ListMetricsAggregator.js
@@ -103,8 +103,10 @@ export default class ListMetricsAggregator {
this._measuredCellsCount += 1;
}
- this._averageCellLength =
- this._measuredCellsLength / this._measuredCellsCount;
+ if (this._measuredCellsCount > 0) {
+ this._averageCellLength =
+ this._measuredCellsLength / this._measuredCellsCount;
+ }
this._cellMetrics.set(cellKey, next);
this._highestMeasuredCellIndex = Math.max(
this._highestMeasuredCellIndex,
@@ -308,6 +310,7 @@ export default class ListMetricsAggregator {
}
if (orientation.horizontal !== this._orientation.horizontal) {
+ this._cellMetrics.clear();
this._averageCellLength = 0;
this._highestMeasuredCellIndex = 0;
this._measuredCellsLength = 0;
diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
index 6fe771ea183a..b0f13e8dcc69 100644
--- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
+++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
@@ -2532,6 +2532,281 @@ it('handles maintainVisibleContentPosition when anchor moves before minIndexForV
expect(component).toMatchSnapshot();
});
+it('handles multiple rapid prepends with maintainVisibleContentPosition', async () => {
+ const items = generateItems(20);
+ const ITEM_HEIGHT = 10;
+
+ let component;
+ await act(() => {
+ component = create(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateLayout(component, {
+ viewport: {width: 10, height: 50},
+ content: {width: 10, height: items.length * ITEM_HEIGHT},
+ });
+ simulateScroll(component, {x: 0, y: 50});
+ performAllBatches();
+ });
+
+ // First prepend: add 5 items at the start
+ const afterFirstPrepend = [
+ ...generateItems(5, items.length),
+ ...items,
+ ];
+ await act(() => {
+ component.update(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateContentLayout(component, {
+ width: 10,
+ height: afterFirstPrepend.length * ITEM_HEIGHT,
+ });
+ simulateScroll(component, {x: 0, y: 50 + 5 * ITEM_HEIGHT});
+ performAllBatches();
+ });
+
+ expect(component).toMatchSnapshot();
+
+ // Second prepend: add 3 more items at the start (rapid succession)
+ const afterSecondPrepend = [
+ ...generateItems(3, afterFirstPrepend.length),
+ ...afterFirstPrepend,
+ ];
+ await act(() => {
+ component.update(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateContentLayout(component, {
+ width: 10,
+ height: afterSecondPrepend.length * ITEM_HEIGHT,
+ });
+ simulateScroll(component, {x: 0, y: 50 + 8 * ITEM_HEIGHT});
+ performAllBatches();
+ });
+
+ expect(component).toMatchSnapshot();
+});
+
+it('maintainVisibleContentPosition delta stays bounded across consecutive updates', async () => {
+ const ITEM_HEIGHT = 10;
+ const VIEWPORT_HEIGHT = 50;
+
+ let component;
+ let currentItems = generateItems(20);
+
+ await act(() => {
+ component = create(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateLayout(component, {
+ viewport: {width: 10, height: VIEWPORT_HEIGHT},
+ content: {width: 10, height: currentItems.length * ITEM_HEIGHT},
+ });
+ simulateScroll(component, {x: 0, y: 50});
+ performAllBatches();
+ });
+
+ const initialScrollY = 50;
+ const numPrepends = 5;
+ const itemsPerPrepend = 3;
+
+ const anchorBeforePrepend =
+ component.getInstance().state.cellsAroundViewport.first;
+
+ for (let i = 0; i < numPrepends; i++) {
+ currentItems = [
+ ...generateItems(itemsPerPrepend, currentItems.length),
+ ...currentItems,
+ ];
+
+ await act(() => {
+ component.update(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateContentLayout(component, {
+ width: 10,
+ height: currentItems.length * ITEM_HEIGHT,
+ });
+ simulateScroll(component, {
+ x: 0,
+ y: initialScrollY + (i + 1) * itemsPerPrepend * ITEM_HEIGHT,
+ });
+ performAllBatches();
+ });
+ }
+
+ const instance = component.getInstance();
+ const anchorAfterPrepend = instance.state.cellsAroundViewport.first;
+ expect(anchorAfterPrepend).toBeGreaterThanOrEqual(anchorBeforePrepend);
+ expect(anchorAfterPrepend).toBeLessThanOrEqual(
+ anchorBeforePrepend + numPrepends * itemsPerPrepend,
+ );
+});
+
+it('maintainVisibleContentPosition with minIndexForVisible > 0 handles rapid prepends', async () => {
+ const items = generateItems(20);
+ const ITEM_HEIGHT = 10;
+
+ let component;
+ await act(() => {
+ component = create(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateLayout(component, {
+ viewport: {width: 10, height: 50},
+ content: {width: 10, height: items.length * ITEM_HEIGHT},
+ });
+ simulateScroll(component, {x: 0, y: 50});
+ performAllBatches();
+ });
+
+ const anchorBeforePrepend =
+ component.getInstance().state.cellsAroundViewport.first;
+
+ // Prepend 10 items — the anchor (item 5) should still be visible
+ const afterPrepend = [...generateItems(10, items.length), ...items];
+ await act(() => {
+ component.update(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateContentLayout(component, {
+ width: 10,
+ height: afterPrepend.length * ITEM_HEIGHT,
+ });
+ simulateScroll(component, {x: 0, y: 50 + 10 * ITEM_HEIGHT});
+ performAllBatches();
+ });
+
+ const anchorAfterPrepend =
+ component.getInstance().state.cellsAroundViewport.first;
+ expect(anchorAfterPrepend).toBeGreaterThanOrEqual(anchorBeforePrepend);
+ expect(anchorAfterPrepend).toBeLessThanOrEqual(anchorBeforePrepend + 10);
+});
+
+it('maintainVisibleContentPosition with inverted VirtualizedList handles prepends', async () => {
+ const items = generateItems(20);
+ const ITEM_HEIGHT = 10;
+
+ let component;
+ await act(() => {
+ component = create(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateLayout(component, {
+ viewport: {width: 10, height: 50},
+ content: {width: 10, height: items.length * ITEM_HEIGHT},
+ });
+ simulateScroll(component, {x: 0, y: 50});
+ performAllBatches();
+ });
+
+ const anchorBeforePrepend =
+ component.getInstance().state.cellsAroundViewport.first;
+
+ // Prepend 10 items — in inverted mode, items are prepended to the visual top
+ const afterPrepend = [...generateItems(10, items.length), ...items];
+ await act(() => {
+ component.update(
+ ,
+ );
+ });
+
+ await act(() => {
+ simulateContentLayout(component, {
+ width: 10,
+ height: afterPrepend.length * ITEM_HEIGHT,
+ });
+ simulateScroll(component, {x: 0, y: 50 + 10 * ITEM_HEIGHT});
+ performAllBatches();
+ });
+
+ const anchorAfterPrepend =
+ component.getInstance().state.cellsAroundViewport.first;
+ expect(anchorAfterPrepend).toBeGreaterThanOrEqual(anchorBeforePrepend);
+ expect(anchorAfterPrepend).toBeLessThanOrEqual(anchorBeforePrepend + 10);
+});
+
function generateItems(count, startKey = 0) {
return Array(count)
.fill()
diff --git a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap
index 80eb159051de..81d4193164ff 100644
--- a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap
+++ b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap
@@ -3925,6 +3925,369 @@ exports[`handles maintainVisibleContentPosition when anchor moves before minInde
`;
+exports[`handles multiple rapid prepends with maintainVisibleContentPosition 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`handles multiple rapid prepends with maintainVisibleContentPosition 2`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
exports[`initially renders nothing when initialNumToRender is 0 1`] = `
0`)
+2. MVCP adjustment tracking (incremented on prepend detection, decremented on scroll events)
+
+**Detection flow (in `getDerivedStateFromProps`):**
+```js
+// When maintainVisibleContentPosition != null:
+if (firstVisibleItemKey changed between renders) {
+ // Item was prepended — find where the previous anchor is now
+ newAdjustment = firstVisibleItemIndex - minIndexForVisible
+ cellsAroundViewport shifted by adjustment
+ pendingScrollUpdateCount++
+}
+```
+
+**Guard interactions:**
+- `_adjustCellsAroundViewport`: Returns early when `pendingScrollUpdateCount > 0`, preventing render window updates during MVCP corrections
+- `_maybeCallOnEdgeReached`: Suppresses edge callbacks while `pendingScrollUpdateCount > 0`
+
+#### 3.1.2 ScrollView (`packages/react-native/Libraries/Components/ScrollView/ScrollView.js`)
+
+**Responsibilities:**
+- Passes `maintainVisibleContentPosition` prop through to native component
+- Sets `collapsableChildren = true` when MVCP is active, preventing React from collapsing/merging child views — critical for stable native view references
+
+**Prop type:**
+```js
+maintainVisibleContentPosition?: ?{
+ minIndexForVisible: number,
+ autoscrollToTopThreshold?: ?number,
+}
+```
+
+#### 3.1.3 ListMetricsAggregator (`packages/virtualized-lists/Lists/ListMetricsAggregator.js`)
+
+**Responsibilities:**
+- Tracks cell layout metrics for approximate sizing
+- Clears metrics on orientation change (prevents stale metric corruption)
+- Guards against divide-by-zero in `_averageCellLength` computation
+
+**Key state:**
+- `_cellMetrics: Map` — per-cell layout info
+- `_measuredCellsCount: number` — count of measured cells
+- `_averageCellLength: number` — computed average, guarded by `if (count > 0)`
+
+### 3.2 iOS Fabric Layer
+
+#### 3.2.1 RCTScrollViewComponentView (`RCTScrollViewComponentView.mm`)
+
+**Mounting transaction callbacks:**
+- `mountingTransactionWillMount:` — triggers `_prepareForMaintainVisibleScrollPosition`
+- `mountingTransactionDidMount:` — triggers `_remountChildren` then `_adjustForMaintainVisibleContentPosition`
+
+**Core methods:**
+- `_prepareForMaintainVisibleScrollPosition` — recomputes anchor before mount; scans subviews to find first visible view
+- `_adjustForMaintainVisibleContentPosition` — computes delta, applies correction
+
+**State variables:**
+
+| Variable | Type | Purpose |
+|----------|------|---------|
+| `_prevFirstVisibleFrame` | `CGRect` | Captured frame of anchor before mount |
+| `_firstVisibleView` | `__weak UIView *` | Reference to current first visible subview |
+| `_firstVisibleViewTag` | `NSInteger` | Tag for recycle detection |
+| `_avoidAdjustmentForMaintainVisibleContentPosition` | `BOOL` | Skip gate for immediate update mode |
+
+**Tag comparison safeguard:**
+```objc
+// 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; // View was recycled - abort correction
+}
+```
+**Status:** Always active. `RCTComponentViewRegistry` assigns tags during dequeue (`componentViewDescriptor.view.tag = tag`) and resets to 0 during enqueue (`componentViewDescriptor.view.tag = 0`). When items are removed and re-added, recycled UIViews get new tags based on their position. The view at position 0 may have a different tag than before, so the check must always run.
+
+#### 3.2.2 RCTComponentViewRegistry (`RCTComponentViewRegistry.mm`)
+
+**Recycle pool mechanics:**
+- Pool size: 1024 views per component type
+- **Enqueue:** Delete mutations -> `prepareForRecycle()` -> push to pool
+- **Dequeue:** Create mutations -> pop from pool -> set new tag -> register
+- **Memory pressure:** Clears entire pool on `didReceiveMemoryWarning`
+
+### 3.3 Android Layer
+
+#### 3.3.1 MaintainVisibleScrollPositionHelper (`MaintainVisibleScrollPositionHelper.kt`)
+
+**Class signature:**
+```kotlin
+internal class MaintainVisibleScrollPositionHelper(
+ private val scrollView: ScrollViewT,
+ private val horizontal: Boolean,
+) : UIManagerListener where ScrollViewT : HasSmoothScroll?, ScrollViewT : ViewGroup?
+```
+
+**State variables:**
+
+| Variable | Type | Purpose |
+|----------|------|---------|
+| `config` | `Config?` | MVCP configuration |
+| `firstVisibleViewRef` | `WeakReference?` | Anchor view reference (auto-nullifies if GC'd) |
+| `prevFirstVisibleFrame` | `Rect?` | Captured frame of anchor |
+| `isListening` | `boolean` | Whether listener is active |
+
+**Lifecycle callbacks:**
+- `willDispatchViewUpdates` — calls `computeTargetView()` (pre-layout, first capture)
+- `willMountItems` — calls `computeTargetView()` (pre-layout, second capture)
+- `didMountItems` — calls `updateScrollPositionInternal()`
+
+**`computeTargetView`:**
+- Iterates from `config.minIndexForVisible` through `contentView.childCount`
+- Selects first child where `position > currentScroll` or the last child
+- Stores `WeakReference(child)` in `firstVisibleViewRef`
+- Captures `child.getHitRect(frame)` into `prevFirstVisibleFrame`
+
+**`updateScrollPositionInternal`:**
+- Retrieves cached `firstVisibleViewRef` and `prevFirstVisibleFrame` (captured by `willMountItems`)
+- Computes delta on `left` (horizontal) or `top` (vertical) coordinates
+- `scrollToPreservingMomentum()`
+- Updates `prevFirstVisibleFrame` to new frame after correction
+- Calls `emitScrollEventNoThrottle()` to ensure JS state is current
+- Early return if `firstVisibleViewRef.get()` is null (view GC'd)
+- **Threshold:** Uses `delta != 0` (vs iOS `ABS(delta) > 0.5`)
+
+#### 3.3.2 ReactScrollView (`ReactScrollView.java`)
+
+**MVCP field:**
+```java
+private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper;
+```
+
+**`setMaintainVisibleContentPosition`:**
+- `config != null && helper == null`: creates new helper with `horizontal = false`, calls `start()`
+- `config == null && helper != null`: calls `stop()`, sets helper to `null`
+- Helper exists: updates config via `setConfig()`
+
+**`horizontal` flag:** Hardcoded to `false` — `ReactScrollView` only supports vertical scrolling.
+
+**Lifecycle integration:**
+- `onAttachedToWindow`: calls `helper.start()`
+- `onDetachedFromWindow`: calls `helper.stop()`
+
+#### 3.3.3 ReactViewGroup Content Culling (`ReactViewGroup.kt`)
+
+**Culling mechanism:**
+- `allChildren` array: stores ALL children (visible + culled) for O(1) re-addition
+- `removeClippedSubviews` boolean: enables culling
+- Off-screen children: `removeViewInLayout()` — detached but kept in `allChildren`
+- On-screen children: `addViewInLayout()` — re-attached from `allChildren`
+
+**MVCP interaction:** `computeTargetView` iterates `contentView.childCount` (visible children only, not `allChildrenCount`), meaning culling affects anchor candidate selection.
+
+---
+
+## 4. Events & Lifecycle
+
+### 4.1 Mount Cycle Events
+
+Both active platforms follow the same high-level pattern:
+
+```
+1. WILL_MOUNT (before mutations):
+ - Capture anchor view's frame -> prevFirstVisibleFrame
+ - Store anchor view reference -> firstVisibleView
+
+2. MOUNT (mutations applied):
+ - New items inserted, existing items shifted
+ - Layout computed, frames updated
+
+3. DID_MOUNT (after mutations):
+ - Compute delta = (anchor's frame now) - (captured frame)
+ - Apply delta to contentOffset
+
+Anchor recomputation happens in the next cycle's WILL_MOUNT phase, not in DID_MOUNT.
+```
+
+#### 4.1.1 iOS Fabric Event Flow
+
+```
+RCTMountingManager.performTransaction:
+ |
+ +-- _observerCoordinator.notifyObserversMountingTransactionWillMount
+ | -> RCTScrollViewComponentView.mountingTransactionWillMount
+ | -> _prepareForMaintainVisibleScrollPosition
+ | -> Scan _contentView.subviews from minIndexForVisible
+ | -> Find first partially visible subview
+ | -> Store: _firstVisibleView, _firstVisibleViewTag, _prevFirstVisibleFrame
+ |
+ +-- RCTPerformMountInstructions (mutations applied)
+ | Create: dequeue from RCTComponentViewRegistry
+ | Delete: enqueue to RCTComponentViewRegistry
+ | Update: update existing views
+ |
+ +-- _observerCoordinator.notifyObserversMountingTransactionDidMount
+ | -> RCTScrollViewComponentView.mountingTransactionDidMount
+ | -> _remountChildren (no-op when enableViewCulling is true;
+ | calls updateClippedSubviewsWithClipRect when false)
+ | -> _adjustForMaintainVisibleContentPosition
+ | -> Tag comparison check (always active)
+ | -> delta = _firstVisibleView.frame - _prevFirstVisibleFrame
+ | -> Abort if ABS(delta) <= 0.5
+ | -> contentOffset += delta
+ | -> autoscrollToTopThreshold check (animate to start if near top)
+```
+
+#### 4.1.2 Android Event Flow
+
+```
+SurfaceMountingManager.onBatchComplete:
+ |
+ +-- UIManagerImplementationExecutor.notifyWillDispatchViewUpdates
+ | -> MaintainVisibleScrollPositionHelper.willDispatchViewUpdates
+ | -> computeTargetView() [pre-layout, first capture]
+ |
+ +-- UIManagerImplementationExecutor.notifyWillMountItems
+ | -> MaintainVisibleScrollPositionHelper.willMountItems
+ | -> computeTargetView() [pre-layout, second capture, overwrites first]
+ |
+ +-- View mount / layout updates
+ | Children added/removed from contentView
+ | UPDATE_LAYOUT: view.measure() + view.layout() — frames set here
+ | Culling: off-screen children removed from children (kept in allChildren)
+ |
+ +-- UIManagerImplementationExecutor.notifyDidMountItems
+ | -> MaintainVisibleScrollPositionHelper.didMountItems
+ | -> updateScrollPositionInternal()
+ | -> firstVisibleView = firstVisibleViewRef.get()
+ | -> if firstVisibleView != null:
+ | -> delta = firstVisibleView.frame - prevFirstVisibleFrame
+ | -> if delta != 0: scrollToPreservingMomentum(currentScroll + delta)
+ | -> Update prevFirstVisibleFrame to new frame
+ | -> emitScrollEventNoThrottle()
+```
+
+### 4.2 Scroll Events
+
+**JS-side scroll event handling (`_onScroll` in VirtualizedList):**
+```js
+if (this.state.pendingScrollUpdateCount > 0) {
+ this.setState({ pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1 });
+}
+```
+
+Each scroll event decrements `pendingScrollUpdateCount`. When it reaches 0, render window updates resume and edge callbacks are re-enabled.
+
+### 4.3 Observer Registration Lifecycle
+
+| Platform | Registration Trigger | Deregistration Trigger |
+|----------|---------------------|----------------------|
+| iOS Fabric | `mountingTransactionWillMount` callback (automatic via observer coordinator) | `mountingTransactionDidMount` callback |
+| Android | `setMaintainVisibleContentPosition:` config != null (creates helper, calls `start()`) | `setMaintainVisibleContentPosition:` config == null (calls `stop()`) |
+
+---
+
+## 5. Code Flows
+
+### 5.1 Normal Operation — Single Prepend
+
+```
+User scrolls to item 5 (offset = 500)
+ |
+ v
+JS renders: [X, A, B, C, D, E, F, G, H] (prepend 1 item)
+ |
+ v
+VirtualizedList detects firstVisibleItemKey changed
+ |
+ v
+JS computes adjustment = 1 (one item prepended above minIndexForVisible)
+JS increments pendingScrollUpdateCount
+JS shifts cellsAroundViewport by 1
+ |
+ v
+Native: Capture anchor (first visible view at offset 500)
+Native: _prevFirstVisibleFrame = {y: 500}
+ |
+ v
+Native: Mount mutations applied
+Native: X inserted at index 0, all items shift down by item height
+Native: Anchor now at y = 550
+ |
+ v
+Native: delta = 550 - 500 = 50
+Native: contentOffset += 50 -> offset = 550
+Native: Anchor stays at same screen position (550 - 550 = 0, top of viewport)
+ |
+ v
+Next cycle's WILL_MOUNT: Recompute anchor for next correction
+JS: Scroll event fires -> pendingScrollUpdateCount decrements
+JS: Render window updates resume
+```
+
+### 5.2 Rapid Consecutive Prepends
+
+```
+First prepend:
+ _prepare (pre-mount): capture A at y=0 [stale frame, pre-layout]
+ mount + layout: A moves to y=100 [frames updated]
+ _adjust: delta = 100 - 0 = 100, offset = 100
+
+Second prepend:
+ _prepare (pre-mount): capture A at y=100 [stale frame, but from correct layout pass]
+ mount + layout: A moves to y=200
+ _adjust: delta = 200 - 100 = 100, offset = 200
+
+Why this works:
+ _prepare runs BEFORE layout blocks fire, so it always captures
+ a stale frame from the previous layout pass. The delta is computed
+ as (post-layout frame) - (pre-layout frame), which correctly
+ represents the frame shift caused by the mount.
+
+ The _prepare capture for batch N+1 uses the frame that was captured
+ by _prepare in batch N (which was stale from N-1's layout). But
+ since the delta is computed from the ACTUAL post-layout frame
+ minus that captured frame, the delta is still correct.
+
+Additional role: anchor re-selection
+ If the correction pushed A off-screen, the next _prepare finds the new
+ first visible view (e.g., B). The next _prepare then anchors to B instead of
+ the now-invisible A. Without recomputation, stale frame data from the wrong view would
+ be used for the next correction.
+```
+
+### 5.3 View Recycling — iOS Fabric
+
+```
+Initial state: [A, B, C, D, E], anchor = B at y=100
+ _firstVisibleView = vB, _firstVisibleViewTag = 101
+ _prevFirstVisibleFrame = {y: 100}
+
+User removes A, adds X at top: [X, B, C, D, E]
+ Differ generates: Delete A, Create X, Update B,C,D,E
+
+Delete A: vA.tag = 0 -> enqueue to recycle pool
+Create X: dequeue vA from pool -> set vA.tag = 200 (X's tag)
+ SAME UIView object, NEW tag
+
+Mount: B moves to index 1, frame.y = 150
+ _firstVisibleView still points to vB (same object, tag unchanged)
+
+Tag check:
+ _firstVisibleView.tag (101) != _firstVisibleViewTag (101) -> PASS
+ (Tag check would fail if _firstVisibleView was recycled)
+
+delta = 150 - 100 = 50
+contentOffset += 50
+```
+
+**Bug scenario when anchor is recycled:**
+If the anchor view itself happens to be recycled (deleted and recreated with a new tag), the tag comparison detects the mismatch and aborts the correction. The next batch will recompute and correct from fresh data.
+
+> **Important:** The tag check is **always active** (no feature flag gate). `RCTComponentViewRegistry` assigns tags during dequeue and resets to 0 during enqueue. When items are removed and re-added, recycled UIViews get new tags based on their position. The view at position 0 may have a different tag than before, so the check must always run. This was confirmed by the `flatlist-inverted-recycle-maintainvisible` maestro test, which failed when the tag check was gated behind `enableViewCulling()` (which returns false in RNTester).
+
+### 5.4 Empty List / Data Reset
+
+**iOS Fabric (minor bug):**
+When the list becomes empty, `_prepareForMaintainVisibleScrollPosition` doesn't execute (loop doesn't run), leaving `_firstVisibleView` unchanged. When `_adjustForMaintainVisibleContentPosition` runs, it accesses `_firstVisibleView.frame` — in Objective-C, accessing `.frame` on nil returns `{0,0}`, so `deltaY = 0 - _prevFirstVisibleFrame.origin.y` causes an incorrect scroll correction.
+
+**Android (safe):**
+`updateScrollPositionInternal` checks `firstVisibleViewRef.get() ?: return` — early return if view is null. No incorrect correction.
+
+---
+
+## 6. State Management
+
+### 6.1 State Variables by Platform
+
+| Variable | iOS Fabric | Android |
+|----------|-----------|---------|
+| Anchor view reference | `_firstVisibleView` (UIView*) | `firstVisibleViewRef` (WeakReference) |
+| Anchor view tag | `_firstVisibleViewTag` (NSInteger) | N/A |
+| Captured frame | `_prevFirstVisibleFrame` (CGRect) | `prevFirstVisibleFrame` (Rect) |
+| Config | `props.maintainVisibleContentPosition` | `config` (Config object) |
+| Skip gate | `_avoidAdjustmentForMaintainVisibleContentPosition` | N/A |
+
+### 6.2 Recomputation Pattern Detail
+
+The recomputation happens at the start of each mount transaction:
+
+```
+Phase 1: _prepareForMaintainVisibleScrollPosition / willMountItems
+ Purpose: Capture anchor that reflects the current (post-layout, pre-mount) state
+ Executes: Before mount mutations are applied
+ Result: Fresh _firstVisibleView, _firstVisibleViewTag, _prevFirstVisibleFrame
+
+Phase 2: _adjustForMaintainVisibleContentPosition / didMountItems
+ Purpose: Compute and apply scroll correction
+ Step 1: delta = newFrame - prevFirstVisibleFrame
+ Step 2: contentOffset += delta
+ Step 3: (Android only) Update prevFirstVisibleFrame to new frame
+```
+
+### 6.3 JS-Side pendingScrollUpdateCount
+
+The `pendingScrollUpdateCount` field in VirtualizedList state serves dual purposes:
+
+1. **Initial scroll index:** Set to `1` when `initialScrollIndex > 0`, preventing render window updates until a valid scroll offset is received from native.
+
+2. **MVCP adjustment tracking:** Incremented when a prepend is detected (JS-side), decremented on each scroll event. While > 0:
+ - `_adjustCellsAroundViewport` returns early (no render window updates)
+ - `_maybeCallOnEdgeReached` is suppressed (edge callbacks don't fire on stale metrics)
+
+This prevents the list from adjusting its render window while native-side MVCP corrections are still settling.
+
+---
+
+## 7. Safeguards & Edge Cases
+
+### 7.1 Tag Comparison Safeguard (iOS Fabric)
+
+**Purpose:** Detect when the anchor view was recycled (deleted and recreated with a new tag) during mount.
+
+**Implementation:**
+```objc
+// 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; // View was recycled - abort correction
+}
+```
+
+**How it works:**
+- `_prepareForMaintainVisibleScrollPosition` captures `_firstVisibleView` and `_firstVisibleViewTag` (the view's React tag)
+- During mount, `RCTComponentViewRegistry` dequeues views from the recycle pool and assigns new tags (`componentViewDescriptor.view.tag = tag`), or resets tags to 0 during enqueue
+- When items are removed and re-added, the same UIView objects may be reused for different items with new tags
+- `_adjustForMaintainVisibleContentPosition` compares the current tag with the captured tag
+- If tags differ → view was recycled → abort correction (avoids applying delta to wrong view)
+
+**Why the check is always active:** `RCTComponentViewRegistry` assigns tags during dequeue and resets to 0 during enqueue, regardless of culling state. When items are removed and re-added, recycled UIViews get new tags based on their position. The view at position 0 may have a different tag than before, so the check must always run.
+
+**Impact:** When the anchor view is recycled, MVCP correctly aborts and waits for the next batch to recompute from fresh data. Without this check, MVCP would apply an incorrect delta to the wrong view, producing incorrect scroll offsets.
+
+### 7.2 Deletion Check (iOS Fabric)
+
+**Purpose:** Detect when the anchor view was deleted (removed from hierarchy) during mount, e.g., during `setData([])` + `scrollToOffset(0)` reset.
+
+**Implementation:**
+```objc
+if (_firstVisibleView.superview != _contentView) {
+ return; // View was deleted - abort correction
+}
+```
+
+**When it triggers:**
+- `setData([])` clears all items → anchor view removed from `_contentView`
+- `_firstVisibleView.superview` becomes nil
+- `_firstVisibleView.superview != _contentView` → abort
+
+**Why it's needed:** Without this check, MVCP would compute a delta from the stale view's frame and apply it to `scrollToOffset(0)`, resulting in incorrect offset (e.g., offset ~3876 instead of 0).
+
+**Two abort conditions compared:**
+
+| Scenario | Tag changed? | Superview changed? | First check (tag) | Second check (superview) |
+|----------|-------------|-------------------|-------------------|-------------------------|
+| Normal prepend | No | No | False | False → **proceed** |
+| View recycled | Yes | No | True → **abort** | - |
+| View deleted (reset) | No | Yes | False | True → **abort** |
+
+Recycling and deletion are mutually exclusive:
+- Recycling: view reused for different item → tag changes, superview unchanged
+- Deletion: view removed from hierarchy → tag unchanged, superview becomes nil
+
+### 7.3 Scroll Skip Guards
+
+**Purpose:** Skip MVCP correction during user dragging or momentum scroll to avoid conflicting with user gestures.
+
+**Current status:**
+| Platform | Scroll Skip Guard |
+|----------|------------------|
+| iOS Fabric | **Not present** in MVCP code. `_avoidAdjustmentForMaintainVisibleContentPosition` is driven by a feature flag for immediate update mode, not scroll state. |
+| Android | **Not present**. No scroll skip guard in `updateScrollPositionInternal`. |
+
+### 7.4 Divide-by-Zero Guard (JS)
+
+**Location:** `ListMetricsAggregator.js`
+
+```js
+if (this._measuredCellsCount > 0) {
+ this._averageCellLength = this._measuredCellsLength / this._measuredCellsCount;
+}
+```
+
+**Purpose:** Prevents `_averageCellLength` from becoming `Infinity` or `NaN` when no cells have been measured yet.
+
+**Related fix:** `_invalidateIfOrientationChanged` clears `_cellMetrics` when orientation changes (horizontal/vertical or RTL), preventing stale metrics from corrupting new measurements.
+
+### 7.5 Empty List Handling
+
+| Platform | Behavior |
+|----------|----------|
+| iOS Fabric | Minor bug: nil `.frame` access returns `{0,0}`, causing incorrect scroll correction |
+| Android | Safe: `firstVisibleViewRef.get() ?: return` early return |
+
+### 7.6 Frame Delta Threshold
+
+| Platform | Threshold |
+|----------|-----------|
+| iOS Fabric | `ABS(delta) > 0.5` |
+| Android | `delta != 0` |
+
+**Purpose:** Prevents sub-pixel noise from triggering unnecessary scroll corrections. The threshold filters out floating-point rounding errors. iOS uses 0.5px while Android uses exact zero comparison.
+
+### 7.7 Autoscroll to Top Threshold
+
+**Prop:** `autoscrollToTopThreshold` (optional, number)
+
+**Behavior:** When the scroll offset after MVCP correction is within the threshold distance from the top (offset < threshold), the list animates to the start position. This handles the case where prepending pushes content entirely off the top of the screen.
+
+### 7.8 Scroll Event Throttle (Android)
+
+**The throttle mechanism:**
+```kotlin
+if (scrollEventType == SCROLL &&
+ scrollView.scrollEventThrottle >= max(17, now - scrollView.lastScrollDispatchTime)) {
+ return // throttled
+}
+```
+
+**Purpose:** Limits `onScroll` event frequency to reduce JS bridge traffic during rapid scrolling. With `scrollEventThrottle = 500`, events are only dispatched once per 500ms window.
+
+**Problem:** The throttle blocks MVCP-adjusted scroll events, causing JS state to be stale:
+- During scroll animation: events are throttled, JS state doesn't update
+- After animation: throttle window hasn't expired, MVCP event is blocked
+- Result: JS offset is stale when MVCP computes delta
+
+**Fix:** Added `emitScrollEventNoThrottle()` that bypasses the throttle check, called in two places:
+
+1. **After scroll animations end** (`registerFlingAnimator.onAnimationEnd`):
+ Ensures JS state is updated immediately when animation completes.
+
+2. **After MVCP adjustments** (`MaintainVisibleScrollPositionHelper`):
+ Ensures JS state reflects MVCP-adjusted position immediately.
+
+**Why this is correct:**
+- Throttle still applies during active scrolling (reduces traffic as intended)
+- Unthrottled events only fire after animations end or MVCP adjusts position
+- JS state is current when needed for delta calculations
+
+**Platform difference:** iOS uses UIScrollViewDelegate callbacks that don't apply the same throttle to programmatic scrolls. Android's ReactScrollView applies throttle uniformly to all events.
+
+---
+
+## 8. Appendix: Key Code References
+
+### iOS Fabric
+
+| File | Description |
+|------|-------------|
+| `RCTScrollViewComponentView.mm` | State variables (_prevFirstVisibleFrame, _firstVisibleView, _firstVisibleViewTag) |
+| `RCTScrollViewComponentView.mm` | Mounting transaction callbacks (willMount/didMount) |
+| `RCTScrollViewComponentView.mm` | `_prepareForMaintainVisibleScrollPosition` — pre-mount recomputation |
+| `RCTScrollViewComponentView.mm` | `_adjustForMaintainVisibleContentPosition` — delta computation + correction |
+| `RCTComponentViewRegistry.mm` | Recycle pool max size constant (1024) |
+| `RCTComponentViewRegistry.mm` | `_dequeueComponentViewWithComponentHandle` — pool dequeue |
+| `RCTComponentViewRegistry.mm` | `_enqueueComponentViewWithComponentView` — pool enqueue |
+| `RCTMountingManager.mm` | `performTransaction` — three-phase mount lifecycle |
+
+### Android
+
+| File | Description |
+|------|-------------|
+| `MaintainVisibleScrollPositionHelper.kt` | Class signature and state variables |
+| `MaintainVisibleScrollPositionHelper.kt` | `updateScrollPositionInternal` — correction logic |
+| `MaintainVisibleScrollPositionHelper.kt` | `computeTargetView` — anchor scan with WeakReference |
+| `MaintainVisibleScrollPositionHelper.kt` | `willMountItems` / `didMountItems` — UIManagerListener callbacks |
+| `ReactScrollView.java` | `mMaintainVisibleContentPositionHelper` field |
+| `ReactScrollView.java` | `setMaintainVisibleContentPosition` — helper creation/update/teardown |
+| `ReactViewGroup.kt` | Culling state (_removeClippedSubviews, allChildren, clippingRect) |
+| `ReactViewGroup.kt` | `updateClippingToRect` — culling implementation |
+
+### JS / VirtualizedLists
+
+| File | Description |
+|------|-------------|
+| `VirtualizedList.js` | State shape (renderMask, cellsAroundViewport, pendingScrollUpdateCount) |
+| `VirtualizedList.js` | `getDerivedStateFromProps` — MVCP prepend detection |
+| `VirtualizedList.js` | `pendingScrollUpdateCount` increment on prepend |
+| `VirtualizedList.js` | `_adjustCellsAroundViewport` — guard when pendingScrollUpdateCount > 0 |
+| `VirtualizedList.js` | `_onScroll` — pendingScrollUpdateCount decrement |
+| `ListMetricsAggregator.js` | State variables (_averageCellLength, _cellMetrics, _measuredCellsCount) |
+| `ListMetricsAggregator.js` | `notifyCellLayout` — cell measurement tracking |
+| `ListMetricsAggregator.js` | Divide-by-zero guard |
+| `ListMetricsAggregator.js` | `_invalidateIfOrientationChanged` — metrics clear on orientation change |
+| `ScrollView.js` | MVCP prop type definition |
+| `ScrollView.js` | `preserveChildren` logic — collapsableChildren when MVCP active |
diff --git a/packages/virtualized-lists/__docs__/HISTORY.md b/packages/virtualized-lists/__docs__/HISTORY.md
new file mode 100644
index 000000000000..970fa4996bab
--- /dev/null
+++ b/packages/virtualized-lists/__docs__/HISTORY.md
@@ -0,0 +1,311 @@
+# MVCP Historical Design (Pre-bf9bb144108)
+
+This document describes the `maintainVisibleContentPosition` (MVCP) architecture as it existed before commit `bf9bb144108` (May 30, 2026 — "test(rn-tester): add maestro tests and refactor maintainVisibleContentPosition examples"), and the three fixes that were applied after.
+
+---
+
+## 1. Original Architecture (Before bf9bb144108)
+
+### 1.1 Design Overview
+
+MVCP prevents unwanted scroll jumps when items are prepended to a list. It works by:
+
+1. Capturing the anchor view's frame before mount mutations
+2. Computing delta = (anchor's frame after mount) - (captured frame)
+3. Applying delta to `contentOffset`
+
+The algorithm runs on three layers:
+- **JS**: Detects prepends via `firstVisibleItemKey` comparison, manages `pendingScrollUpdateCount`
+- **iOS Fabric**: `mountingTransactionWillMount` / `mountingTransactionDidMount` callbacks
+- **Android**: `willMountItems` / `didMountItems` UIManagerListener callbacks
+
+### 1.2 Original iOS Fabric Algorithm
+
+**`_prepareForMaintainVisibleScrollPosition` (pre-mount):**
+- Scans `_contentView.subviews` from `minIndexForVisible`
+- Finds first partially visible subview
+- Stores: `_firstVisibleView`, `_firstVisibleViewTag`, `_prevFirstVisibleFrame`
+
+**`_adjustForMaintainVisibleContentPosition` (post-mount):**
+- Computes delta = `_firstVisibleView.frame - _prevFirstVisibleFrame`
+- Applies `contentOffset += delta`
+- Checks `autoscrollToTopThreshold`
+
+**Original abort conditions (2):**
+1. `!props.maintainVisibleContentPosition` — feature disabled
+2. `_avoidAdjustmentForMaintainVisibleContentPosition` — immediate update mode
+
+**Missing abort conditions:**
+- No nil check for `_firstVisibleView`
+- No superview check for deleted views
+- Tag check existed but was gated behind `enableViewCulling()`
+
+### 1.3 Original Android Algorithm
+
+**`computeTargetView` (pre-mount):**
+- Iterates `contentView.childCount` from `minIndexForVisible`
+- Selects first child where `position > currentScroll` or last child
+- Stores `WeakReference(child)` and `child.getHitRect(frame)`
+
+**`updateScrollPositionInternal` (post-mount):**
+- Retrieves `firstVisibleViewRef.get()` and `prevFirstVisibleFrame`
+- Computes delta on `left` (horizontal) or `top` (vertical)
+- Calls `scrollToPreservingMomentum()`
+- Threshold: `delta != 0`
+
+**Missing:**
+- No `emitScrollEventNoThrottle()` after MVCP adjustment
+- JS offset state could be stale during delta calculations
+
+### 1.4 Original JS Layer
+
+**`ListMetricsAggregator`:**
+- `_cellMetrics: Map` — per-cell layout info
+- `_measuredCellsCount: number` — count of measured cells
+- `_averageCellLength: number` — computed average
+
+**Bugs:**
+1. `_invalidateIfOrientationChanged()` reset counts but did NOT clear `_cellMetrics`
+2. `_averageCellLength` division was not guarded against `_measuredCellsCount === 0`
+
+**`VirtualizedList`:**
+- `pendingScrollUpdateCount` — dual-purpose (initial scroll index + MVCP adjustment)
+- Detects prepends via `firstVisibleItemKey` comparison
+
+### 1.5 Original State Variables
+
+| Variable | iOS Fabric | Android |
+|----------|-----------|---------|
+| Anchor view reference | `_firstVisibleView` (UIView*) | `firstVisibleViewRef` (WeakReference) |
+| Anchor view tag | `_firstVisibleViewTag` (NSInteger) | N/A |
+| Captured frame | `_prevFirstVisibleFrame` (CGRect) | `prevFirstVisibleFrame` (Rect) |
+| Config | `props.maintainVisibleContentPosition` | `config` (Config object) |
+| Skip gate | `_avoidAdjustmentForMaintainVisibleContentPosition` | N/A |
+
+---
+
+## 2. Applied Fixes (After bf9bb144108)
+
+### Fix 1: `90e370a3a20` — Clear cell metrics on orientation change + divide-by-zero guard
+
+**Date:** June 2, 2026
+
+**Problem:**
+- `_invalidateIfOrientationChanged()` reset `_measuredCellsCount` to 0 but did NOT clear `_cellMetrics`
+- New cells measured in new orientation found stale entries, causing `_measuredCellsCount` to stay at 0
+- `_averageCellLength = _measuredCellsLength / _measuredCellsCount` → `NaN` or `Infinity`
+- Affected scroll position calculations, content length estimates, index-to-offset conversions
+
+**Before:**
+```js
+_invalidateIfOrientationChanged() {
+ if (orientation.horizontal !== this._orientation.horizontal) {
+ this._measuredCellsCount = 0;
+ this._measuredCellsLength = 0;
+ this._averageCellLength = 0;
+ // _cellMetrics NOT cleared — stale entries remain
+ }
+}
+
+notifyCellLayout(key, length) {
+ this._measuredCellsCount++;
+ this._measuredCellsLength += length;
+ this._cellMetrics.set(key, { length, timestamp: Date.now() });
+ this._averageCellLength = this._measuredCellsLength / this._measuredCellsCount;
+ // Division not guarded — NaN/Infinity if _measuredCellsCount is 0
+}
+```
+
+**After:**
+```js
+_invalidateIfOrientationChanged() {
+ if (orientation.horizontal !== this._orientation.horizontal) {
+ this._cellMetrics.clear(); // NEW: clear stale entries
+ this._averageCellLength = 0;
+ this._measuredCellsCount = 0;
+ this._measuredCellsLength = 0;
+ }
+}
+
+notifyCellLayout(key, length) {
+ this._measuredCellsCount++;
+ this._measuredCellsLength += length;
+ this._cellMetrics.set(key, { length, timestamp: Date.now() });
+ if (this._measuredCellsCount > 0) { // NEW: guard division
+ this._averageCellLength = this._measuredCellsLength / this._measuredCellsCount;
+ }
+}
+```
+
+---
+
+### Fix 2: `059e57333e7` — iOS anchor view deleted/recycled abort conditions
+
+**Date:** June 12, 2026
+
+**Problem:**
+When a FlatList with `maintainVisibleContentPosition` undergoes mount operations that delete or recycle the anchor view, `_firstVisibleView` may point to a stale view. MVCP computes a delta from this stale view's frame and applies it to the current offset, resulting in incorrect scroll position.
+
+**Scenarios:**
+- Reset/clear: `setData([])` + `scrollToOffset(0)` removes all items
+- Item deletion: Items removed from list
+- View recycling: When culling is enabled, views are reused for different items
+- Empty→repopulate: List starts empty, then items are added
+
+**Repro (horizontal reset):**
+1. FlatList in horizontal mode with `maintainVisibleContentPosition={{minIndexForVisible: 0}}`
+2. Add 50 items at top (70 items total, each 204px wide)
+3. Scroll to offset ~3876 (anchor = item_18 at x=3876)
+4. Reset (`setData(INITIAL_DATA)` + `scrollToOffset(0)`)
+5. item_18 removed from hierarchy
+6. MVCP computes `deltaX = newFrame.x - 3876` (stale)
+7. `offset = 0 + deltaX ≈ 3876` (WRONG — should be 0)
+
+**Before:**
+```objc
+- (void)_adjustForMaintainVisibleContentPosition
+{
+ const auto &props = static_cast(*_props);
+ if (!props.maintainVisibleContentPosition || _avoidAdjustmentForMaintainVisibleContentPosition) {
+ return;
+ }
+
+ // Missing: nil check for _firstVisibleView
+ // Missing: superview check for deleted views
+
+ // Tag check existed but was gated behind enableViewCulling()
+ if (ReactNativeFeatureFlags::enableViewCulling()) {
+ if (_firstVisibleView.tag != _firstVisibleViewTag) {
+ return;
+ }
+ }
+
+ CGFloat deltaX = _firstVisibleView.frame.origin.x - _prevFirstVisibleFrame.origin.x;
+ if (ABS(deltaX) > 0.5) {
+ _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x + deltaX, _scrollView.contentOffset.y);
+ }
+}
+```
+
+**After:**
+```objc
+- (void)_adjustForMaintainVisibleContentPosition
+{
+ const auto &props = static_cast(*_props);
+ if (!props.maintainVisibleContentPosition || _avoidAdjustmentForMaintainVisibleContentPosition) {
+ return;
+ }
+
+ // NEW: Abort if no first visible view (list was empty during mount)
+ if (!_firstVisibleView) {
+ return;
+ }
+
+ // NEW: Tag check now ALWAYS active (removed enableViewCulling gate)
+ if (_firstVisibleView.tag != _firstVisibleViewTag) {
+ return;
+ }
+
+ // NEW: Abort if view was deleted during mount (removed from hierarchy)
+ if (_firstVisibleView.superview != _contentView) {
+ return;
+ }
+
+ CGFloat deltaX = _firstVisibleView.frame.origin.x - _prevFirstVisibleFrame.origin.x;
+ if (ABS(deltaX) > 0.5) {
+ _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x + deltaX, _scrollView.contentOffset.y);
+ }
+}
+```
+
+**Key changes:**
+1. Added nil check: `if (!_firstVisibleView) return` — handles empty list case
+2. Tag check: removed `enableViewCulling()` gate — recycling happens regardless of culling state
+3. Added superview check: `if (_firstVisibleView.superview != _contentView) return` — handles deletion case
+
+**Tag vs Superview checks are mutually exclusive:**
+- Recycling: tag changes, superview unchanged
+- Deletion: tag unchanged, superview becomes nil
+
+---
+
+### Fix 3: `8c8726ff9eb` — Android event throttle blocking MVCP scroll events
+
+**Date:** June 12, 2026
+
+**Problem:**
+`scrollEventThrottle` limits `onScroll` event frequency to reduce JS bridge traffic. With a 500ms throttle, events are only dispatched once per 500ms window. During scroll animations (~300ms), most events are throttled, and JS state never updates to reflect the actual scroll position.
+
+When MVCP adjusts the scroll position programmatically, the `onScroll` event is also throttled if it falls within the throttle window, causing JS state to remain stale.
+
+**Repro:**
+1. Enable 500ms throttle
+2. Scroll to offset 100
+3. Read JS offset display — shows 1 instead of 100 (throttled)
+4. Add item at top (triggers MVCP adjustment to ~144)
+5. Delta = 144 - 1 = 143 (WRONG — expected 144 - 100 = 44)
+
+**Before:**
+```kotlin
+// ReactScrollViewHelper.kt
+private fun dispatchScrollEvent(scrollView: ScrollViewT, scrollEventType: String, x: Float, y: Float) {
+ val now = SystemClock.elapsedRealtime()
+ if (scrollEventType == SCROLL &&
+ scrollView.scrollEventThrottle >= max(17, now - scrollView.lastScrollDispatchTime)) {
+ return // throttled — blocks MVCP-adjusted events too
+ }
+ // ... dispatch event
+}
+
+// MaintainVisibleScrollPositionHelper.kt
+private fun updateScrollPositionInternal() {
+ // ... compute delta, apply correction
+ // No unthrottled event after MVCP adjustment
+ // JS state remains stale
+}
+```
+
+**After:**
+```kotlin
+// ReactScrollViewHelper.kt
+private fun dispatchScrollEvent(scrollView: ScrollViewT, scrollEventType: String, x: Float, y: Float) {
+ val now = SystemClock.elapsedRealtime()
+ if (scrollEventType == SCROLL &&
+ scrollView.scrollEventThrottle >= max(17, now - scrollView.lastScrollDispatchTime)) {
+ return // throttled during active scrolling
+ }
+ // ... dispatch event
+}
+
+// NEW: Bypass throttle after animations end
+private fun registerFlingAnimator() {
+ scrollView.flingAnimator?.addAnimatorListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ emitScrollEventNoThrottle(scrollView, 0f, 0f) // NEW: ensure JS state is current
+ }
+ })
+}
+
+// MaintainVisibleScrollPositionHelper.kt
+private fun updateScrollPositionInternal() {
+ // ... compute delta, apply correction
+ emitScrollEventNoThrottle(scrollView, 0f, 0f) // NEW: ensure JS state reflects MVCP position
+}
+```
+
+**Key changes:**
+1. Added `emitScrollEventNoThrottle()` that bypasses the throttle check
+2. Called after scroll animations end — ensures JS state is current when animation completes
+3. Called after MVCP adjustments — ensures JS state reflects MVCP-adjusted position immediately
+
+**Throttle still applies during active scrolling** (reduces JS bridge traffic as intended). Unthrottled events only fire after animations end or MVCP adjusts position.
+
+---
+
+## 3. Summary of Changes
+
+| Fix | Commit | Date | Platform | What Changed |
+|-----|--------|------|----------|-------------|
+| 1 | `90e370a3a20` | Jun 2 | JS | Clear `_cellMetrics` on orientation change; guard `_averageCellLength` division |
+| 2 | `059e57333e7` | Jun 12 | iOS Fabric | 3 abort conditions: nil check, tag check (ungated), superview check |
+| 3 | `8c8726ff9eb` | Jun 12 | Android | `emitScrollEventNoThrottle()` after animations end and MVCP adjustments |
diff --git a/packages/virtualized-lists/__docs__/TESTING.md b/packages/virtualized-lists/__docs__/TESTING.md
new file mode 100644
index 000000000000..1ee37be11536
--- /dev/null
+++ b/packages/virtualized-lists/__docs__/TESTING.md
@@ -0,0 +1,711 @@
+# MVCP Use Cases & Test Coverage
+
+This document enumerates use cases that trigger `maintainVisibleContentPosition` (MVCP) and specifies expected behavior. Each scenario includes test coverage status (present/absent with rationale). Passing/failing status is tracked separately in the Maestro test coverage document.
+
+---
+
+## Testing Strategy
+
+MVCP is tested across three layers, from fastest/cheapest to slowest/most expensive:
+
+```text
+JS Unit (Jest) → Fantom Integration → Maestro E2E
+```
+
+Native unit tests (Android and iOS, testing individual native classes in isolation) are **not currently feasible** for this capability because MVCP depends on a complete React Native runtime that these frameworks cannot construct (see below). Fantom integration tests avoid this limitation by running the complete runtime headlessly rather than trying to isolate individual native components.
+
+### Layer 1: JS Unit Tests (Jest + react-test-renderer)
+
+**File:** `packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js`
+
+**How to run:**
+```bash
+yarn jest packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js --testNamePattern="maintainVisibleContentPosition"
+```
+
+**Purpose:** Validates the JS-side scroll delta computation. These tests exercise the JavaScript implementation of MVCP — how deltas are computed from scroll offsets and how they are applied to the virtualized list's scroll state. They cannot directly test native-side behavior (no access to native view frames), but ensure the JS layer behaves correctly when deltas are applied.
+
+**Scope:** JS-side delta computation, bounded delta verification across consecutive updates, minIndexForVisible bounds enforcement, inverted mode JS behavior.
+
+### Layer 2: Fantom Integration Tests
+
+**File:** `packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-maintainVisibleContentPosition-itest.js`
+
+**How to run:**
+```bash
+yarn fantom -- packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-maintainVisibleContentPosition-itest.js
+```
+
+**Purpose:** Exercises the JS→native bridge for scroll events. Provides the closest thing to E2E testing without a real device, validating end-to-end scroll behavior through the bridge. Fantom renders the actual React Native components and processes scroll events through the native bridge, catching issues that JS-only tests miss.
+
+**Out of scope:** Visual scroll position verification (Maestro), real device behavior, keyboard events, pull-to-refresh gestures, navigation frame animations, orientation changes, momentum scroll behavior, sub-pixel drift detection.
+
+### Layer 3: Maestro E2E Tests
+
+**Example files:** `packages/rn-tester/js/examples/FlatList/FlatList-maintainVisibleContentPosition.js`, `packages/rn-tester/js/examples/ScrollView/ScrollViewMaintainVisibleContentPositionExample.js`
+
+**How to run:**
+```bash
+# Build the RNTester app
+cd packages/rn-tester
+yarn e2e-build-android # or yarn e2e-build-ios
+
+# Run the tests
+yarn e2e-test-android # or yarn e2e-test-ios
+```
+
+**Purpose:** Runs on real devices or simulators to verify actual visual scroll position preservation. The most valuable layer for catching regressions because it exercises the complete stack: JS rendering, native view mounting, frame measurement, delta computation, and scroll offset adjustment. Maestro reads the actual visual position of list items, catching sub-pixel drift that bridge-level tests miss.
+
+**Scope:** All MVCP scenarios across FlatList and ScrollView, including horizontal/inverted/recycling/throttle/variable-height/empty-list/scrollToOffset/orientation/momentum/rapid-prepends/prepend-delete variants.
+
+### Native Unit Tests — Limitations
+
+MVCP's native code depends on a complete React Native runtime (Surface, ShadowTree, MountingManager, ScrollView internals, etc.). Traditional native unit test frameworks (Robolectric, XCTest) cannot construct this runtime when testing individual native classes in isolation. Fantom integration tests avoid this limitation entirely by not isolating anything — they run the complete React Native runtime headlessly.
+
+| Platform | Limitation |
+|----------|------------|
+| **Android** | Robolectric can instantiate `ReactViewGroup` and basic `View` objects, but cannot construct the full scroll view hierarchy with measured frames needed to verify `computeTargetView()` behavior. `MaintainVisibleScrollPositionHelper` depends on `ScrollView` internals, `contentView.childCount` iteration, and frame measurements that Robolectric cannot reliably simulate in isolation. |
+| **iOS (Legacy)** | XCTest can create `UIView` instances and add them as subviews, but `RCTScrollView` requires a full `RCTSurfacePresenter` and `RCTBridge` context to process scroll events and mount items. Unit tests cannot inject controlled mount items or intercept the UIBlock execution sequence needed to verify the double-recompute pattern. |
+| **iOS (Fabric)** | `RCTScrollViewComponentView` requires a full `Surface` with `ShadowTree` and `MountingManager`. Tag-based recycling detection (`_firstVisibleViewTag`) and the `prepareForRecycle` lifecycle cannot be tested without a complete Fabric runtime. Creating a `Surface` in a unit test context is not supported by the current XCTest infrastructure. |
+
+### Testing Summary
+
+| Layer | Purpose | CI Coverage |
+|-------|---------|-------------|
+| JS Unit (Jest) | JS-side delta computation, bounded delta verification | ✅ Automated |
+| Fantom Integration | JS→native bridge exercise, scroll behavior through bridge | ✅ Automated |
+| Maestro E2E | Actual visual scroll position preservation on device | ⚠️ Device required |
+
+The three-layer approach covers the capability from delta computation through to visual behavior, with Fantom providing a headless integration layer that runs the complete React Native runtime — something traditional native unit test frameworks cannot do.
+
+---
+
+## 1. Prepends
+
+### 1.1. Normal Single Prepend (1-5 items)
+
+**Trigger:** Items are inserted at the beginning of the data array. FlatList re-renders, native mounts new views at the top.
+
+**Expected behavior:** The anchor view (first visible item) shifts downward by the total height of prepended items. MVCP captures the anchor's pre-mount frame, computes delta = newFrame - oldFrame, and adjusts `contentOffset` by the delta to keep the anchor at the same screen position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition preserves position on prepend`
+✅ Maestro: `flatlist-maintainvisible.yml` — basic prepend of 1 item, delta assertion [42, 46]
+
+---
+
+### 1.2. Rapid Consecutive Prepends
+
+**Trigger:** Multiple prepend operations in quick succession (no user interaction between batches). The `pendingScrollUpdateCount` mechanism in JS prevents render window adjustment during MVCP corrections, ensuring deltas settle before the next batch.
+
+**Expected behavior:** Each prepend's delta is applied sequentially. The anchor's final position after all prepends should be stable — the item that was visible before any prepends should remain at the same screen position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles consecutive prepends without drift`
+✅ Maestro: `flatlist-maintainvisible.yml` — basic test covers consecutive prepends
+✅ Maestro: `flatlist-rapid-prepends-maintainvisible.yml` — 5x 50-item prepends without waits, asserts total delta ~11000px
+
+---
+
+### 1.3. Prepend with Delete (Top + Bottom)
+
+**Trigger:** Items are prepended at the top AND removed from the bottom in the same data batch.
+
+**Expected behavior:** The native side is unaffected by bottom deletes since MVCP only looks at the first visible view. The prepend delta is computed from the anchor's frame shift. The `TODO: detect and handle/ignore re-ordering` comment at `RCTScrollViewComponentView.mm:1110` and `RCTScrollView.m:1001` explicitly acknowledges this is unhandled for re-ordering scenarios.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles prepend with delete from bottom`
+✅ Maestro: `flatlist-maintainvisible.yml` — basic test covers prepend+delete
+✅ Maestro: `flatlist-prepend-delete-maintainvisible.yml` — prepends 1, removes 3 from bottom in same batch, asserts delta ~44px
+
+---
+
+### 1.4. Large Prepend (50+ items)
+
+**Trigger:** A large number of items (50+) are inserted at the beginning of the data array.
+
+**Expected behavior:** The anchor view may be recycled by FlatList's view pool (different data item gets the same UIView). MVCP's tag comparison safeguard (`_firstVisibleView.tag != _firstVisibleViewTag`) detects the recycled view and aborts the correction. Without this check, the delta would be computed from the wrong view.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles large prepend (50+ items)`
+✅ Maestro: `flatlist-recycle-maintainvisible.yml` — 50-item prepend with delta assertion [2100, 2300]
+✅ Maestro: `flatlist-horizontal-recycle-maintainvisible.yml` — horizontal variant
+✅ Maestro: `flatlist-inverted-recycle-maintainvisible.yml` — inverted variant
+✅ Maestro: `flatlist-horizontal-inverted-recycle-maintainvisible.yml` — combined variant
+✅ Maestro: `flatlist-inverted-recycle-maintainvisible.yml` — inverted variant
+✅ Maestro: `flatlist-horizontal-inverted-recycle-maintainvisible.yml` — combined variant
+
+---
+
+### 1.5. First Prepend (Anchor State Not Yet Initialized)
+
+**Trigger:** The very first prepend after initial list mount. The anchor state (`_prevFirstVisibleFrame`, `firstVisibleViewRef`) has not been initialized by a prior MVCP cycle.
+
+**Expected behavior:** On first mount, `_prepareForMaintainVisibleScrollPosition` initializes the anchor state. The first prepend should work correctly because the initial mount establishes the baseline.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles first prepend after initial mount`
+✅ Maestro: `flatlist-first-prepend-maintainvisible.yml` — single prepend at offset 500, asserts delta [42, 46]
+
+---
+
+### 1.6. Variable-Height Items
+
+**Trigger:** Items have dynamic heights (images loading, variable text). The anchor's frame size may differ between pre-mount capture and post-layout measurement.
+
+**Expected behavior:** The delta formula `newFrame - oldFrame` conflates two effects: (a) the position shift from prepended items, and (b) the size change of the anchor item itself. The frame-based approach is inherently correct because it measures actual positions, not estimated ones. However, the first MVCP correction may be inaccurate if the initial frame measurement doesn't match the final rendered size.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles variable-height items`
+✅ Maestro: `flatlist-variable-height-maintainvisible.yml` — prepends 1 item three times, delta assertion [28, 112]
+✅ Maestro: `flatlist-variable-height-first-prepend-maintainvisible.yml` — variable height + single prepend
+
+---
+
+## 2. Appends
+
+### 2.1. Normal Append (Add Items at End)
+
+**Trigger:** Items are inserted at the end of the data array.
+
+**Expected behavior:** Appends don't shift existing items' frames, so MVCP delta is 0 and no scroll correction is triggered. The anchor view stays at the same frame position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition does not trigger correction on append`
+✅ Maestro: `flatlist-append-maintainvisible.yml` — append baseline (control test)
+
+---
+
+### 2.2. Append with initialScrollIndex > 0
+
+**Trigger:** A list is rendered with `initialScrollIndex` pointing to a non-first item, then items are prepended.
+
+**Expected behavior:** If `initialScrollIndex` refers to an item that gets pushed by prepend, the scroll destination may be wrong because JS's initial scroll calculation doesn't account for MVCP corrections.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with initialScrollIndex + prepend after remount`
+
+---
+
+## 3. Deletes
+
+### 3.1. Delete Anchor Item
+
+**Trigger:** The item currently at the anchor position (first visible) is removed from the data array.
+
+**Expected behavior:** The anchor shifts to the next visible item. MVCP captures the new anchor's frame, computes delta, and adjusts scroll. The visible content may jump slightly as a new anchor is selected.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles delete of anchor item`
+✅ Maestro: covered implicitly in `flatlist-prepend-delete-maintainvisible.yml` (delete from bottom doesn't affect anchor)
+
+---
+
+### 3.2. Delete Non-Anchor Item
+
+**Trigger:** An item that is not the anchor is removed from the data array.
+
+**Expected behavior:** If the deleted item is above the anchor, the anchor shifts up. MVCP delta = newFrame - oldFrame, scroll offset adjusted accordingly. If below anchor, no effect.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles delete from middle of list`
+
+---
+
+### 3.3. Delete All Items (Empty List)
+
+**Trigger:** All items are removed from the data array. The list becomes empty.
+
+**Expected behavior:** When the list becomes empty, `_recomputeFirstVisibleViewForMaintainVisibleContentPosition` doesn't execute (loop doesn't run), leaving `_firstVisibleView` unchanged (pointing to a culled/detached view). The nil check (`if (!_firstVisibleView) return`) prevents accessing frame on a nil/invalid view. Android is safe: `updateScrollPositionInternal` checks `firstVisibleViewRef.get() ?: return`.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles empty list gracefully`
+✅ Maestro: `flatlist-empty-list-maintainvisible.yml` — MVCP nil frame check verified
+
+---
+
+### 3.4. Delete from Middle
+
+**Trigger:** Items are removed from the middle of the data array.
+
+**Expected behavior:** Items below the deletion point shift up. The anchor's frame changes. MVCP delta = newFrame - oldFrame, scroll offset adjusted to keep anchor at same screen position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles delete from middle of list`
+
+---
+
+## 4. Item Updates (Content, Size, Key Changes)
+
+### 4.1. Content Change (Same Size)
+
+**Trigger:** An item's content changes but its rendered size stays the same.
+
+**Expected behavior:** No frame change, no delta, no scroll correction. The anchor stays at the same position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles content change with same size`
+
+---
+
+### 4.2. Size Change (Item Grows/Shrinks)
+
+**Trigger:** An item's rendered size changes (e.g., image loads, text wraps differently).
+
+**Expected behavior:** The anchor's new frame is compared against `prevFirstVisibleFrame`, and the delta correction keeps the item at the same screen position. If the anchor itself changes size (not just items above/below), the delta correction is applied to the scroll offset, which may over-correct because the item's own size change is included in the delta.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles sibling items above anchor growing`, `maintainVisibleContentPosition handles sibling items above anchor shrinking`
+✅ Maestro: `flatlist-variable-height-maintainvisible.yml` — variable height items inherently exercise size changes
+
+---
+
+### 4.3. Key Change (New Key)
+
+**Trigger:** An item's `key` prop changes, causing React to treat it as a new item.
+
+**Expected behavior:** If the old anchor key exists in new data, position is maintained. Otherwise, JS computes adjustment as null and native side recomputes anchor from new view hierarchy.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles data reset with entire data replacement`
+
+---
+
+## 5. View Culling Scenarios
+
+### 5.1. Anchor Culled (Pushed Off-Screen)
+
+**Trigger:** Items above the anchor grow, pushing the anchor off the top of the visible area. Culling removes off-screen views.
+
+**Expected behavior:** On the next mount cycle, `_recomputeFirstVisibleViewForMaintainVisibleContentPosition` finds a new anchor (the first view whose bottom edge is below the scroll offset). The tag comparison safeguard detects when the anchor view was recycled (different tag) and aborts the correction.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles anchor culled (pushed off-screen)`
+✅ Maestro: `flatlist-recycle-maintainvisible.yml` — 50-item prepend causes anchor recycling
+
+---
+
+### 5.2. Non-Anchor Culled
+
+**Trigger:** An item that is not the anchor is culled (pushed off-screen).
+
+**Expected behavior:** No effect on MVCP. The anchor is unaffected by culling of non-anchor views.
+
+**Test coverage:** ✅ Fantom: inherent in all Fantom tests (culling is a native-side behavior exercised during prepends)
+
+---
+
+### 5.3. All Visible Items Culled (Spacers Only)
+
+**Trigger:** The content view has only spacers (placeholder views with no data binding) in the visible area.
+
+**Expected behavior:** The anchor selection is incorrect. The delta computed from a spacer's frame is meaningless.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles all items culled (spacers only in viewport)`
+
+---
+
+## 6. Orientation Changes
+
+### 6.1. Vertical to Horizontal
+
+**Trigger:** Device rotates from portrait to landscape. The ScrollView's contentSize changes, potentially changing its horizontal flag.
+
+**Expected behavior:**
+- **iOS Paper:** `isHorizontal:` at `RCTScrollView.m:557` checks `contentSize.width > frame.size.width` dynamically — handles orientation changes correctly.
+- **iOS Fabric:** `horizontal` detection at line 1073 also checks `contentSize.width > self.frame.size.width` dynamically — handles orientation changes correctly.
+- **Android:** `horizontal` flag is set at constructor time (`MaintainVisibleScrollPositionHelper.kt:33`) and never changes. If the ScrollView's orientation changes after the helper is created, MVCP continues on the wrong axis.
+
+**Test coverage:** ✅ Maestro: `flatlist-orientation-maintainvisible.yml` — MVCP survives landscape→portrait (iOS only)
+
+---
+
+## 7. Horizontal Lists (LTR vs RTL)
+
+### 7.1. Horizontal LTR
+
+**Trigger:** A horizontally scrolling list in left-to-right layout direction.
+
+**Expected behavior:** Both iOS and Android compute deltas using frames directly, which are in the same coordinate space as contentOffset. The delta should be correct for LTR.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition preserves position on horizontal prepend`
+✅ Maestro: `flatlist-horizontal-maintainvisible.yml` — horizontal prepend, asserts item_5 + item_10
+
+---
+
+### 7.2. Horizontal RTL
+
+**Trigger:** A horizontally scrolling list in right-to-left layout direction.
+
+**Expected behavior:** Frame-based delta computation should work for RTL since frames are in the same coordinate space as contentOffset. The `contentInset` handling in RTL isn't explicitly tested. iOS Paper at `RCTScrollView.m:1054-1056` uses `self.inverted ? self->_scrollView.contentInset.right : self->_scrollView.contentInset.left` for horizontal, but this is for inverted mode, not RTL layout direction.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition preserves position on horizontal prepend in RTL`
+
+---
+
+## 8. Inverted Lists
+
+### 8.1. Vertical Inverted
+
+**Trigger:** A vertically inverted FlatList (`inverted={true}`). Items are rendered in reverse order.
+
+**Expected behavior:** Inverted mode uses CSS transforms (`scaleY: -1` on Android, `scaleY: -1` on iOS) to flip the visual order. The native subview order remains unchanged. The MVCP logic finds the first subview whose bottom edge is below the scroll offset — in inverted mode, this is the visually-topmost visible item. This is correct because we want to maintain the topmost visible item's position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with inverted ScrollView preserves position on prepend`, `maintainVisibleContentPosition with inverted ScrollView handles consecutive prepends`
+✅ Maestro: `flatlist-inverted-maintainvisible.yml` — vertical inverted prepend, delta [40, 48]
+
+---
+
+### 8.2. Horizontal Inverted
+
+**Trigger:** A horizontally inverted FlatList.
+
+**Expected behavior:** Same as vertical inverted — CSS transform flips visual order, native subview order unchanged, MVCP finds first subview below scroll offset.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition preserves position on horizontal + inverted prepend`
+✅ Maestro: `flatlist-horizontal-inverted-maintainvisible.yml` — horizontal + inverted
+
+---
+
+### 8.3. Inverted + Recycling
+
+**Trigger:** An inverted list with culling enabled, causing view recycling during prepends.
+
+**Expected behavior:** The tag comparison safeguard must work correctly in inverted mode. The tag check is always active (not gated behind `enableViewCulling()`) because `RCTComponentViewRegistry` assigns tags during dequeue regardless of culling state.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with inverted + recycling`
+✅ Maestro: `flatlist-inverted-recycle-maintainvisible.yml` — inverted + recycling, all three assertions pass
+✅ Maestro: `flatlist-horizontal-inverted-recycle-maintainvisible.yml` — horizontal + inverted + recycling
+
+---
+
+## 9. Empty Lists / Initial Render / Data Reset
+
+### 9.1. Empty List (No Items)
+
+**Trigger:** The list has no items. MVCP prop is set.
+
+**Expected behavior:** When the list is empty, `_firstVisibleView` is nil (or points to a culled view). The nil check (`if (!_firstVisibleView) return`) prevents accessing frame on nil. Android is safe (early return at `MaintainVisibleScrollPositionHelper.kt:95`).
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles empty list gracefully`
+✅ Maestro: `flatlist-empty-list-maintainvisible.yml` — MVCP nil check verified
+
+---
+
+### 9.2. Initial Render with MVCP Prop
+
+**Trigger:** A list is rendered with `maintainVisibleContentPosition` prop set on first mount.
+
+**Expected behavior:** `_prepareForMaintainVisibleScrollPosition` initializes anchor state on first mount. Subsequent mounts use the stored state for delta computation.
+
+**Test coverage:** ✅ Covered implicitly in all tests (all lists start with MVCP prop set)
+
+---
+
+### 9.3. Data Reset (Replace Entire Data)
+
+**Trigger:** `setData([])` + `scrollToOffset(0)` clears and repopulates the list.
+
+**Expected behavior:** If the old anchor key exists in new data, position is maintained. Otherwise, JS computes adjustment as null and native side recomputes anchor from new view hierarchy.
+
+Two abort conditions prevent incorrect corrections during reset:
+1. **Tag check** (`_firstVisibleView.tag != _firstVisibleViewTag`): Catches view recycling. If the view was reused for a different item, its tag changes.
+2. **Superview check** (`_firstVisibleView.superview != _contentView`): Catches view deletion. If the view was removed from the hierarchy during reset, its superview becomes nil.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles data reset with entire data replacement`
+✅ Maestro: `flatlist-maintainvisible.yml` — reset assertion in basic test
+✅ Maestro: `flatlist-recycle-maintainvisible.yml` — reset with recycling
+✅ Maestro: `flatlist-horizontal-recycle-maintainvisible.yml` — horizontal reset
+✅ Maestro: `flatlist-horizontal-add50-reset-maintainvisible.yml` — horizontal + add 50 + reset
+
+---
+
+## 10. Sibling Items Above Anchor Resized
+
+### 10.1. Sibling Items Above Anchor Grow
+
+**Trigger:** Items positioned above the anchor in the list grow in size (e.g., images load, expandable content opens).
+
+**Expected behavior:** The mathematical invariant `deltaY = newAnchorY - oldAnchorY = growth_of_items_above_anchor` holds. `contentOffset` increases by exactly the growth of items above, and the anchor's screen position (`anchorY - contentOffset`) remains constant. The anchor can never be pushed off-screen by sibling growth alone.
+
+**Example:**
+- List: [A, B, C, D, E, F, G, H], viewport 600px, scrollOffset.y = 0
+- Before mount: A-E total = 200px, F at y=200 (first visible, anchor)
+- After mount: A-E total = 800px (images load), F at y=800
+- Delta = 800 - 200 = 600, contentOffset += 600 → contentOffset.y = 600
+- F on screen = 800 - 600 = y=200 (same screen position as before)
+
+**Correctness:** The delta correction works as designed. The anchor stays at the same screen position. This differs from CSS scroll anchoring, which keeps the user's *reading position* stable (visible content may shift on screen). MVCP keeps the *same view* locked to the same screen position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles sibling items above anchor growing`
+
+---
+
+### 10.2. Sibling Items Above Anchor Shrink
+
+**Trigger:** Items positioned above the anchor shrink in size.
+
+**Expected behavior:** Same invariant as growth, but delta is negative. `contentOffset` decreases by the shrinkage amount.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles sibling items above anchor shrinking`
+
+---
+
+## 11. User Interaction During MVCP
+
+### 11.1. User Drag During MVCP
+
+**Trigger:** The user is actively dragging (touch-scrolling) the list when a data change triggers MVCP.
+
+**Expected behavior:** If the user is actively scrolling when a prepend happens, the MVCP correction may compete with the user's scroll. The scroll skip guard (on `patch/add-scrolling-guard` branch) would skip correction during user dragging, but this is NOT merged.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition does not interrupt scroll during prepend`
+✅ Maestro: `flatlist-maintainvisible.yml` — tests user scroll during prepend (drag *during* prepend only)
+
+---
+
+### 11.2. Momentum Scroll During MVCP
+
+**Trigger:** The list is in momentum scroll (fling) when a data change triggers MVCP.
+
+**Expected behavior:** The MVCP correction is applied during `didMountItems` which happens asynchronously, so timing is unpredictable. The unmerged fix on `patch/add-scrolling-guard` branch adds scroll correction skip during momentum across iOS (Fabric) and Android, but this is NOT merged. iOS Paper does NOT have this guard even in the merged fix.
+
+**Test coverage:** ✅ Maestro: `flatlist-momentum-scroll-maintainvisible.yml` — prepend, then momentum scroll; verify position stable after settle
+
+---
+
+### 11.3. Pull-to-Refresh + Prepend
+
+**Trigger:** User performs pull-to-refresh which triggers a data prepend.
+
+**Expected behavior:** Pull-to-refresh typically scrolls to top, then prepends items. MVCP should handle the prepend delta after the refresh completes. No specific guard exists for this scenario.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition simulates pull-to-refresh pattern`
+
+---
+
+## 12. Navigation (Mount/Unmount Cycles)
+
+### 12.1. Unmount ScrollView
+
+**Trigger:** The ScrollView is unmounted (e.g., user navigates away from screen).
+
+**Expected behavior:** iOS Fabric: `prepareForRecycle` at `RCTScrollViewComponentView.mm:685-715` resets `_prevFirstVisibleFrame`, `_firstVisibleView`, `_firstVisibleViewTag` when the ScrollView is recycled. Android: `stop()` at `MaintainVisibleScrollPositionHelper.kt:77-83` removes UIManager listener.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles unmount/remount (navigation pattern)`
+
+---
+
+### 12.2. Mount ScrollView (New Screen)
+
+**Trigger:** A new screen with a FlatList is pushed onto the navigation stack.
+
+**Expected behavior:** Fresh MVCP state initialization. `_prepareForMaintainVisibleScrollPosition` runs on first mount.
+
+**Test coverage:** ❌ Covered implicitly in all tests (each test starts with a fresh FlatList)
+
+---
+
+### 12.3. Screen Transition + MVCP
+
+**Trigger:** Items are prepended while the list is visible during a push/pop navigation transition. The ScrollView's frame is animating.
+
+**Expected behavior:** The frame-based delta computation may be wrong because the ScrollView's frame is animating. The captured pre-mount frame and post-layout frame may not reflect the final resting position.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles unmount/remount (navigation pattern)`
+
+---
+
+## 13. Concurrent Mutations
+
+### 13.1. Prepend + Append + Middle Delete
+
+**Trigger:** Multiple mutation types in the same data batch: items prepended at top, items appended at bottom, items deleted from middle.
+
+**Expected behavior:** The anchor's final frame reflects ALL changes, so delta is correct for the net effect. MVCP computes a single delta from the anchor's total frame shift.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles complex concurrent mutations (prepend + append + middle delete)`
+
+---
+
+### 13.2. Rapid State Updates (Many Renders)
+
+**Trigger:** Many rapid state updates cause many re-renders in quick succession.
+
+**Expected behavior:** If scroll events are throttled (via `scrollEventThrottle`), `pendingScrollUpdateCount` may not decrement promptly, blocking render window updates for longer than expected. The Android scroll throttle fix (`emitScrollEventNoThrottle()`) ensures JS state is current after MVCP adjustments.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles rapid state updates`
+✅ Maestro: `flatlist-rapid-prepends-maintainvisible.yml` — 5x 50-item prepends without waits (tests rapid mutations)
+✅ Maestro: `flatlist-throttle-maintainvisible.yml` — 500ms throttle; prepend under throttle; delta assertion [42, 46]
+
+---
+
+## 14. getItemLayout Usage
+
+### 14.1. Fixed-Size List with getItemLayout
+
+**Trigger:** A FlatList with a `getItemLayout` prop providing fixed item dimensions.
+
+**Expected behavior:** Native MVCP always reads actual frames, so it is accurate regardless of JS metrics. `getItemLayout` doesn't affect native MVCP.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with getItemLayout prop`
+
+---
+
+### 14.2. Dynamic-Size List without getItemLayout
+
+**Trigger:** A FlatList without `getItemLayout`, items have variable sizes.
+
+**Expected behavior:** Native MVCP reads actual frames, so it is accurate. The initial frame measurement may be wrong if items have highly variable sizes (e.g., images with unknown dimensions), causing incorrect first MVCP delta. Subsequent layout updates correct this.
+
+**Test coverage:** ✅ Maestro: `flatlist-variable-height-maintainvisible.yml` — variable height items (no `getItemLayout`)
+
+---
+
+## 15. ContentInset Changes (Keyboard, Safe Area)
+
+### 15.1. Keyboard Appears/Disappears
+
+**Trigger:** The keyboard appears or disappears, changing the ScrollView's contentInset.
+
+**Expected behavior:** If the keyboard appears exactly when MVCP correction is applied, the inset change may interfere with the scroll correction. The timing is race-dependent. Frame-based MVCP delta computation is not affected by inset changes because frames are in content coordinates, not inset-adjusted coordinates.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles contentInset changes (keyboard/safe area)`
+
+---
+
+### 15.2. Safe Area Inset Changes
+
+**Trigger:** Safe area insets change (e.g., device rotation, split-screen on iPad).
+
+**Expected behavior:** Similar to keyboard — frame-based delta is in content coordinates, so inset changes don't directly affect delta computation. The scroll offset may need adjustment if the visible area changes.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition handles contentInset changes (keyboard/safe area)`
+✅ Maestro: `flatlist-orientation-maintainvisible.yml` — exercises related behavior
+
+---
+
+## 16. scrollToOffset During MVCP Active
+
+### 16.1. scrollToOffset (Non-Animated)
+
+**Trigger:** A programmatic `scrollToOffset(offset)` call is made while MVCP is active (list has `maintainVisibleContentPosition` prop set).
+
+**Expected behavior:** Programmatic `scrollToOffset` during MVCP active can cause incorrect final position. The MVCP delta is additive, so it adds to whatever the current scroll position is, which may have been changed by `scrollToOffset`. This is a known open issue.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with scrollToOffset (non-animated)`
+✅ Maestro: `flatlist-scrolltooffset-maintainvisible.yml` — verifies offset ~100 after scrollToOffset (not 100 + MVCP delta)
+
+---
+
+### 16.2. scrollToOffset (Animated)
+
+**Trigger:** An animated `scrollToOffset` call is in progress when MVCP correction is applied.
+
+**Expected behavior:** Animated scrollToOffset is interrupted by MVCP correction because setting `contentOffset` directly (iOS) or calling `scrollToPreservingMomentum` (Android) replaces any ongoing animation.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with scrollToOffset (animated)`
+✅ Covered implicitly in `flatlist-scrolltooffset-maintainvisible.yml`
+
+---
+
+## 17. ScrollView-Specific Scenarios
+
+### 17.1. ScrollView with minIndexForVisible
+
+**Trigger:** A ScrollView (not FlatList) with `maintainVisibleContentPosition={{minIndexForVisible: N}}`.
+
+**Expected behavior:** Same MVCP logic as FlatList, but ScrollView has a fixed set of subviews (no virtualization). The anchor is the Nth subview whose bottom edge is below the scroll offset.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with minIndexForVisible > 0 skips early items`
+✅ Maestro: `scrollview-minindex-maintainvisible.yml` — delta assertion [38, 44] for 40px items (no margin)
+
+---
+
+### 17.2. ScrollView with autoscrollToTopThreshold
+
+**Trigger:** A ScrollView with `autoscrollToTopThreshold` set.
+
+**Expected behavior:** When scroll offset drops below the threshold, the ScrollView auto-scrolls to top. MVCP should not interfere with this behavior.
+
+**Test coverage:** ✅ Fantom: `maintainVisibleContentPosition with autoscrollToTopThreshold triggers scroll to top`
+✅ Maestro: `scrollview-threshold-maintainvisible.yml` — delta [38, 44] + threshold scroll-to-top behavior
+
+---
+
+## Summary: Test Coverage Gaps
+
+### Maestro Gaps
+
+| Gap | Severity | Rationale for No Test |
+|-----|----------|----------------------|
+| 2.2 initialScrollIndex + prepend | Medium | `initialScrollIndex` only fires on init; remount triggers RNTester navigation |
+| 4.3 Key Change (New Key) | Low | Data reset test exercises same code path (key mismatch → anchor recomputation)
+| 5.3 All Visible Items Culled (Spacers) | Low | Edge case not exercisable in RNTester harness |
+| 7.2 Horizontal RTL | Low | Requires device language change for RTL layout |
+| 12.1/12.3 Navigation | Medium | Navigation transitions not part of RNTester harness |
+| 15.1 Keyboard Inset Changes | Low | Keyboard not part of RNTester harness |
+
+### Fantom Gaps
+
+| Gap | Severity | Rationale for No Test |
+|-----|----------|----------------------|
+| 6.1 Vertical to Horizontal (Orientation) | Medium | Device rotation not simulatable in Fantom |
+| 11.2 Momentum Scroll During MVCP | Medium | Physics-based scroll behavior not simulatable in Fantom |
+
+## Summary: Tests Present
+
+### Fantom Integration Tests
+
+| Fantom Test | Scenarios Covered |
+|-------------|------------------|
+| `maintainVisibleContentPosition preserves position on prepend` | 1.1 |
+| `maintainVisibleContentPosition handles consecutive prepends without drift` | 1.2 |
+| `maintainVisibleContentPosition does not interfere with normal scroll` | 1.1 baseline |
+| `maintainVisibleContentPosition with autoscrollToTopThreshold triggers scroll to top` | 17.2 |
+| `maintainVisibleContentPosition with minIndexForVisible > 0 skips early items` | 17.1 |
+| `maintainVisibleContentPosition with inverted ScrollView preserves position on prepend` | 8.1 |
+| `maintainVisibleContentPosition with inverted ScrollView handles consecutive prepends` | 8.1 |
+| `maintainVisibleContentPosition does not interrupt scroll during prepend` | 11.1 |
+| `maintainVisibleContentPosition preserves position on horizontal prepend` | 7.1 |
+| `maintainVisibleContentPosition preserves position on horizontal + inverted prepend` | 8.2 |
+| `maintainVisibleContentPosition does not trigger correction on append` | 2.1 |
+| `maintainVisibleContentPosition handles delete of anchor item` | 3.1 |
+| `maintainVisibleContentPosition handles delete from middle of list` | 3.4 |
+| `maintainVisibleContentPosition handles empty list gracefully` | 3.3, 9.1 |
+| `maintainVisibleContentPosition handles sibling items above anchor growing` | 10.1 |
+| `maintainVisibleContentPosition handles sibling items above anchor shrinking` | 10.2 |
+| `maintainVisibleContentPosition handles data reset with entire data replacement` | 9.3 |
+| `maintainVisibleContentPosition with initialScrollIndex + prepend after remount` | 2.2 |
+| `maintainVisibleContentPosition preserves position on horizontal prepend in RTL` | 7.2 |
+| `maintainVisibleContentPosition handles complex concurrent mutations (prepend + append + middle delete)` | 13.1 |
+| `maintainVisibleContentPosition with getItemLayout prop` | 14.1 |
+| `maintainVisibleContentPosition handles all items culled (spacers only in viewport)` | 5.3 |
+| `maintainVisibleContentPosition simulates pull-to-refresh pattern` | 11.3 |
+| `maintainVisibleContentPosition handles unmount/remount (navigation pattern)` | 12.1, 12.3 |
+| `maintainVisibleContentPosition handles contentInset changes (keyboard/safe area)` | 15.1, 15.2 |
+| `maintainVisibleContentPosition handles prepend with delete from bottom` | 1.3 |
+| `maintainVisibleContentPosition handles large prepend (50+ items)` | 1.4 |
+| `maintainVisibleContentPosition handles first prepend after initial mount` | 1.5 |
+| `maintainVisibleContentPosition handles variable-height items` | 1.6 |
+| `maintainVisibleContentPosition handles anchor culled (pushed off-screen)` | 5.1 |
+| `maintainVisibleContentPosition with inverted + recycling` | 8.3 |
+| `maintainVisibleContentPosition handles rapid state updates` | 13.2 |
+| `maintainVisibleContentPosition with scrollToOffset (non-animated)` | 16.1 |
+| `maintainVisibleContentPosition with scrollToOffset (animated)` | 16.2 |
+| `maintainVisibleContentPosition handles content change with same size` | 4.1 |
+
+### Maestro E2E Tests
+
+| Test File | Scenarios Covered |
+|-----------|------------------|
+| `flatlist-maintainvisible.yml` | 1.1, 1.2, 1.3, 11.1, 9.3 |
+| `flatlist-append-maintainvisible.yml` | 2.1 |
+| `flatlist-first-prepend-maintainvisible.yml` | 1.5 |
+| `flatlist-recycle-maintainvisible.yml` | 1.4, 5.1, 9.3 |
+| `flatlist-variable-height-maintainvisible.yml` | 1.6, 4.2 |
+| `flatlist-variable-height-first-prepend-maintainvisible.yml` | 1.6, 1.5 |
+| `flatlist-delete-anchor-maintainvisible.yml` | 3.1 |
+| `flatlist-delete-middle-maintainvisible.yml` | 3.2, 3.4 |
+| `flatlist-pull-to-refresh-maintainvisible.yml` | 11.3 |
+| `flatlist-complex-mutations-maintainvisible.yml` | 13.1 |
+| `flatlist-throttle-maintainvisible.yml` | 13.2 |
+| `flatlist-horizontal-maintainvisible.yml` | 7.1 |
+| `flatlist-horizontal-add50-reset-maintainvisible.yml` | 9.3 |
+| `flatlist-inverted-recycle-maintainvisible.yml` | 8.3 |
+| `flatlist-inverted-maintainvisible.yml` | 8.1 |
+| `flatlist-horizontal-inverted-maintainvisible.yml` | 8.2 |
+| `flatlist-horizontal-inverted-recycle-maintainvisible.yml` | 8.3 |
+| `flatlist-horizontal-recycle-maintainvisible.yml` | 1.4, 9.3 |
+| `flatlist-empty-list-maintainvisible.yml` | 3.3, 9.1 |
+| `flatlist-orientation-maintainvisible.yml` | 6.1 |
+| `flatlist-scrolltooffset-maintainvisible.yml` | 16.1, 16.2 |
+| `flatlist-momentum-scroll-maintainvisible.yml` | 11.2 |
+| `flatlist-rapid-prepends-maintainvisible.yml` | 1.2, 13.2 |
+| `flatlist-prepend-delete-maintainvisible.yml` | 1.3 |
+| `scrollview-minindex-maintainvisible.yml` | 17.1 |
+| `scrollview-threshold-maintainvisible.yml` | 17.2 |