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

Merged
merged 29 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
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: https://ca.slack-edge.com/T108ZKPMF-U01AX1WF1UZ-e7a81d9b8dec-512
Copy link
Contributor

Choose a reason for hiding this comment

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

Je crois que c'est mieux d'utiliser une photo de chez nous plutôt que depuis slack

m_blanc:
name: Maxime Blanc
avatar: /images/avatar/m_blanc.jpeg
Expand Down
115 changes: 115 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,115 @@
---
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)
---

A core experience of a Bedrock app for the end user is browsing the catalogue. Scrolling vertically through blocks of content, and scrolling horizontally through lists of items. TVs do not offer high performance and provide poor user experience during heavy resource actions. Namely, we noticed that scrolling horizontally in a list was laggy and unpleasant. This article focuses on performance optimization to enhance the horizontal scroll experience.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
A core experience of a Bedrock app for the end user is browsing the catalogue. Scrolling vertically through blocks of content, and scrolling horizontally through lists of items. TVs do not offer high performance and provide poor user experience during heavy resource actions. Namely, we noticed that scrolling horizontally in a list was laggy and unpleasant. This article focuses on performance optimization to enhance the horizontal scroll experience.
One of the core experiences of a Bedrock app for the end user is browsing the catalogue. 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 on TVs. This article focuses on performance optimization to enhance the horizontal scroll experience.

because I felt that this block was not easy to read/understand

Copy link
Contributor

Choose a reason for hiding this comment

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

I would add "on TV" at the very end of the paragraph. Yes it's a repetition, but makes the goal of the article 100% clear.


# [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.
BernierMaxence marked this conversation as resolved.
Show resolved Hide resolved

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, a new page fetched means the new items are immediately all rendered to the DOM when a new page is fetched, imposing a heavy load to display content that is not even on the screen.
BernierMaxence marked this conversation as resolved.
Show resolved Hide resolved

# [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 of the list is stored in a redux store, normalized: to select a specific item from the store, all you need is its index in the array of items for the corresponding list.
Copy link
Contributor

Choose a reason for hiding this comment

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

Rephrase

Suggested change
For context, the content of the list is stored in a redux store, normalized: to select a specific item from the store, all you need is its index in the array of items for the corresponding list.
For context, the content we display on each list is normalized and stored in a redux store. All the items are 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.png)

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…)
BernierMaxence marked this conversation as resolved.
Show resolved Hide resolved

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

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.
BernierMaxence marked this conversation as resolved.
Show resolved Hide resolved

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

By switching items to a cell over at every user input, we achieve a visual scroll, with only a subsection of the list displayed on screen.
BernierMaxence marked this conversation as resolved.
Show resolved Hide resolved

![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. 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.
Copy link
Contributor

Choose a reason for hiding this comment

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

Un petit saut de ligne pour couper le paragraphe et fluidifier la lecture et un peu de wording
Peut-être un petit schéma aussi pour l'explication des clés vu que c'est très technique 😄 ?

Suggested change
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. 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.
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.
Rendered items 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 rendering as the item in cell 2 after rendering 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Je sais même pas quoi faire comme schema 😆
Faute de mieux pour l'instant, j'ai mis la doc de React sur les keys :itssomething:

Copy link
Contributor

Choose a reason for hiding this comment

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

Oui c'est vrai que c'est pas simple à illustrer ça 🤔
La doc suffira j'imagine 🤷


<figure>
<img src="/images/posts/2024-11-22-tvjs-scroll-performance-enhancement/profiling.png" alt="profiling"/>
<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 unbothered 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 are 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
Contributor 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
Contributor 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

Beyond benchmarks, on-device tests were also conclusive: the scroll is smoother, we almost eliminate the lag caused by a pagination fetch. Overall, it feels better to scroll through a list.
Copy link
Contributor

Choose a reason for hiding this comment

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

🥖 It could be nice to have video before/after from a device to visually see the improvment, what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Videos are tough : since you don't have the information of how fast the arrows are pressed, or the delay between the press and the scroll on screen, it's hard to really gauge the performance
I'll try, see what I can get, but I remember struggling with this when I first sent the scroller to tech review 😄

# [Conclusion](#conclusion)
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 catalogue, which presented its own challenges but was also a success.
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.
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.