react-tv-space-navigation icon indicating copy to clipboard operation
react-tv-space-navigation copied to clipboard

Question/bug: Are nested SpatialNavigationVirtualizedList (vertical parent + horizontal rails) supported? scrollBehavior breaks and virtualization collapses at scale

Open nriccar opened this issue 7 months ago • 11 comments

Describe the bug When nesting SpatialNavigationVirtualizedList (SNVL) — a vertical parent with horizontal SNVL rails as children — the horizontal scrollBehavior='stick-to-start' becomes inconsistent (doesn’t snap/stick reliably), and performance degrades as the screen adds more rails/items (focus lag after a few moves, virtualization seems less effective).

To Reproduce

import React, { memo, useMemo } from 'react'
import { Text, View } from 'react-native'
import {
  DefaultFocus,
  SpatialNavigationFocusableView,
  SpatialNavigationNode,
  SpatialNavigationRoot,
  SpatialNavigationVirtualizedList,
} from 'react-tv-space-navigation'

const rails = new Array(4).fill(0).map((_, r) => ({
  id: `rail-${r}`,
  items: new Array(10).fill(0).map((__, i) => ({ id: `r${r}-i${i}` })),
}))

const ROW_HEIGHT = 220
const TILE_W = 120
const GAP = 16
const ITEM_SIZE = TILE_W + GAP

export const Test = () => {
  const data = useMemo(() => rails, [])

  return (
    <View
      style={{
        flex: 1,
        overflow: 'hidden',
      }}>
      <SpatialNavigationRoot>
        <SpatialNavigationVirtualizedList
          orientation='vertical'
          data={data}
          itemSize={ROW_HEIGHT}
          additionalItemsRendered={1}
          scrollBehavior='stick-to-start'
          renderItem={({ item: rail }) => (
            // CHILD: horizontal SNVL per row
            <SpatialNavigationVirtualizedList
              orientation='horizontal'
              data={rail.items}
              itemSize={ITEM_SIZE}
              scrollBehavior='stick-to-start' // ← doesn't work
              additionalItemsRendered={1}
              renderItem={({ item }) => (
                <SpatialNavigationNode>
                  <Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
                </SpatialNavigationNode>
              )}
            />
          )}
        />
      </SpatialNavigationRoot>
    </View>
  )
}

const Tile = memo(
  ({ id, width, height }: { id: string; width: number; height: number }) => {
    const Wrapper = id === 'rail-0' ? DefaultFocus : View
    return (
      <Wrapper>
        <SpatialNavigationFocusableView>
          {({ isFocused }) => (
            <View
              style={{
                width,
                height,
                backgroundColor: isFocused ? 'red' : 'blue',
                justifyContent: 'center',
                alignItems: 'center',
              }}>
              <Text
                style={{
                  color: 'white',
                  fontSize: 20,
                  fontWeight: 'bold',
                }}>
                {id}
              </Text>
            </View>
          )}
        </SpatialNavigationFocusableView>
      </Wrapper>
    )
  },
)

Observed behavior

Holding RIGHT/LEFT within a horizontal rail: after a few items, snapping to “start” doesn't work.

As more rails/items are added, overall responsiveness drops and focus moves become laggy (virtualization seems to keep more mounted than expected) - testing in release modes.

Expected behavior

  • With vertical SNVL parent + horizontal SNVL children, scrollBehavior='stick-to-start' should remain reliable in each horizontal rail.
  • LEFT/RIGHT within a rail and UP/DOWN between rails should be smooth.
  • Virtualization should remain effective (only a small window mounted) as rails/items scale.

Library version: 5.2.0 React Native version: 0.77.0

Additional context

I followed the Pitfalls & Troubleshooting doc: every item is always wrapped in a SpatialNavigationNode / SpatialNavigationFocusableView (no conditional mounting); only inner content changes.

This issue appears specifically when the vertical container is SNVL; with a vertical SpatialNavigationScrollView, snapping is correct but performance doesn’t scale since everything stays mounted.

Questions:

  • Is SNVL→SNVL nesting (vertical parent + horizontal children) officially supported?
  • If yes, are there recommended props/patterns to keep scrollBehavior and virtualization stable at scale?
  • If not recommended, what’s the preferred pattern for large datasets?

nriccar avatar Sep 13 '25 00:09 nriccar

Found that nested SpatialNavigationVirtualizedList (vertical parent + horizontal children) works fine with a small number of rails. However, once the vertical rail count exceeds ~5, vertical scrolling/focus starts behaving inconsistently (snaps drift, focus jumps or stalls). Interestingly, very large horizontal lists are OK (e.g., 2,000 items per rail); the problem appears tied to the number of vertical rails rather than the size of each horizontal list.

To Reproduce

import React, { memo, useMemo } from 'react'
import { Dimensions, Text, View } from 'react-native'
import {
  DefaultFocus,
  SpatialNavigationFocusableView,
  SpatialNavigationNode,
  SpatialNavigationRoot,
  SpatialNavigationView,
  SpatialNavigationVirtualizedList,
} from 'react-tv-space-navigation'

const AMOUNT_OF_RAILS = 10
const AMOUNT_OF_ITEMS = 2000

const rails = new Array(AMOUNT_OF_RAILS).fill(0).map((_, r) => ({
  id: `rail-${r}`,
  items: new Array(AMOUNT_OF_ITEMS)
    .fill(0)
    .map((__, i) => ({ id: `r${r}-i${i}` })),
}))

const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window')

const scale = SCREEN_W / 1920
export const responsiveSize = (pixels: number) => pixels * scale

const ROW_HEIGHT = responsiveSize(355)
const TILE_W = responsiveSize(225)
const GAP = responsiveSize(24)
const ITEM_SIZE = TILE_W + GAP

export const POC1_Nested = () => {
  const data = useMemo(() => rails, [])

  return (
    <SpatialNavigationRoot>
      <View
        style={{
          width: SCREEN_W,
          height: SCREEN_H,
          overflow: 'hidden',
        }}>
        {/* Simulate a heavier upper UI area */}
        <View style={{ height: SCREEN_H * 0.6 }} />
        {/* Rails area (nested SNVL) */}
        <View style={{ height: SCREEN_H * 0.4, overflow: 'scroll' }}>
          <SpatialNavigationVirtualizedList
            orientation='vertical'
            data={data}
            itemSize={ROW_HEIGHT}
            scrollBehavior='stick-to-start'
            renderItem={({ item: rail }) => (
              <SpatialNavigationView
                direction='horizontal'
                style={{ height: ROW_HEIGHT, width: SCREEN_W }}>
                <SpatialNavigationVirtualizedList
                  orientation='horizontal'
                  data={rail.items}
                  itemSize={ITEM_SIZE}
                  scrollBehavior='stick-to-start' // ← becomes inconsistent when nested with many vertical rails
                  renderItem={({ item }) => (
                    <SpatialNavigationNode>
                      <Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
                    </SpatialNavigationNode>
                  )}
                />
              </SpatialNavigationView>
            )}
          />
        </View>
      </View>
    </SpatialNavigationRoot>
  )
}

const Tile = memo(
  ({ id, width, height }: { id: string; width: number; height: number }) => {
    // Make the very first tile focused initially
    const Wrapper = id === 'r0-i0' ? DefaultFocus : View
    return (
      <Wrapper>
        <SpatialNavigationFocusableView>
          {({ isFocused }) => (
            <View
              style={{
                width,
                height,
                backgroundColor: isFocused ? 'red' : 'blue',
                justifyContent: 'center',
                alignItems: 'center',
              }}>
              <Text style={{ color: 'white', fontSize: 20, fontWeight: 'bold' }}>
                {id}
              </Text>
            </View>
          )}
        </SpatialNavigationFocusableView>
      </Wrapper>
    )
  },
)

Horizontal rails can be huge (2k items is fine), but as soon as I crank up the number of vertical rows (say ~10), things get weird. I’d expect the vertical layer to scale just like the horizontal one: smooth up/down between rails, each rail still snapping stick-to-start on left/right, and virtualization staying sane (no container growth or over-mounting).

nriccar avatar Sep 13 '25 01:09 nriccar

Hi!

Thank you for your issue. It is actually duplicate with https://github.com/bamlab/react-tv-space-navigation/issues/167 I think. The short answer: it is not supported yet 😭 I have to have a deep look, but this is a complex topic.

I would expect the virtualization inside virtualization to work (with a major drawback at first: as the rails will be unmounted, they will lose their state... so scroll will be reset if you go up and down after virtualization). But it doesn't work for a reason that I couldn't identify yet, and as this is very time consuming, I haven't had the chance to have a deeper look in the past months (I'm not working on any TV projects right now).

Unfortunately, I don't know what to recommend yet. On my projects, we managed to lazy load our rows vertically because we don't have too many rows (20 max), so it works fine without virtualization.

As soon as I try to really virtualize, I encounter a bug where I get stuck (focus is lost and I can't recover it).

I'm saying all this but I'm not 100% sure your problem is the same problem though, it looks related but the symptoms differ 🤔

pierpo avatar Sep 15 '25 08:09 pierpo

hey Pierre, thanks for sharing your thoughts!

somehow we were able to include snippet structure into our project and its working better - although virtualization could be improved like in flashlist or legend list -, its actually virtualizing content. what we found is that tree structure is very fragile, if we change any of the spatial components in the tree auto scroll focus behaviour from spatial navigation stops working.

one of the key components is the wrapper for SpatialNavigationVirtualizedList, a SpatialNavigationView with the same orientation as the nested virtualized list.

nriccar avatar Sep 15 '25 12:09 nriccar

Hey Pierre, We tested on beta6.0.0 and this is not working well, it has jerks on scroll.

This is making the library un-usable.

vishu2124 avatar Sep 15 '25 18:09 vishu2124

I agree, it is a critical issue indeed. I will mention it in the README in the mean time, because I have no ETA for the fix.

pierpo avatar Sep 16 '25 15:09 pierpo

@nriccar I'm using the same structure: vertical SpatialNavigationVirtualizedList which renders horizontal SpatialNavigationVirtualizedLists. Focus works as expected and we don't have performance issues considering the limited resources of the TV Device I'm testing on for development. I am wondering if you intentionally omitted this in your code for brevity, but I notice no use of onEndReached which would definitely help

adrianbunea avatar Sep 18 '25 10:09 adrianbunea

@nriccar off topic, to focus the first item automatically you wrote:

const Wrapper = id === 'rail-0' ? DefaultFocus : View

but <DefaultFocus> has a enabled flag so you can write:

<DefaultFocus enabled={id === 'rail-0'}>

adrianbunea avatar Sep 18 '25 11:09 adrianbunea

we were able to somehow nest virtualized lists, and its working correctly, it actually virtualizes. I'm going to share a reproducible example asap. btw, thanks @adrianbunea!

nriccar avatar Sep 18 '25 12:09 nriccar

Good job @adrianbunea, I even forgot that we implemented that! 😂

pierpo avatar Sep 18 '25 15:09 pierpo

we were able to somehow nest virtualized lists, and its working correctly, it actually virtualizes. I'm going to share a reproducible example asap. btw, thanks @adrianbunea!

Hi @nriccar, will you be able to share some details? I would also love to get it to work. Thanks!

Janjiran avatar Oct 01 '25 14:10 Janjiran

Hello @nriccar @Janjiran @vishu2124 @adrianbunea,

TLDR

Use a custom keyExtractor on the first layer of virtualization (the first virtualized list, not the nested one) to deactivate recycling of rows / columns

Disadvantage : When unmouting rows, we lose the active child (last focused index), so the user experience might be affected by this change when navigating up/down between rows

Investigations

I investigated the nested virtualization problem.

For now I identified 2 cases where it works :

  1. When all the parent virtualized lists are rendered: in the shared examples it means that all rows ('rails') are rendered, i.e. virtualization is disabled on first level. To do so, you can setup the additionnalItemsRendered to the length of data first layer.
      ...
        {/* Rails area (nested SNVL) */}
        <View style={{ height: SCREEN_H * 0.4, overflow: 'scroll' }}>
          <SpatialNavigationVirtualizedList
            orientation="vertical"
            data={data}
            itemSize={ROW_HEIGHT}
            scrollBehavior="stick-to-start"

            additionalItemsRendered={rails.length} // OR rails.length - 1 for jump-on-scroll scrollBehavior (see getAdditionalNumberOfItemsRendered.ts)

            renderItem={({ item: rail }) => (
              <SpatialNavigationView
                direction="horizontal"
                style={{ height: ROW_HEIGHT, width: SCREEN_W }}
              >
                <SpatialNavigationVirtualizedList
                  orientation="horizontal"
                  data={rail.items}
                  itemSize={ITEM_SIZE}
                  scrollBehavior="stick-to-start" // ← becomes inconsistent when nested with many vertical rails
                  renderItem={({ item }) => (
                    <SpatialNavigationNode>
                      <Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
                    </SpatialNavigationNode>
                ...
  );

It significantly degrades performance so I recommend the second approach

  1. Deactivate recycling of rows ('rails') using the deprecated keyExtractor prop :

export const NestedVirtualizedListsPage = () => {
  const data = useMemo(() => rails, []);

  return (
    <Page>
      {/* Rails area (nested SNVL) */}
      <View style={{ height: '100%', width: '100%' }}>
        <SpatialNavigationVirtualizedList
          orientation="vertical"
          data={data}
          itemSize={ROW_HEIGHT}
          scrollBehavior="stick-to-start"

          keyExtractor={(index) => `${index}`} // This keyExtractor deactivates row recycling by giving each row a unique key -> its index

          debug
          renderItem={({ item }) => (
                    <SpatialNavigationNode>
                      <Tile id={item.id} width={TILE_W} height={ROW_HEIGHT - GAP} />
                    </SpatialNavigationNode>
                ...

The keyExtractor prop sets the key of react components to update performance Here is a definition of component recycling, to keep in mind

Rows should not be recycled : we want them to mount / unmount when the virtualization window moves

Disadvantage : When unmouting rows, we lose the active child (last focused index), so the user experience might be affected by this change when navigating up/down between rows

Next steps

I will keep on investigating this to search another solution (maybe a better one). If not, we should unmark the keyExtractor prop as deprecated and add some docs + a nested virtualizedLists implementation in the example package

AlecColas avatar Oct 23 '25 14:10 AlecColas