Repository navigation
Description
Description
On Android with the New Architecture, a ScrollView.scrollTo sent from useLayoutEffect in the same commit that makes the content taller doesn't scroll. The ScrollView stays at 0 and never reaches the requested offset. The same scrollTo works once the taller content is mounted.
Mounting a commit waits until the end of the JS task, so layout effects run first. View commands don't wait. MountItemDispatcher.dispatchMountItems also runs queued view commands before the batch's mount items (disableEarlyViewCommandExecution defaults to false). So the command reaches ReactScrollView while it still holds the previous, shorter content.
ScrollView.scrollTo clamps the offset to that content, here to 0. The content has a height above 0, so isContentReady() is true and setPendingContentOffsets stores nothing for onLayout to re-apply. Nothing throws either, so the RetryableMountingLayerException retry, which only covers views that don't exist yet, doesn't kick in.
This looks like the Android counterpart of #47576. #47604 fixed it on iOS by queuing view commands together with mounting in RuntimeScheduler, and noted that Android relies on its retry mechanism. That retry doesn't cover a view that exists with a stale layout.
Forcing disableEarlyViewCommandExecution = true (#54074) fixes it, in the reproducer below and in our app. To force it, call ReactNativeFeatureFlags.dangerouslyForceOverride after loadReactNative with a ReactNativeNewArchitectureFeatureFlagsDefaults subclass. Grow then lands on row 1000 by itself.
Steps to reproduce
- Run the app below on Android (New Architecture, RN 0.86.2).
- Tap Grow to 2000 rows and scroll to row 1000. The list grows, but stays at row 0 and
contentOffset.ystays 0. - Tap Control: scrollTo the same offset now. The identical
scrollTolands on row 1000 (contentOffset.y = 50000).
import { useLayoutEffect, useRef, useState } from 'react';
import { Button, ScrollView, StyleSheet, Text, View } from 'react-native';
const ROW_HEIGHT = 50;
const TARGET_ROW = 1000;
const TARGET_Y = TARGET_ROW * ROW_HEIGHT;
export default function App() {
const scrollRef = useRef<ScrollView>(null);
const [rowCount, setRowCount] = useState(5);
const [offset, setOffset] = useState(0);
// Grow the content and scroll in the same commit, from a layout effect.
useLayoutEffect(() => {
if (rowCount > 5) {
scrollRef.current?.scrollTo({ y: TARGET_Y, animated: false });
}
}, [rowCount]);
return (
<View style={styles.root}>
<View style={styles.header}>
<Button
title={`Grow to 2000 rows and scroll to row ${TARGET_ROW}`}
onPress={() => setRowCount(2000)}
/>
<Button
title="Control: scrollTo the same offset now"
onPress={() =>
scrollRef.current?.scrollTo({ y: TARGET_Y, animated: false })
}
/>
<Text>
contentOffset.y = {offset} (expected {TARGET_Y})
</Text>
</View>
<ScrollView
ref={scrollRef}
scrollEventThrottle={16}
onScroll={e => setOffset(Math.round(e.nativeEvent.contentOffset.y))}
>
{Array.from({ length: rowCount }, (_, i) => (
<View key={i} style={styles.row}>
<Text>Row {i}</Text>
</View>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, paddingTop: 48 },
header: { padding: 12, gap: 8 },
row: {
height: ROW_HEIGHT,
justifyContent: 'center',
paddingHorizontal: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
});Reproducer: https://github.com/IjzerenHein/rn-android-scrollto-layout-effect (screenshots in its README).
Expected behavior
The ScrollView scrolls to row 1000 after Grow, as the identical scrollTo does after Control.
Real-world impact
FlashList v2 sends its initialScrollIndex scroll from a layout effect while it renders progressively. It retries once after setTimeout(0), which usually rescues it. On a slow device, that retry lands in the same UI batch as the large mount, still runs first, and is clamped too.
The list then draws its items around the target offset while the native scroll view sits at 0. The screen stays blank until the user scrolls, and then shows the top of the list. We hit this in production on Android, and reproduced it on an emulator with a probe in MainActivity:
| Time | scrollY |
Content height | Note |
|---|---|---|---|
| 16:39:16.750 | 0 | 184 px | first FlashList commit mounted |
| 16:39:21.745 | — | — | JS: list loaded, target offset 6,600 dp |
| 16:39:21.811 | 0 | 299,384 px | final commit mounted, scroll lost |
| with the flag on | 12,840 px | 299,784 px | lands in the same frame |
Environment
- React Native 0.86.2, New Architecture (bridgeless), stable release level, fresh
@react-native-community/cli initapp - Android 16 (API 36), Pixel Tablet emulator
- iOS not checked; per synchronise dispatching of view commands through RuntimeScheduler #47604 it should be fixed there
Question
disableEarlyViewCommandExecution has expectedReleaseValue: true and ossReleaseStage: 'none'. Is shipping it the plan for this, or is a fix like #47604 for Android more likely?
Activity