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).

@amen Do you have any updates ?

Hi @DVD

Rest assured we are working tirelessly on your reported issue and will update you ASAP.
Apologies for the long time this is taking. I would request you to allow us some more time.
Thanks for your patience and understanding!

Warm regards,
Ivy

Happy new year!
Do you have any updates?

Happy New Year @DVD !
Hope you are doing well.

Key findings up untill now:

  1. During refresh first remove mutation is called, then insert and then update instead of only update mutation
  2. Focus is lost before update mutation during remove. That’s why update is not able to restore the focus.
  3. KeplerFocusManager is not present during mutations for Focus Management
  4. Forced creation of KeplerFocusManager - Focus is retained in this case as per expected behavior. Focus gets removed and added back by Focus Manager. So, older focus flow implemented in libKeplerScript.so is working but FocusV2 flow is unable to restore focus.
  5. In point 4, although focus is restored there are few other issues observed like unable to navigate left in the list and focused item tile looks disabled.

What we are still working on:

  1. Try to fix other bugs observed in the older focus flow
  2. Figure out why FocusV2 does not work.
  3. Understand if hybrid use of V1 and V2 can be used

I understand this is taking a long time and I apologize for the delay. We are trying to get to the solution ASAP.

Warm regards,
Ivy

Hi @DVD,

Thank you for your patience while we investigated this issue.

Root Cause Analysis: Our engineering team has completed a thorough investigation and identified the root cause. The focus loss issue with @amzn/shopify__flash-list during data refresh is consistent with Android TV behavior. When FlashList performs delete and update mutations (unmounting old elements and mounting new ones), focus is temporarily lost because there’s no focusable parent container to hold and restore focus.

Solution Provided: The recommended solution is to wrap your FlashList component in a TVFocusGuideView. This component acts as a focus container that temporarily holds focus during unmount operations and restores it to the first focusable child after remounting completes.

Here’s the implementation:

import {TVFocusGuideView} from '@amazon-devices/react-native-kepler';

<TVFocusGuideView
  style={styles.list}
  autoFocus={true}>
  <FlashList
    ref={ref}
    data={data}
    extraData={{data, focusedIndex}}
    horizontal={true}
    renderItem={renderItem}
    keyExtractor={keyExtractor}
    drawDistance={ITEM_WIDTH}
    scrollEnabled={false}
    estimatedItemSize={ITEM_WIDTH}
    removeClippedSubviews={false}
    viewabilityConfig={viewabilityConfig}
    style={styles.list}
  />
</TVFocusGuideView>

Why This Works: The TVFocusGuideView provides the focus management mechanism that’s present in standard Android TV components but missing when using FlashList directly. It ensures focus is properly maintained across component remounts, which is exactly what happens when your app periodically refreshes data from the backend API.

Can you try implementing this solution in your DirecTV app and let us know if it resolves the focus issue? The team has verified this approach maintains focus correctly during data refreshes.

Thanks for helping us improve the Vega platform!

Warm regards,
Aishwarya

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.