Skip to content

[Android][Fabric] scrollTo from a layout effect is clamped to the previous content size and lost #59832

Open

Description

@IjzerenHein

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

  1. Run the app below on Android (New Architecture, RN 0.86.2).
  2. Tap Grow to 2000 rows and scroll to row 1000. The list grows, but stays at row 0 and contentOffset.y stays 0.
  3. Tap Control: scrollTo the same offset now. The identical scrollTo lands 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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions