Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(posts): TVJS scroll performance enhancement #458

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions _data/authors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ m_alves:
m_benali:
name: Marwa Ben Ali
avatar: /images/avatar/m_benali.jpg
m_bernier:
name: Maxence Bernier
avatar: /images/avatar/m_bernier.avif
m_blanc:
name: Maxime Blanc
avatar: /images/avatar/m_blanc.jpeg
Expand Down
128 changes: 128 additions & 0 deletions _posts/2024-11-22-tvjs-scroll-performance-enhancement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
layout: post
title: How we improved scroll performance on Smart TV apps
description: From an R&D project came a new scroll implementation for our Smart TV apps, with better performance and experience.
author: [m_bernier]
tags: [TV, performance, javascript, react, web, frontend]
color: rgb(251,87,66)
---

One of the core experiences of a Bedrock app for the end user is browsing the catalog. Scrolling vertically through blocks of content, and scrolling horizontally through lists of items. However, TVs do not offer high performance and provide poor user experience during heavy resource actions. We especially noticed that scrolling horizontally in a list was laggy and unpleasant. This article focuses on performance optimization to enhance the horizontal scroll experience on Smart TVs.

![Laggy scroll video](/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/old-scroll.gif)

_Note : The GIF above shows a laggy scroll experience on TV. During the videos featured in this article, a x20 cpu throttle has been enforced on the browser, to mimic a low-performance TV device_

# [Context](#context)
On TV, we scroll horizontally by focusing each item sequentially when the user presses the left or right arrow button on their remote.

Scrollable lists can be of various sizes and even include paginated content. In cases of paginated content, the next page is fetched preemptively during scroll, when the focus reaches a certain threshold.

Our old scroll component worked as follows: we would render a whole list of items, in a parent component handling scroll. When scrolling horizontally, the focus would switch to the next item. This would notify the parent component in charge of scroll, that would move the whole list laterally. The movement was computed from the measurements of the focused item, the size of the list, and the size of the container.
BernierMaxence marked this conversation as resolved.
Show resolved Hide resolved

There are multiple chances for improvement in this implementation:

1. Since every item was rendered in the DOM, moving the whole list was very heavy. Subsequently, a whole page of lists was itself pretty heavy to render.
2. Because the whole list is rendered, fetching a new page means that the new items are immediately all rendered to the DOM, imposing a heavy load to display content that is not even on the screen.

# [Virtualization](#virtualization)
To address the first shortcoming of the initial approach, we introduced virtualization. Virtualization is a technique to render only the items that are visible on the screen.

For context, the content we display on each list is normalized and stored in a redux store. All the items are available in an array and can be selected by their respective index.

```javascript
const ItemComponent = ({ position }) => {
const item = useSelector(selectItemByIndex(position));

return <Item {...item} />;
}
```
The virtualized scroll renders items based on a static array, each cell of the array being a slot for the item it’s going to display.
```javascript
const ScrollComponent = () => {
const SCROLLER_BASE_ARRAY = Array.from(
{ length: nbItemsToDisplay },
(_, index) => index - 1
);

return SCROLLER_BASE_ARRAY.map(index => {
const [focusOffset, setFocusOffset] = useState(0);
// focusOffset is a state updated upon user input:
// + 1 when the right arrow is clicked, -1 when the left arrow is clicked
const position = index + focusOffset;


return (
<ItemComponent
key={position}
position={position}
/>
);
});
}
```

![Schema representing 4 empty slots](/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/empty-slots.avif)

Each cell is connected to the store and uses its own index as selection parameter to get the corresponding item in the store (cell of index 0 gets the first item, cell of index 1 gets the second, etc.)

![Schema representing 4 slots with rendered items inside](/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/filled-slots.avif)

At this point, only a subset of the list is rendered, as many items as the static array has cells.

Horizontal scroll is managed by incrementing the selection index upon user input (e.g., pressing the right arrow key). Using the same array, each cell now selects from the store the item for its index plus an “offset” that describes how much the list is scrolled.

![Schema representing 4 slots with rendered items inside](/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/filled-slots-with-offset.avif)

By offsetting the items at every user input (negative offset to move the items to the right and positive offset to move the items to the left), we achieve a visual scroll, with only a subsection of the list displayed on screen.

![Animation showing a scrolling list.gif](/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/scrolling.gif)

# [Optimised Rendering with React Keys](#optimised-rendering-with-react-keys)

The heart of the implementation is to fill each cell with a new item at each scroll. From the point of view of a single cell, when we scroll, the item it displays is new. But we know that the item already existed in the DOM, just one cell over.

This is where we can leverage [React's keys mechanism](https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key). Items rendered use a key that combines the original cell index with the current scroll offset. These keys help React reconcile the item in cell 1 before render as the item in cell 2 after render as the same item, thus reusing the same DOM node. As a result, we get 0 re-renders for the items that are shifting places, significantly reducing the performance impact of a scroll.

<figure>
<img src="/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/profiling.avif" alt="profiling flame graph"/>
<figcaption>☝️Profiling during a single scroll right. The only items rendering are the ones with focus change (item losing focus and item gaining focus) and the new item that wasn’t on the screen. Every other item is unaffected by a horizontal scroll</figcaption>
</figure>
---

# [Optimised pagination](#optimised-pagination)

A nice win from virtualization is the impact on pagination. Only a subset of items is rendered on the screen. Also, the list itself only needs to know about that subset of items since it uses a constant array to display its items. This means that a new page fetched has 0 impact on renders: the new items are added to the store, but the React component itself has no knowledge of that operation and triggers no re-renders.

# [Results](#results)
_Note: measurements presented here are taken with the Chrome DevTools performance tab, with x6 CPU throttle and network connection limited to fast 4G to mimic a low-performance TV device and keep a steady test environment. Times are scripting and rendering times added._

We can compare a few benchmarks to exhibit the gains from the new scroller.


Scrolling right is obviously less expensive now. Here, measurements were taken from a single scroll right, in a 72 items list.

|Before|After, with new scroll|
|-|-|
|462ms|41ms (-91%)|

But more closely to the app's actual use, here is a scenario measuring the cost of scrolling right through a list of 72 items, with 8 pages fetched during scroll.

| Before | After |
|-|-|
| 11615ms | 8631ms (-26%) |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Le top du top serait d'avoir un .gif avec les deux cas où on peut voir la différence 😇

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

J'ai essayé, mais un gif c'est pas du tout représentatif : on se ne rend pas compte de la vitesse à laquelle on appuie sur la flèche, et il y a pas beaucoup de frames donc ça ne capture pas le lag 😬

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah oui, dommage 😢
C'est toujours cool d'avoir le petit effet visuel avant/après mais tant pis 😅

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je vais essayer !


Here, we include everything else a list does when scrolling (fetching new pages, additional handlers...), so the gain is less, but still significant.

Scrolling down in a page with lighter lists is also more efficient. Here, measurements were taken during a scroll down 25 lists.
BernierMaxence marked this conversation as resolved.
Show resolved Hide resolved

| Before | After |
|--------------|------------------------|
| 1308ms | 1038ms (-21%) |

Beyond benchmarks, on-device tests were also conclusive: the scroll is smoother, we almost eliminated the lag caused by a pagination fetch. Overall, it feels better to scroll through a list.
# [Conclusion](#conclusion)

![New and more fluid scroll](/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/new-scroll.gif)

This frontend R&D project successfully addressed the scrolling performance issues on TV. The new scrolling solution dramatically improved performance by limiting re-renders. This optimization ensured a smoother scrolling experience, enhancing usability on TV devices. From this experience, we also moved on to implementing the same virtualization on the horizontal scroll of the catalog, which presented its own challenges but was also a success.
Binary file added images/avatar/m_bernier.avif
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading