What's the Best React Grid Component?
We have a few options for how we include jQuery and a few considerations:
Considerations
- jquery-ujs 13 monkey patches $.ajax to send the CSRF parameters. For more info on this, see: Rails Guides on CSRF 13. This is the key file that gets inserted: rails.js 5. This code uses the jQuery.ajaxPrefilter 4 from this line 6. If this file is not loaded in the Rails
application.js
, then the CSRF token is not added when when$.ajax
(or it’s friends, like$.get
) is called. This is part of the jquery-rails gem 21. Note, there is no separate gem forjquery-ujs
. However, there’s a separate github repo, probably so that it can be used by npm, but I’m just guessing. - We under NO CIRCUMSTANCES want to load 2 versions of jQuery. We want the version of jQuery used to be as transparent as possible. Is the reason obvious? Thus, it’s critical that we load jQuery from the gem, then we DO NOT load jquery in the webpack config.
- It turns out that there is a npm version of jquery-ujs 31!
#Options for jQuery Inclusion
A. jQuery loaded from Rails
Having jQuery included before loading the bundle from web, and specify jQuery as an “external” 43.
1. jquery-rails Gem
Use the jquery-rails gem 21 and be sure to specify what version of jQuery. This is somewhat obtuse, as it requires locking the jQuery version by the gem version. This is specified here 16.
2. jQuery from a CDN
Use the jquery-rails-cdn 10 gem. If we do that, we need to load it first in the Rails main application layout. This gem will use this version of jQuery:
jQuery version is automatically detected via jquery-rails
B. jquery-ujs and jquery from npm
Let’s load both jquery-ujs and jquery in our webpack configuration. The advantages to doing this:
-
It’s clear what versions we’re using as they specified just like the other dependencies. This is the way we’re handling all other 3rd party JavaScript, so let’s be consistent.
-
No chance of accidentally having a different version loaded from both Rails and Webpack.
-
We simply need to expose jQuery as global so that other JavaScript or CoffeeScript code in the Rails project that’s not using the Webpack config can find
jQuery
and$
. This is documented for Webpack 27: -
We need to expose the jquery-ujs part, through an addition to the entries so this gets loaded by webpack (PENDING EXAMPLE).
-
You need to use the expose-loader.
npm install expose-loader --save
You can either do this when you require it:
require("expose?$!jquery");
or you can do this in your config:
loaders: [
{ test: /jquery\.js$/, loader: 'expose?$' },
{ test: /jquery\.js$/, loader: 'expose?jQuery' }
]
We will very shortly have this as an example here: https://github.com/shakacode/react-webpack-rails-tutorial/issues/51 92.
Questions
- How useful is the CDN for jQuery performance wise?
React version: 18.1.0
Summary
React seems to cause Chrome to leak core Blink (renderer) memory when a large number of divs are mounted. Significantly more memory is leaked than when an equivalent script is run using vanilla JS.
In the following reproduction, 100k divs are mounted at a time. We loop through 50 "pages", each with 100k divs. We then render a blank page (or multiple pages with a single div, to ensure that React isn't holding onto the prior tree).
React Version:
400 MB of total "Memory Footprint" is retained. Note that the JS Memory usage is only 40MB. (Also note that the browser's total memory usage peaks significantly higher while these massive pages are loaded.)
(Screenshot from Chrome's "Task Manager")
In another run, 300 MB of total "Memory Footprint" is retained. Note that the live JS Memory usage is only 15MB.
Pure JS Version:
160 MB of total "Memory Footprint" is retained. Note that the JS Memory usage is only 2MB.
The browser is thus retaining an extra ~200MB (~300MB / 400MB vs ~150 MB) of memory when React is used.
Context:
I've dug through the JS Memory Heap dumps for both versions, but can't find a clear explanation for why Chrome is holding onto more memory here.
The issue still occurs when les divs are mounted, but mounting more divs makes the pattern easier to see.
I believe that this issue is the root-cause of a real-world issue I've been having in another application. In the real application we see an even bigger leak of > 1GB. In the following screenshot 1.5GB of total "Memory Footprint" is retained, while only 400MB of JS Memory is used.
Hypothesis:
I'm in contact with a Chrome-team developer who ran a separate reproduction script for our real-world application, and they hypothesized that Chrome was allocating a large amount of styles here. However, the issue still occurs when all CSS is removed. Is it possible that React is causing extra styles to be attached to these divs somehow? (Not sure if this is a red-herring or not. This might be a separate issue with our application.)
Quote from someone on the Chrome team:
I ran it through chrome://tracing and it looks like there are a lot of Blink (renderer) objects being allocated on PartitionAlloc, which is the allocator used for non-GC'd objects within the renderer. These tend to be performance-sensitive classes like strings, vectors, DOM nodes, style data, etc. The memory dump lists the bulk of this memory as (not web_cache, font_caches, site_storage, or parkable_strings).
I observed several OOM crashes while doing this, and the traces uploaded to Google's internal crash database all had StyleInheritedVariables::Copy() which doesn't necessarily mean that there's a leak there, but suggests that the app is causing a lot of copies of this object to be made. This object is allocated on PartitionAlloc, so that's a bit suspicious. I believe this is also not reflected in the JS heap size, so that's consistent with your observations. This object is used for the CSS Properties and Values API, in particular for storing custom properties that allow inheritance. Below is the relevant portion of the call stack. You can look up the code on source.chromium.org if you want. I took a quick look but all I can tell is that it probably has something to do with setting, changing, inheriting, or registering a CSS variable. I don't know if merely invoking var() causes the copy to be made.
So as a first step, I'd remove any uses of inherited CSS custom properties and see if that changes anything.
Steps To Reproduce
Note that you must give time for Chrome's GC to kick-in after the test stops. The test will take a few minutes to run.
Warning: These load heavy pages
- React Reproduction: https://mfranzs.github.io/react-leak/
- React Code: https://github.com/mfranzs/react-leak/blob/main/src/App.js
- Pure-JS Reproduction: https://mfranzs.github.io/react-leak/pure-js.html
- Pure-JS Code: https://github.com/mfranzs/react-leak/blob/main/docs/pure-js.html
React:
function App() {
const [i, setI] = useState(0);
// Re-render this component every 100ms
useEffect(() => {
setInterval(() => {
setI((curI) => (curI += 1));
}, 100);
}, []);
// 50 times, render a large list of 100k divs
// We don't construct this large array on the initial run so that our `useEffect` closure
// doesn't accidentally include a large `c` array.
let c = [];
if (i > 2 && i < 50) {
for (let j = 0; j < 100 * 1000; j++) {
c.push(
<div>
{j} - {Math.random()}
</div>
);
}
}
return (
<div className="App">
Memory Test - {i}
<br />
{c}
</div>
);
}
Pure JS:
<title>100k Divs Vertically, then Clear</title>
<div id="root"></div>
<script>
const root = document.getElementById("root");
let i = 0;
const int = setInterval(() => {
console.log(i);
for (const child of [...root.childNodes].reverse()) {
child.remove();
}
for (let j = 0; j < 100 * 1000; j += 1) {
const d = document.createElement("div");
d.innerText = "j" + j + "-" + Math.random();
root.appendChild(d);
}
i += 1;
if (i == 100) {
clearInterval(int);
root.remove();
}
}, 100);
</script>
This happens for any local React App, but I've provided a small CRA in case any automation is done.
- Visit the locally run app on Firefox Browser. In my case, v101.0.1 (64-bit)
- Set up open in editor URL in settings, set up open in Editor URL. I used
"vscode://file/{path}"
- On the Components pane, select any element in the page
- Click on "Open in Editor" button
Actual result:
Nothing happens. The link does not open in my editor
Desired result:
The link should open in my VSCode editor
I think the reason why this is happening is because of a Firefox bug where window.open
does not work in extensions. It is used in InspectedElement.js to open the source url. According to https://bugzilla.mozilla.org/show_bug.cgi?id=1282021, the extension should use browser.windows.create
for Firefox
React version: 18.1, probably 18.2 as well but can't chose it in CodeSandbox
Exhibit A
Steps To Reproduce
- Open https://codesandbox.io/s/nice-danilo-ej7mlr?file=/src/App.js, click on "Add Resource" to create resource
The current behavior
This is a variation on the Concurrent Mode documentation sample, but without the Suspense wrapping the consumer of resource. Clicking on the button sets state of the FirstChild component, resource
variable is truthy and SecondChild is rendered, which reads resource and throws out a promise, and suspends (is this the right term?) both FirstChild and SecondChild as SecondChild is not wrapped in Suspense. After the promise is resolved, both FirstChild and SecondChils are rendered.
The expected behavior
According to the documentation, previous issues and common sense (setting state is not inside Transition), both FirstChild and SecondChild are unmounted when Suspense shows fallback, so state of the FirstChild should be reset. Instead, it somehow preserves the resource inside state and renders SecondChild.
Exhibit B
Steps To Reproduce
- Open https://codesandbox.io/s/funny-pateu-z4cr8t?file=/src/App.js, click on "Refresh"
The current behavior
This is a modification of the previous sandbox, but resource is first created outside of FirstChild and can be refreshed, without Transition and even inside setTimeout so that React can't be smart and track it as event handler. Additional state randomValue
is present that is preserved after unmounting and mounting again.
The expected behavior
As previously, I expect that state is reset, and randomValue
is a new number on every refresh. Same result if using useMemo
or useRef
.
Exhibit C
Steps To Reproduce
The current behavior
Code is stuck in an infinite loop. A new resource is created not outside of component and not inside event handler, but during render. With everything else equal, and contrary to the previous two examples, it (apparently) does not save the state that has resource but resets it.
The expected behavior
Biiig question mark here. My expectations is that components inside Suspense are fully unmounted and lose all state, hence we need to either create resources outside of render tree, or place state with resources above Suspense, aka wrap children in additional Suspense. But that does not seem to be the case in the first two examples. Which means I have no idea how it should behave and if it is undocumented feature or a bug. Also, useEffects
are also not running after unmounting-and-mounting, but useLayoutEffects
do, which seems in line with React 18 RFC.