Description
Bug description
The Tooltip
component accumulates anchors to elements in anchorsBySelect
state without ever releasing old, already unused elements. It causes applications to have massive memory leaks and Detached
elements in memory, which eventualy leads to the death of the app.
Version of Package
v5.21.5
To Reproduce
Sorry, I dont have time to setup a proper bench for it, but this should suffice.
- open codesandbox
- focus the window on the right and hold the right arrow for a bit
- open dev tools and make a memory snapshot. See lots of detached nodes
- repeat steps 2-3 and see the detached nodes increase (not all of those are because of react-tooltip, but many are. Some are from devtools, some from codesandbox dev env)
Expected behavior
Detached nodes should not be increasing. anchorsBySelect
should not be updated with the old + new
, old values must be filtered first.
Screenshots
The screenshot from our app in production using react-tooltip
. If we remore <Tooltip />
from the root of the app, those detached nodes are gone.
Additional context
The problem happens because you are not filtering out the old anchorsBySelect
state and just add the new elements on top of it here
So those elements are never actualy released from memory and always hang there, causing massive memory leaks.
To fix this either make use of the removedNodes
from MutationObserver
like so
const documentObserverCallback: MutationCallback = (mutationList) => {
const oldAnchors: HTMLElement[] = [];
const newAnchors: HTMLElement[] = [];
for (const mutation of mutationList) {
if (
mutation.type === "attributes" &&
mutation.attributeName === "data-tooltip-id"
) {
const newId = (mutation.target as HTMLElement).getAttribute(
"data-tooltip-id"
);
if (newId === id) {
newAnchors.push(mutation.target as HTMLElement);
}
}
if (mutation.type !== "childList") {
continue;
}
for (const node of Array.from(mutation.removedNodes)) {
oldAnchors.push(
node as HTMLElement,
// eslint-disable-next-line unicorn/prefer-spread
...(Array.from(
(node as HTMLElement).querySelectorAll(selector)
) as HTMLElement[])
);
if (!activeAnchor || !node?.contains?.(activeAnchor)) {
continue;
}
setRendered(false);
handleShow(false);
setActiveAnchor(null);
if (tooltipShowDelayTimerRef.current) {
clearTimeout(tooltipShowDelayTimerRef.current);
}
if (tooltipHideDelayTimerRef.current) {
clearTimeout(tooltipHideDelayTimerRef.current);
}
}
if (!selector) {
continue;
}
const elements = Array.from(mutation.addedNodes).filter(
(node) => node.nodeType === 1
);
newAnchors.push(
// the element itself is an anchor
...(elements.filter((element) =>
(element as HTMLElement).matches(selector)
) as HTMLElement[]),
// the element has children which are anchors
...elements.flatMap(
(element) =>
// eslint-disable-next-line unicorn/prefer-spread
Array.from(
(element as HTMLElement).querySelectorAll(selector)
) as HTMLElement[]
)
);
}
if (newAnchors.length > 0 || oldAnchors.length > 0) {
setAnchorsBySelect((anchors) => [
...anchors.filter((anchor) => !oldAnchors.includes(anchor)),
...newAnchors,
]);
}
};
Or just do a document.querySelectorAll(selector)
on mutation, which, tbh, could be faster than those iterations, transforms and array merges.