Skip to content

Hotfix: client-side memory issues #1255

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

Merged
merged 8 commits into from
Mar 17, 2025
Merged

Hotfix: client-side memory issues #1255

merged 8 commits into from
Mar 17, 2025

Conversation

JoshuaVulcan
Copy link
Collaborator

What does this PR do?

  • Fixes a memory leak issue with unrevoked object URLs being used in the generation of cleaned SVGs for map icon presentation.
  • Also adds a bit of memoization for additional performance gains.

I had a call with some admins from Lewa and SWT this morning about performance issues, and noticed our map image object URLs being recalculated at a high frequency. While looking into memoizing those calculated values for a performance gain, I noticed the object URLs weren't being revoked...in this context, this causes a memory leak. And if called that frequently, it could really pile up. So I fixed that, then added the memoization, and some test coverage to match.

I'd like to hotfix this into the current release as a first test to see if it helps out the folks at Lewa.

Copy link
Contributor

@luixlive luixlive left a comment

Choose a reason for hiding this comment

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

I see some logical errors and have a suggestion to instead of using utility methods with global variables as cache mechanism to go for a more React-y approach. Maybe a context provider handling the caching and retrieval of images. Also, I read this code and kind of get a sense of what is doing but it feels very technical and not verbose enough to fully tell me what is doing. Maybe we could use this PR for a bit of a refactoring on the variable names, conditions, adding comments, etc... To make it cleaner and more readable 👍


expect(setTimeOfDayTimeZone).toHaveBeenCalledTimes(1);
// This test may break if someday the IANA standard updates.
expect(setTimeOfDayTimeZone).toHaveBeenCalledWith('America/Guadeloupe');
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can keep this test uncommented and having partial coverage if all we do is just remove this line. We would at least make sure that setTimeOfDayTimeZone is called.

@@ -11,42 +12,83 @@ const imgNeedsHostAppended = url => {
return true;
};

export const imgElFromSrc = (src, width = 30, height = null) => new Promise((resolve, reject) => {
let img = new Image();
const imageCache = new Map();
Copy link
Contributor

Choose a reason for hiding this comment

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

Having a global variable as a cache store has a smell to me. Maybe it's time to re-work the approach of these methods? Couldn't we have a hook with a state variable with the "cache" that passes down through context the imgElFromSrc method? Something like that sounds like the React approach, easier to memoize, debug, etc...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's less efficient to do it that way, but we can certainly React-ify it more if you would like. I would prefer just using a factory function to create a nice closure to wrap the cache in.

Copy link
Contributor

Choose a reason for hiding this comment

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

Well, it shouldn't be less efficient if it's properly memoized. Maybe we are talking about a couple renders and a couple milliseconds, but React re-renders hundreds of times and the cache mechanism will make those milliseconds be negligible. I think the React approach is a better fit four our React application, but I also understand it's out of the scope of a hotfix, just wanted to share the idea.

Copy link

Choose a reason for hiding this comment

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

How often and how much data can/should be held by this cache? Would a regular js map be good enough for a cache? Or should we use like a bounded in-memory cache to avoid another memory leak - the cache can potentially grow indefinitely here.

Also, we could reach for persistent solutions like LocalStorage/SessionStorage.

return `${src}:${w}:${h}`;
};

export const imgElFromSrc = (src, baseUnit = null) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we use more readable method and variable names? imgElFromSrc, w, and h names aren't very descriptive. And specifically with the method, just by reading the name (even if we use complete words) doesn't really tell what it does, maybe adding a verb to the name could help.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Totally! Maybe in a separate technical debt task? This is a hotfix branch and these are by and large existing function names

const cacheKey = generateImageCacheKey(src, baseUnit, null);

if (imageCache.has(cacheKey)) {
return imageCache.get(cacheKey);
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe I don't have a full understanding of what is going on, but it feels like this is a patch and not the solution to the issue. Why do we need to cache the images? Shouldn't we instead ask ourselves why is this method being called so many times being asked for the same resources?

Copy link
Collaborator Author

@JoshuaVulcan JoshuaVulcan Mar 14, 2025

Choose a reason for hiding this comment

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

The render cycle for the map clusters builds the image strings each time, which is normal since that's how the functional programming nature of React componentry works, and then has traditionally relied on browser caching mechanisms to not over-request images which have already been fetched.

Since we're creating object URLs for some images, this causes a memory leak; the object URLs are being created each time, and are held in memory indefinitely. Having our own cache here prevents createObjectURL from being called more than needed, and thus patches the leak.

src/utils/img.js Outdated
if (baseUnit && img.naturalWidth && img.naturalHeight) {
const widthIsLarger = img.naturalWidth > img.naturalHeight;

if (widthIsLarger || !widthIsLarger) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What is this? 🤯 This is logically equivalent to if (true) hahaha. This condition will always be true, it doesn't make sense to have it wrapped.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Just a WIP typo. This isn't part of the latest change set.

src/utils/img.js Outdated
@@ -57,5 +99,7 @@ export const calcUrlForImage = imagePath => {
return imagePath;
}
const appendString = !!DAS_HOST ? `${DAS_HOST}/` : '';
return `${appendString}${imagePath}`.replace(/^http:\/\//i, 'https://').replace('.org//', '.org/');
const final = `${appendString}${imagePath}`.replace(/^http:\/\//i, 'https://').replace('.org//', '.org/');
Copy link
Contributor

Choose a reason for hiding this comment

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

Personally, I don't think this variable is helping a lot. final doesn't tell me anything about the content of this string. If the intention was to make clearer what is being returned, we could be a bit more verbose.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Got it

@@ -0,0 +1,214 @@
import { imgElFromSrc, calcImgIdFromUrlForMapImages, calcUrlForImage, registerActiveURL } from './img';
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the tests!!

@JoshuaVulcan
Copy link
Collaborator Author

JoshuaVulcan commented Mar 15, 2025

I see some logical errors and have a suggestion to instead of using utility methods with global variables as cache mechanism to go for a more React-y approach. Maybe a context provider handling the caching and retrieval of images. Also, I read this code and kind of get a sense of what is doing but it feels very technical and not verbose enough to fully tell me what is doing. Maybe we could use this PR for a bit of a refactoring on the variable names, conditions, adding comments, etc... To make it cleaner and more readable 👍

I agree with 100% of this -- can we do it outside of the scope of this hotfix? I'd like to apply this change in isolation to a couple of prod sites to test if it resolves a memory leak I noticed recently

Copy link

@Chouffe Chouffe left a comment

Choose a reason for hiding this comment

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

Overall looks like a nice and pragmatic fix to the memory leak that is described in the PR description.

The main question I have is whether a regular js Map is enough as an in-memory cache because it is unbounded. If we know that only a small number of Images can be generated overall, it should be fine, but if bazillions of images are potentially generated we probably want to use something more robust than a Map to avoid another memory-leak.

return Promise.reject('no src provided');
}

const cacheKey = generateImageCacheKey(src, baseUnit, null);
Copy link

Choose a reason for hiding this comment

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

It looks like baseUnit is not always the width of the image:

if (baseUnit && img.naturalWidth && img.naturalHeight) {
        const widthIsLarger = img.naturalWidth > img.naturalHeight;

        const aspectRatio = widthIsLarger ?
          img.naturalHeight / img.naturalWidth :
          img.naturalWidth / img.naturalHeight;

        if (widthIsLarger) {
          img.width = baseUnit;
          img.height = Math.round(baseUnit * aspectRatio);
        } else {
          img.height = baseUnit;
          img.width = Math.round(baseUnit * aspectRatio);
        }
      } else if (baseUnit) {
        img.width = baseUnit;
        img.height = baseUnit * aspectRatio;
      } else {
        img.height = baseUnit;
        img.width = baseUnit * aspectRatio;
      }

Does it make sense to update the generateImageCacheKey function to use baseUnit instead of width, height parameters?


img.addEventListener('load', cleanupAndResolve, { once: true });

img.onerror = (e) => {
Copy link

Choose a reason for hiding this comment

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

Nice cleanup here

@@ -11,42 +12,83 @@ const imgNeedsHostAppended = url => {
return true;
};

export const imgElFromSrc = (src, width = 30, height = null) => new Promise((resolve, reject) => {
let img = new Image();
const imageCache = new Map();
Copy link

Choose a reason for hiding this comment

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

How often and how much data can/should be held by this cache? Would a regular js map be good enough for a cache? Or should we use like a bounded in-memory cache to avoid another memory leak - the cache can potentially grow indefinitely here.

Also, we could reach for persistent solutions like LocalStorage/SessionStorage.

@JoshuaVulcan
Copy link
Collaborator Author

Overall looks like a nice and pragmatic fix to the memory leak that is described in the PR description.

The main question I have is whether a regular js Map is enough as an in-memory cache because it is unbounded. If we know that only a small number of Images can be generated overall, it should be fine, but if bazillions of images are potentially generated we probably want to use something more robust than a Map to avoid another memory-leak.

@Chouffe great question! The reason I used to a Map to begin with was actually how nice it was to work with when busting cache items beyond a certain limit (I think I'd originally set it to be like 100 or 200 in my first pass at the code). But in real-world terms, there's a maximum of a few hundred possible for an ER site; as such, I removed the bounding/lifecycle management stuff and just kept it simple.

@JoshuaVulcan JoshuaVulcan merged commit 5e5f3f6 into release-2.113.1 Mar 17, 2025
3 checks passed
@JoshuaVulcan JoshuaVulcan deleted the ERA-11293 branch March 17, 2025 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants