Have I mentioned that it’s a nightmare to install React Devtools in Electron? Turns out, it’s worth it. But it’s still a pain. If you want to try, you’ll need to first install the extension in a chrome-based browser (like Brave, the one I use) and then install it by code. At Chlorine, what I did was to copy the whole extension folder to a devtools
directory and then changed the script to load electron to the script below (see the BrowserWindow.addDevToolsExtension
):
const path = require('path') const {app, BrowserWindow} = require('electron') app.on('ready', () => { let browser = new BrowserWindow({ width: 900, height: 600, webPreferences: {nodeIntegration: true}}) browser.loadURL(path.join('file://', __dirname, '/index.html')) BrowserWindow.addDevToolsExtension('./devtools') })
Originally, the React Devtools was installed at ~/.config/BraveSoftware/Brave-Browser/Default/Extensions/fmkadmapgofadopljbjfkapdkoienihi/4.21.0_0
(tilde is my home folder). If you’re trying to install on Atom editor, you’ll have to open developer tools and issue the following script:
r = require('remote') r.BrowserWindow.addDevToolsExtension('./devtools') // or the right directory
This command will fail, but somehow when you reload Atom, the React Devtools will be there. There’s only one feature that I use – highlight which elements were updated. And that’s where our performance tuning starts again.
Combining elements
Turns out that in the last post, I’ve decided to combine elements. It works fine, but for “root” elements there’s a catch: as it’s a tree data structure, when I combine elements React loses the ability to individually update a single component. That means that if you render [[1] [2] [3]]
, and opens up the root, then tries to open [2]
(a sub-element), it’ll update THE WHOLE TREE instead of only [2]
. That’s a BIG performance hit when you’re rendering big structures, and a moderate one when you’re rendering medium ones. Suppose, for example, that you’re rendering about 500 elements. The whole process ends up in less than 300ms – not bad, really.
But then, opening every single element will also run in 300ms. If I didn’t do that, elements would open in 80ms or less. So, yeah – big performance hit. What now?
So I decided to try some approaches. All of the following code will use the same data: it’s the result of (->> (range 4000) (map (constantly ["1" 2 3])) vec)
(so, a vector with 3 sub-elements).
Don’t batch at all when it’s a “root” element
All interaction happens at the “roots” of the rendering. So, nothing better than just not batch when it’s in the root, right?
Turns out that this makes the “root” rendering REALLY slow, but the leafs REALLY fast. For the test data, that makes the root take 4.42s, and the leafs take 180ms. Great for the leafs, bad for the root. There’s also a 230ms garbage collector at the end of the rendering, so if possible I would like to avoid this option
Combining elements in batches
Instead of combining ALL elements into one, combine a group of elements – like, 500 – into one. This means that when you interact with the leafs, you’ll pay a performance price (because you’ll not only update the leaf, but also the other 499 elements) but it’s lower than combining everything. So I decided to try – and the code was really easy to do too (oh, the joys of ClojureScript) – instead of doing (->> elements (map #(html-for {:root? true} %)) (combine-leafs [:br]))
, I added a partition-all
:
(->> elements (partition-all 500) (map (fn [elements] [(fn [] (->> elements (mapv #(html {:root? true} %)) (combine-leafs [:br])))])))
This… was a completely disappointment. The rendering went down to 3.83s, with the same GC pause. But leafs now take 260ms to render. So I pay a high price to a low improvement on the root rendering. Not good at all.
Pagination
Creating the React elements is not slow – what’s slow is rendering them on the interface. With that idea, I decided to paginate results. When someone opens a very big element tree, what if it shows only the first 100 elements, and then add a link to show more? With the idea in mind, I decided to try. Again, it’s incredibly simple to do it – just add, on the current state, a key/value :loaded-elements
that defaults to 100
, and then use:
(->> elements (map (fn [e] [html {:root? true} e])) (take (:loaded-elements @state)))
And then add a link to update the state
, incrementing 500 elements. That’s all. With this, the root renders in astonishingly 457ms! Leafs take 85ms to render, and asking for “more” data takes 1.22s – the best performance for EVERYTHING so far! So, next versions of Chlorine (and Clover) will have a kind of pagination on ClojureScript, Clojerl, etc so they will render faster.
Other performance improvements
While I was doing all the improvements, I also tried other ways of combining leafs. The combination of leafs can be as complicated as I want, and while I could combine more, in the end it’s tricky, it would require A LOT of testing for a minimum performance improvement, and it would be a wasted effort considering that I’m only doing it when things are not expanded so…
But what I did try was to improve performance of some Clojure constructs that I know are slow. In the first version of combine-leafs
I used destructuring to get the first
and the rest
of elements. combine-leafs
is currently not used to render the tree-like structure anymore, so I just benchmarked the first rendering – I did not benchmark the open/close of the tree, nor the leafs. I did three different renderings, and compared their results. The first, unoptimized version, rendered in 1.35s, 630ms, and 550ms.
Then I tried to memoize
, equivalent?
– that’s one function that’s called multiple times for almost the same elements over and over. And it did change the render time – to 1.26s, 632ms, and 599ms – it got WORSE! But then I looked at the source of memoize
, and saw that it does use atom
. I tried to write my own version of memoize
using volatile!
, and now it did render in 1.13s, 545ms, and 495ms. So, even considering that atom
and volatile!
are almost the same in ClojureScript, it did make a difference!
I decided, them, to try different approaches to combine-leafs
. First, to avoid creating sub-elements by using transducers in Clojure. With this change, the whole process rendered in 1.04s, 550ms and 504ms – not much difference, to be honest… The last thing I did was try to refactor combine-leafs
to use vectors instead of regular collections – that way I could use subvec
and nth 0
instead of the slower versions of first
and rest
. This gave me rendering times of 1.05s, 560ms, and 487ms – the fastest implementation after everything “warms up”, but not that far from the original implementation – it’s not fast enough so that the user will see any difference, and also I lost a little bit of flexibility because combine-leafs
now needs a vector
every time, so it’s a tricky trade-off.
Throwing it all away!!! 😭
It was fun while it lasted, but in the end, React does not like what I did. Combining leafs that way made the whole process incredibly slow for every interaction except the first render, so I ended up just “streaming” the rendering process – that is, instead of (map <my-element> children)
, I did (take <n> children)
and then changed the n
inside a setTimeout
until it was equals, or greater, than the number of children I had to render. That made the first render take 400ms (REALLY fast) and every secondary render take about 500ms (also fast) in a way that users’ can’t even see the difference. So, that’s where it ended up.
So, while it was a fun experiment to work with pure data, combine different things, and try new ideas, the libraries are still JS – they don’t like what I did, and they also don’t understand what happened. If, instead of React, the code was some pure ClojureScript, that made diffs between hiccup elements instead of virtual DOMs, and somehow rendered only what changed on the real DOM, them probably that original technique could work. In fact – that may be an interesting idea for a library, for someone that wants to avoid react in the future?
In the meantime, it was fun knowing a little bit more about performance in ClojureScript, learning about bench-marking React apps, and be able to see how to debug rendering inside a react app – it was really fun, and it’s a tool I’ll surely use in the future.
So a little table to compare everything:
“Micro” optimizations on combine-leaf
, with percents compared to unoptimized version:
Item | 1st render | % | 2nd Render | % | 3rd Render | % |
---|---|---|---|---|---|---|
Unoptimized | 1,350s | 100% | 630ms | 100% | 550ms | 100% |
Memoize | 1,260ms | 94.00% | 632ms | 100.32% | 599ms | 108.90% |
Memoize w/ volatile! | 1,130ms | 84.32% | 545ms | 86.51% | 495ms | 90.00% |
Transducers | 1,040ms | 77.61% | 550ms | 87.30% | 504ms | 91.34% |
subvec | 1,050ms | 78.36% | 560ms | 88.89% | 487ms | 88.54% |
And for the code to render root and leafs in a faster way, all compared to the current implementation of Chlorine:
Item | Rendering root | % | Interacting with Leafs | % |
---|---|---|---|---|
Current version | 4362ms | 100% | 1471ms | 100% |
Don’t batch root | 4420ms | 101.33% | 180ms | 12.24% |
Combine batches | 3832ms | 87.85% | 269ms | 18.29% |
Pagination | 457ms | 10.48% | 85ms | 5.78% |
Pagination – 2nd page | 1221ms | 27.99% | 85ms | 5.78% |
1 Comment
Maurício Szabo · 2024-06-06 at 10:31
[…] is probably the last post about these improvements. At the last post, I showed how I threw every performance optimization I tried away (combine-leafs, other stuff, etc) […]
Comments are closed.