reagent

shaunlebron 2025-08-23T15:13:02.494269Z

We blew up Chrome with a memory leak that we traced to [:tr {:key (hash [i a-large-map row-stuff])}], even if the tr had no children, just the key. It was fixed by using just :key i. Any intuition on why?

shaunlebron 2025-08-27T16:50:41.069539Z

Here’s the simplest way I can reproduce the memory leak: https://github.com/shaunlebron/reagent-leak/blob/main/src/foo/core.cljs I published a project there for running it.

Roman Liutikov 2025-08-27T16:52:10.810599Z

@shaunlebron what version of React are you using?

Roman Liutikov 2025-08-27T16:53:19.606779Z

there's a bug in React prior to v18, more details here https://clojurians.slack.com/archives/C0620C0C8/p1732303133856729

👀 1
shaunlebron 2025-08-27T16:53:35.662139Z

16.13.1

Roman Liutikov 2025-08-27T16:54:19.661649Z

then I'm 99% sure it's React, try to repro it with v18

shaunlebron 2025-08-27T16:55:42.708129Z

great, thank you! should I update reagent as well? I guess we’ll see

shaunlebron 2025-08-27T17:06:06.051769Z

still happening in react 18.3.1, but console says I need to switch to createRoot. going through reagent changelog now

shaunlebron 2025-08-27T17:27:30.195869Z

@roman01la just confirmed it is still happening when updating the render method to React 18. pushed code to verify

shaunlebron 2025-08-27T17:28:03.339809Z

@p-himik pinging you in case you wanted to look at the MRE

Roman Liutikov 2025-08-27T17:30:12.756119Z

Interesting. The only thing I’d suggest for now is to run heap profiler before and after and inspect a diff to track down leaked memory

rolt 2025-08-27T17:51:11.612089Z

I tried your repro with chromium (I don't have chrome) and I'm not seeing a big memory leak. Heap meme is slightly increasing, (1MB in 5 minutes).

shaunlebron 2025-08-27T17:54:25.635709Z

@rolthiolliere thanks, what chromium version?

rolt 2025-08-27T17:59:14.504929Z

139.0.7258.138

shaunlebron 2025-08-27T18:03:53.090179Z

I guess I would have to build chromium myself to test this

shaunlebron 2025-08-27T18:04:07.843989Z

I filed a reagent bug here: https://github.com/reagent-project/reagent/issues/637

shaunlebron 2025-08-27T18:05:55.074379Z

@rolthiolliere what OS are you on?

rolt 2025-08-27T18:11:31.838129Z

manjaro

rolt 2025-08-27T18:16:35.871779Z

I don't have a memory leak with firefox either, but it's a lot slower than chromium. After about 50 updates it slows down to 1 "obj" update per second

p-himik 2025-08-27T18:22:58.660699Z

On my end there doesn't seem to be a leak as well. Firefox is 2-3x faster than Chrome. RAM usages ranges from sub-100 MB to 2 GB after which CPU usage spikes and RAM gets back down - I assume GC gets triggered at that point.

p-himik 2025-08-27T18:23:29.564419Z

@shaunlebron Have you reproduced it in a perfectly clean environment, in a fresh browser profile with default settings and no extensions?

shaunlebron 2025-08-27T18:26:41.386809Z

what os are you on? I can try uninstalling chrome and clearing everything out

p-himik 2025-08-27T18:27:15.622389Z

When running allocation tracking profiler, there are a lot of detached DOM nodes. But again - seems like they get cleaned up eventually. I'm on Ubuntu 24.04.3. I don't think you need to reinstall anything. Just try a new profile, you can do it via CLI.

p-himik 2025-08-27T18:28:34.178259Z

Ah, the large number is caused by, well, a large number of cells - every table has 10k of them. But while profiling for 20 seconds, only 7 tables were still detached, so it doesn't seem like a leak at all on my end - just a delay in GC for some reason.

shaunlebron 2025-08-27T18:32:20.412009Z

uninstalling chrome and removing my extensions fixed it

shaunlebron 2025-08-27T18:32:57.010889Z

@p-himik thanks

p-himik 2025-08-27T18:34:29.164339Z

That was definitely an overkill but glad that nuking everything fixed the issue. :) So maybe it's some extension that was tracking things. Hard to say why it could possibly be hash-related though.

🙏 1
p-himik 2025-08-23T15:17:29.145069Z

If i is the index, you're probably better off with not using a key at all and instead simply avoiding the lazy collection, if there's one. But now clue about the leak. I can only add that hashing a collection in CLJS mutates the object if hash has never been called before. But the equality and identity aren't changed in the process.

p-himik 2025-08-23T15:18:42.223129Z

If you can create a minimal reproducible example, it'd be curious to take a look at.

shaunlebron 2025-08-23T15:19:32.793239Z

the tr is inside a (doall (for [[i r] (map-indexed ...)] ...))

p-himik 2025-08-23T15:19:57.906369Z

Then you don't need :key at all.

p-himik 2025-08-23T15:20:21.813689Z

Well, maybe you do because of the type of the coll. But I'd just use mapv probably.

shaunlebron 2025-08-23T15:20:27.341619Z

I think I remember always seeing warnings about missing keys, maybe we’re on an older react and something changed?

p-himik 2025-08-23T15:21:57.643709Z

IIRC if the collection is lazy, there will be a warning. Even if the collection is fully realized. Ah, and mapv won't work because you need to "unsplice" the children. (into [the-parent ...] (map-indexed ...) ...) should work. Or use a fragment - [:<> ...] instead of the parent if there's now known parent at that point.

shaunlebron 2025-08-23T15:23:00.197249Z

oh I see, yeah I’ve not seen this pattern anywhere in our code

p-himik 2025-08-23T15:24:03.910829Z

Of course, the most appropriate solution would be to use natural unique keys, if they exist in the data.

p-himik 2025-08-23T15:24:16.794969Z

Then you don't need any non-lazy collections, or even doall.

shaunlebron 2025-08-23T15:25:01.779929Z

yeah I’m wondering why the original author chose hashing because we use it a lot for keys

p-himik 2025-08-23T15:25:55.347929Z

It could be that they just didn't know any better at the time of writing. 🤷‍♂️

shaunlebron 2025-08-23T15:26:58.076249Z

conceptually I think it’s to force the row’s vdom to be wiped clean if anything inside the row changes?

shaunlebron 2025-08-23T15:27:48.500909Z

I vaguely remember this causing problems with stale callbacks not being updated

shaunlebron 2025-08-23T15:28:43.592429Z

out of time for the day, but I’ll try a repro since this is the second time I’ve been surprised by reagent/react keys here and I’d like to avoid this again!

p-himik 2025-08-23T15:48:42.037559Z

A complete absence of keys would also do that. Or maybe things inside rows used form-2 or form-3 components and were effectively "caching" callbacks. Then IMO not using form-2 or form-3 components as immediate children of :tr would be a better solution.

lilactown 2025-08-24T18:17:53.740789Z

keys are required as long as the collection of elements is dynamic, whether or not it's lazy

lilactown 2025-08-24T18:18:11.903619Z

https://react.dev/learn/rendering-lists

lilactown 2025-08-24T18:19:42.908629Z

the key only needs to be unique for the given list, so using I is just fine, however it's often better to use something more specific to the thing being rendered (such as an entity ID) if applicable

p-himik 2025-08-24T18:20:44.720129Z

> keys are required as long as the collection of elements is dynamic, whether or not it's lazy What does "dynamic" here mean? > so using I is just fine, Don't the React docs themselves say that the index should not be used as a key?

lilactown 2025-08-24T18:29:00.498349Z

React's key prop is meant to optimize re-rendering lists where items may be added, removed or change order. It allows React to detect which elements have actually been changed and do specific DOM manipulation to only remove/swap/add elements that have actually changed. So if your collection of elements is "static", i.e. it will always be the same count and identity, then using (into [] ,,,) and avoiding keys is fine. But if your collection changes based on the state of the app, then it should use keys. For example:

(defn edit-items
  [items remove-item change-item]
  [:div
   (for [item items]
     [:<> {:key (:id item)}
      [:button {:on-click remove-item} "-"]
      [:input {:value (:value item) :on-change change-item}]])])
In this component, because we pass a key, if we click the minus button React will know which specific item in the list has been removed and will remove that element from the DOM, preserving the rest of the list. This means that DOM state like focus will be preserved, and it will be faster. If you didn't have a key here, then React would receive a new list that was one shorter, but it wouldn't know which element to remove so it may remove the whole list and re-add it to the DOM, which is slower and will blow away your DOM state.

lilactown 2025-08-24T18:32:44.895049Z

> Don't the React docs themselves say that the index should not be used as a key? The reason for this is because for instances like the example above, the index will change for elements that haven't in fact been changed, causing unnecessary changes to the DOM. Sometimes, in rare instances, the index is the "entity ID". I can't tell with what the OP posted if that's the case or not, but it's certainly just as much - if not more - "correct" than using the hash of [i some-map]

lilactown 2025-08-24T18:34:17.649079Z

I think this is stuff you already sort of said above p-himik, just answering the question for shaunlebron why this would be better than no keys at all

p-himik 2025-08-24T18:37:55.788639Z

> React's key prop is meant to optimize re-rendering lists where items may be added, removed or change order. Ah, I see what you meant, yes. But isn't there still a performance benefit from using keyed collections in contexts when only the items themselves change - no insertion/deletion/swapping? > The reason for this is because for instances like the example above, the index will change for elements that haven't in fact been changed, causing unnecessary changes to the DOM. That's definitely not the only issue. Quoting the docs: > [using indices as keys] may cause issues with component state > As a result, component state for things like uncontrolled inputs can get mixed up and updated in unexpected ways. And I have definitely seen it happen.

lilactown 2025-08-24T18:48:07.449889Z

yeah you're right. React also uses it to key the local state of the component

shaunlebron 2025-08-25T18:24:40.488619Z

generating an unused key is still causing the leak, so this doesn’t seem reagent-related afterall, but rather an unexpected behavior of cljs.core/hash causing a retained reference somehow

p-himik 2025-08-25T18:40:23.208759Z

Have you tried using a memory profiler to see if what the largest objects are and what references them?

shaunlebron 2025-08-25T19:03:19.658859Z

the profile is really noisy, not sure how to locate my local binding of k inside the profile view

p-himik 2025-08-25T19:05:17.572199Z

A local binding will not be there. Chrome shows you the largest objects. Find where your a-large-map is in there, see what references it.

p-himik 2025-08-25T19:07:36.445529Z

Just to exclude the obvious - if you have something like re-frame-10x, the map might be there. Or if you log it to the console and the dev tools panel is open. Hashing here could make things worse if the map has some lazy colls. Using such a map might not realize some lazy colls, but hashing it will realize every single thing.

shaunlebron 2025-08-25T21:23:30.643649Z

my chrome tab keeps crashing when trying to snapshot the heap, but I ruled out the your suggestions about reframe, devtool logging, and the map has no lazy seqs

p-himik 2025-08-25T21:24:32.850249Z

Any luck with an MRE?

shaunlebron 2025-08-25T21:25:39.411819Z

forgive me what does that mean

p-himik 2025-08-25T21:27:00.747989Z

Minimal Reproducible Example, SO terminology.

shaunlebron 2025-08-25T21:29:27.289079Z

none yet sorry, also I misspoke about unused keys—I had some stray code that threw off my results

shaunlebron 2025-08-25T21:29:36.022749Z

but I did find that storing the key in a let-binding first prevents the memory leak, instead of evaluating it in the metadata form 😮

(let [k (hash ...)] ^{:key k} [my-component ...])  ;; <-- no leak
^{:key (hash ...)} [my-component ...]) ;; <-- leak

p-himik 2025-08-25T21:35:40.134319Z

I'm very skeptical. Are you sure nothing else is going on? Like hash being shadowed or k shadowing something else. If yes, then you can try comparing the resulting JS and see what's going on there.