When data is refreshed, @amzn/shopify__flash-list finds it difficult to keep the focus on the same item

:backhand_index_pointing_right: Bug Description


1. Summary

App Name: DirecTV

Bug Severity
Select one that applies

  • Impacts operation of app
  • Blocks current development
  • Improvement suggestion
  • Issue with documentation (If selected, please share the doc link and describe the issue)
  • Other

2. Steps to Reproduce

  1. Create a sample app
  2. Intall a @amzn/shopify__flash-list pkg.
  3. Use provided js code below (see 7) as source code in the src/App.tsx
  4. Run the app
  5. Move focus to item 4, 5, 6
  6. Wait data to be updated
  7. Observe the focus was lost

3. Observed Behavior

Focus is lost in the sample app (in DTV app it land on prev. item)

4. Expected Behavior

The same as on FireTV/AndroidTV/tvOS platforms: Once the data has been updated, the focus should stay on the same item on the list.

4.a Possible Root Cause & Temporary Workaround

  • Make drawDistance bigger so list will render all items
  • Use keyExtractor= (_,index) => index

^^^ no acceptable in our case (itโ€™s a pref. degradation)

5. Logs or crash report

 (NOBRIDGE) LOG  Focused on {"index": 3}          <-- I move focus to 4 item
 (NOBRIDGE) LOG  Item UN_Mounted {"index": 0}     <-- 1st item was unmounted 
 (NOBRIDGE) LOG  Item Mounted {"index": 8}
 (NOBRIDGE) LOG  Updating data                    <-- Data has beed updated
 (NOBRIDGE) LOG  Render App
 (NOBRIDGE) LOG  Item Blur {"index": 3}           <-- Focus lost :(

6. Environment

Please fill out the fields related to your bug below:

  • SDK Version: 0.20.3719

  • App State: Foreground

  • OS Information

    cat: /etc/os-release: No such file or directory
    

7. Example Code Snippet / Screenshots / Screengrabs

import React, {
  useState,
  useEffect,
  useCallback,
  useRef,
  ComponentRef,
} from 'react';
import {
  StyleSheet,
  ImageBackground,
  View,
  Text,
  TouchableOpacity,
} from 'react-native';
import {FlashList} from '@amzn/shopify__flash-list';

const DATA = new Array(100).fill(0).map((_, i) => ({
  id: Math.random().toString(36).substr(2, 2),
  title: `Item ${i + 1}`,
}));

const ITEM_WIDTH = 400;

function MyItem({item, index, onFocus}) {
  const [focused, setFocused] = useState(false);

  const styles = getStyles();

  const myOnFocus = useCallback(
    (e) => {
      console.log('Item Focus', {index});
      setFocused(true);
      onFocus?.(e);
    },
    [onFocus],
  );
  const onBlur = useCallback(() => {
    console.log('Item Blur', {index});
    setFocused(false);
  }, []);

  return (
    <TouchableOpacity
      key={item.id}
      hasTVPreferredFocus={index === 0}
      onFocus={myOnFocus}
      onBlur={onBlur}
      style={[styles.item, focused && styles.focus]}>
      <MountLogger name="Item" index={index} />
      <View>
        <Text style={{fontSize: 40}}>{item.title}</Text>
      </View>
    </TouchableOpacity>
  );
}

const viewabilityConfig = {
  itemVisiblePercentThreshold: 10,
  minimumViewTime: 100,
};

export const App = () => {
  const [data, setData] = useState(DATA);
  const ref = useRef<ComponentRef<typeof FlashList> | null>(null);
  const styles = getStyles();

  useEffect(() => {
    const id = setInterval(() => {
      console.log('Updating data');
      setData([...DATA].map(item => ({...item})));
    }, 10_000);
    return () => clearInterval(id);
  }, []);

  const keyExtractor = useCallback((item) => item.id, []);

  const renderItem = useCallback(
    ({item, index}) => (
      <MyItem
        item={item}
        index={index}
        onFocus={() => {
          console.log('Focused on', {index});
          ref.current?.scrollToIndex({
            index,
            animated: true,
            viewPosition: 0.01,
          });
        }}
      />
    ),
    [],
  );

  console.log('Render App');

  return (
    <ImageBackground
      source={require('./assets/background.png')}
      style={styles.background}>
      <FlashList
        ref={ref}
        data={data}
        extraData={data}
        horizontal={true}
        renderItem={renderItem}
        keyExtractor={keyExtractor}
        drawDistance={ITEM_WIDTH}
        scrollEnabled={false}
        estimatedItemSize={ITEM_WIDTH}
        removeClippedSubviews={false}
        viewabilityConfig={viewabilityConfig}
        style={styles.list}
      />
    </ImageBackground>
  );
};

const getStyles = () =>
  StyleSheet.create({
    background: {
      flex: 1,
    },
    list: {
      height: 400,
    },
    focus: {borderColor: 'red', borderWidth: 5, opacity: 1},
    item: {
      width: ITEM_WIDTH - 20,
      height: 180,
      margin: 10,
      backgroundColor: 'white',
      justifyContent: 'center',
      alignItems: 'center',
      borderRadius: 10,
      shadowColor: '#000',
      shadowOffset: {width: 0, height: 2},
      shadowOpacity: 0.3,
      shadowRadius: 3,
      elevation: 5,
    },
  });

function MountLogger({name, ...props}) {
  useEffect(() => {
    console.log(name + ' Mounted', props);
    return () => console.log(name + ' UN_Mounted', props);
  }, []);
  return null;
}

Hi @DVD
Thanks for reporting this issue and adding all the details. Weโ€™ll try to replicate the same and will update you soon.
Warm regards,
Ivy

Hi @DVD,

I noticed something in your code that might help resolve the FlashList focus issue.

The Problem: In your current code, this line is causing the focus loss:

setData([...DATA].map(item => ({...item})));

This creates entirely new object references for every item, causing FlashList to treat all items as changed and remount them, which loses the focus state.

The Solution: Replace that line with:

setData((prevData) => [...prevData]); // Maintain same object references

This preserves the existing object references while still triggering a re-render, so FlashList maintains focus on the currently selected item.

Try this fix and let me know if it resolves the issue for you!

Warm regards,
Aishwarya

@amen Have you considered that this was done intentionally to demonstrate the issue?

The provided code is only meant to simulate the real behavior of the TV app.

In the actual app, the DTV client periodically re-fetches data from the backend API, so oldData[0] !== newData[0] โ€” even though the content remains the same (oldData[0].id === newData[0].id).