ReacTV
Veröffentlicht von InterVenture am November 4, 2021Author: Bojan Todorovic, Senior Frontend Engineer @Redbox
Vizio, LG, Samsung, PS4, PS5, Xbox, VewD.
What do all these platforms have in common?
Yup, that’s right, React! All of these devices support web apps, and React is the web king.
At Redbox, a streaming service you might not have heard of, we run React on all of these platforms, from a single codebase.
Now you might think „oh, so it’s just a regular web app, okay“.
And you would be correct, up to a point.
But let’s go beyond that point.
Challenges
There are a couple of challenges when developing TV web app that you don’t generally encounter doing „normal“ web apps.
- Ancient browsers
- Spatial navigation
- So many platforms
- Performance
Some of these are TV specific, but some can be applied to improve any web app. So, don’t worry if you’re not starting a TV web app project tomorrow, might still find something for you below.
Ancient ones
Browsers on TVs can be old. Old like Chrome v38(latest is v94), Opera v36(latest is v80), old non-Chromium Edge, Safari 6, etc. And most of them are not vanilla browsers, but platforms built on top of these browsers. Meaning there’s always some custom code in there too, potentially making compatibility even more painful. We come well prepared in the web world to deal with this, however. Most of the time browserslist
will take care of it.
Still, two main issues can arise here:
- CSS – it can be a pain anywhere, but we all know old browsers are especially volatile.
- Transpilation – it is generally the practice to exclude
node_modules
from transpilation, as it decreases build time significantly. However, you may find for TVs that many modules over time drop support for browsers you simply have to continue supporting. You can include the wholenode_modules
in transpilation, but we’ve found including only a handful of modules with the issues works well.
include: [ path.resolve(__dirname, 'src'), { include: path.resolve(__dirname, 'node_modules'), or: [/wonka/, /vtt-to-json/, /serialize-error/, /joi-browser/, /whatwg-fetch/], }, ],
Alternatively, there are tools like are-you-es5 that you can try out.
Spatial navigation
Besides your regular mouse and keyboard, TVs work with remotes.
There are modern „magic remotes“ that function almost the same as the mouse.
But the classic remote requires navigating by arrow keys around your UX, or as commonly referred to, „spatial navigation“.
There is nowadays this library for React react-spatial-navigation
However, one safe and secure way is to build your own React wrapper around the tried and tested Mozilla’s open source spatial navigation.
And we have done just that.
So many platforms
Supporting all the browsers on the web from a single codebase is a pain, but much less pain the doing it with all of TVs.
For regular web apps, besides a browserslist
, you might need an if
to apply different styling or similar here and there, but that’s about it.
TVs, on the other hand, are platforms built on top of browsers, and this is where the difficulty lies.
All of these platforms will have different ways to handle remote keys, TV specific events, to get device info, playback, etc.
There are a lot of ways to elegantly handle this platform specificity in a codebase and make it less painful.
Here’s one:
Let’s say you want to exit the application when exit button is pressed on the remote. So you do this:
import { exitApplication } from '../../utils/device/device'; // .... call exitApplication in some event handler
But, the trick is, every platform has it’s own way of handling application exiting.
So, we make a device folder with the structure:
/device |- device.lg.js |- device.tizen.js |- device.xbox.js |- device.vizio.js
And we make a little webpack magic.
Note that we have separate build script for every platform, so application is aware where it’s being run by build script passing env.platform
variable.
function platformizeExtensions(platform, extensions) { return [...extensions.map(extension => `.${platform}${extension}`), ...extensions];
And in your webpack.config.js
resolve: { extensions: platformizeExtensions(env.platform, [ '.mjs', '.js', '.jsx', '.scss', ]), },
For LG, this will make extensions look like this:
['.lg.mjs', '.lg.js', '.lg.jsx', '.lg.scss', '.mjs', '.js', '.jsx', '.scss'];
This way, doing import { exitApplication } from '../../Utils/device/device';
will import from device file for the platform, ie on LG it will import from device.lg.js
. Problem solved.
Naturally, one caveat of this is that every device.*.js
will have to export methods with the same name, otherwise you might encounter an error trying to import something that doesn’t exist on some platforms. Ie all of our device files have the same signature:
export const getDeviceId = () => {}; export const getOSVersion = () => {}; export const exitApplication = () => {}; export const isTTSEnabled = () => {}; export const isLowEndDevice = () => {};
And we do the same with eg. keyCodes
, since most platforms have keys on the remote dispatch onKeyDown
event with their own custom set of keyCodes
.
But, this little trick can have more use cases than just TV web app development.
One advantage of this approach over classical if
or switch
is that code in modules for other platforms is never imported, and therefore shaken off by webpack at bundling time, reducing bundle size.
Performance
You might have heard of „you need to watch for performance, mobile devices are low powered“. That is certainly true, until you encounter a new beast, a TV device. Premium TV devices will probably be on par with mid range phones, which is great. But budget TVs are more on par with a calculator. I’m talking couple of hundred MHz processing power and 1GB or less RAM, shared with the operating system too. Even a powerful platform like PlayStation, only allocates a small amount of resources to a web app, so in practice is also very low powered.
So, it’s clear, you need to watch for performance, and not just like an afterthought.
That, however, involves multiple layers, not just React.
Let’s go over some of the stuff you can do to preserve optimal experience on low end devices.
Measuring
A good starting point is always to continually run your app through well established performance measuring tools. No single tool that I know of has everything regarding exposing performance flaws in your code, but a combination should do. These tools are great for pointing out weak spots in terms of performance, and even suggesting improvements.
I’d mention:
- Lighthouse, Webpagetest, etc These ones do it from a simulated user perspective, what might be called „end to end“, on a web app level. This is what you always want to have. But, they don’t precisely point out flaws in your React code, so there’s still a gap for another tool.
- React profiler Great for measuring and pointing out where you have performance bottlenecks in your React code. An absolute must.
Ideally, you’d want one of these tool in CI/CD pipeline. But, we found that manual checks will always be required.
Assets
- Fonts – trying not to load huge file sizes for fonts is always sensible. For optimization, try preloading fonts with
<link rel="preload" as="font">
and avoiding flash of invisible text while fonts are loading by using font-display API, iefont-display: swap;
- Images – ideally use
webp
format, and keep images as small as possible by loading in only what you need in terms of resolution. Ie, if user is on mobile, and image is displayed in ie 320×160, don’t load huge image for desktop and resize it in-browser. This can be achieved by tools like Thumbor. - Compression – gzip your data sent over network, that goes for API data and for JS/CSS files(which should be minimized too)
Preconnecting to relevant domains
Any app nowadays is bound to fetch a lot of stuff from other domains. Things like data from your APIs, images from image server, etc. Preconnecting to these domains or doing DNS prefetch might improve load time somewhat. Learn the differences between these two and have them in mind as tools at your disposal
<link rel="preconnect" href="https://example.com"> <link rel="dns-prefetch" href="https://example.com">
Prefetch/preload, async/defer
Another set of tools that might come in handy is preload and prefetch. Also, script async and defer. Again, learn the differencies between these, so you’re aware if and when to use them.
<link rel="prefetch" href="/bundle.js"> <link rel="preload" href="/something.chunk.js"> <script defer src="./script.js"></script> <script async src="./script.js"></script>
Reflow vs Repaint
While this is somewhat advanced and you might not need it on a daily basis, learning the concept of browser repaint and reflow cycles might further expand your horizons when pondering performance. And for general web performance overview, MDN is always a good starting point.
Code splitting
Code splitting with React and bundlers like webpack is extremely easy to setup, and you should almost always use it.
The most sensible way to start with is usually splitting your routes and maybe some parts of the application that are not accessed very frequently by users.
const Library = React.lazy(() => import( /* webpackChunkName: "library" */ /* webpackPrefetch: true */ './Components/Library/Library' ) );
Watch out for async/await
We all know async/await is great, right?
But one thing I noticed it has lead to, is the pitfall of sequential code where none is needed.
It’s not once that I’ve seen in the wild code that awaits something, while there’s code below hanging in there, even though it does not have to.
async componentDidMount() { const genres = await fetchGenres(); this.setState({ genres }); const isPlatformReady = await platformReady(); if (isPlatformReady) { this.setState({ isPlatformReady: true }); } }
In the case above, there’s no reason for anything below line 3 to wait for genres to fetch.
Beware of sequential code, folks.
React components
Performance wise, React is great. But, there are still stuff to watch out for. Here’s some:
React.memo
should be used sparingly. Sure, it sounds great in theory, but in practice it can easily prove „more trouble than it’s worth“. Eg. if a component has large number of props, might be the same or even faster to just let it re-render instead of making a costly check against all those props. Always make sure when introducing it to actually check in the profiler whether you’re getting something out of it.Context
is always somewhat costly to use. Make sure it’s not overused. Prop drilldown isn’t ideal, but it might save you some performance hits of having every component ever connected to global state management. One problem we encountered was withstyled-components
a couple of years ago, when we started the project. Not sure about now, but back then it used context for every single styled component. Needless to say, we noticed performance hits, and quickly switched to good old sass.useMemo
anduseCallback
are generally worth it, with some exceptions.useMemo
is great for your stuff that is derived from props/state anduseCallback
for your functions in components. Main thing to watch out for here is using these if their dependencies change too often. Ie, if you’re memoizing function reference withuseCallback
, but it’s dependency is ieinputValue
which changes on every key press. In that case,useCallback
just slows you down, as function reference will change anyway because of constantly changing dependency, you’re just introducing memoization on top of recreating the function.
Virtualization
There are many great open source libraries for React which handle virtualization and lazy loading of components in lists.
Most notable being react-virtualized.
These are generally easy to setup and use, and solve almost all your problems of slow rendering in long lists of components.
However, because of spatial navigation, none of them satisfy our needs on TVs.
So, we built our own virtualization that works well for us, although we can’t say we’re too happy about having to alocate time for that.
Fortunately, if you’re not running your web app on a TV, this is a problem you won’t encounter.
Conclusion
And that about covers the main stuff.
Sure, there’s also stuff like video playback, which is an epic narrative on it’s own.
The accessibility, TV vendors usually have mandatory requirement for TTS accessibility in apps. That’s where we learned the hard way that WAI-ARIA standard is not much of a standard and that imperative TTS is much more maintainable.
And don’t get me started on development experience TV vendors provide, or we might be here all day.
But, these are stories for another time.
Nevertheless, if you find it interesting and would like to know more, take a look at the open positions and opportunities in Redbox team.