How can I prevent a DOM node removed from its tree from being held by spurious strong references, like from closures?

462 Views Asked by At

For a toy example, suppose I have a clock widget:

{
  const clockElem = document.getElementById('clock');

  const timefmt = new Intl.DateTimeFormat(
    'default', { timeStyle: 'medium', });

  setInterval(() => {
    const d = new Date;
    console.log('tick', d, clockElem);
    clockElem.querySelector('p').innerHTML =
      timefmt.format(d);
  }, 1000);

  clockElem.querySelector('button')
    .addEventListener('click', ev => {
      clockElem.remove();
    });
}
<div id="clock">
  <button>Remove</button>
  <p></p>
</div>

When I click the button to remove the clock, the setInterval callback is still invoked. The callback closure holds the DOM node strongly, which means its resources cannot be freed. There is also the circular reference from the button event handler; though perhaps that one could be handled by the engine’s cycle collector. Then again, maybe not.

Never fear: I can create a helper function ensuring that closures only hold the DOM node by a weak reference, and throw in FinalizationRegistry to clean up the timer.

const weakCapture = (captures, func) => {
  captures = captures.map(o => new WeakRef(o));
  return (...args) => {
    const objs = [];
    for (const wr of captures) {
      const o = wr.deref();
      if (o === void 0)
        return;
      objs.push(o);
    }
    return func(objs, ...args);
  }
};

const finregTimer = new FinalizationRegistry(
  timerId => clearInterval(timerId));

{
  let clockElem = document.getElementById('clock');

  const timefmt = new Intl.DateTimeFormat(
    'default', { timeStyle: 'medium', });

  const timerId = setInterval(
    weakCapture([clockElem], ([clockElem]) => {
      const d = new Date;
      console.log('tick', d);
      clockElem.querySelector('p').innerHTML =
        timefmt.format(d);
    }), 1000);
  
  finregTimer.register(clockElem, timerId);

  clockElem.querySelector('button')
    .addEventListener('click',
      weakCapture([clockElem], ([clockElem], ev) => {
        clockElem.remove();
      }));

  clockElem = null;

  // now clockElem should be held strongly only by the DOM
}
<div id="clock">
  <button>Remove</button>
  <p></p>
</div>
<button onclick="+'9'.repeat(1e9)">Try to force GC</button>

But this doesn’t seem to work. Even after the clockElem node is removed, the ‘tick’ keeps being logged to the console, meaning the WeakRef has not been emptied, meaning something seems to still hold a strong reference to clockElem. Given that GC is not guaranteed to run immediately, I expected some delay, of course, but even when I try to force GC by running memory-heavy code like +'9'.repeat(1e9) in the console, the weak reference is not cleared (despite this being enough to force GC and clear weak references in even more trivial cases like new WeakRef({})). This happens both in Chromium (118.0.5993.117) and in Firefox (115.3.0esr).

Is this a flaw in the browsers? Or is there perhaps some other strong reference that I missed?

(In short: this is an attempt to implement the weak event pattern in JavaScript.)

2

There are 2 best solutions below

0
KooiInc On

[Element].remove() removes ('disconnects') Element from the DOM, but not from memory. As long as setInterval is not ended, it will keep running happily with the element from memory.

So ending the timer function will make the element garbage collectable (if there are no other references to it ofcourse).

You can check the existence of an element in the DOM using its isConnected property and end the timer if it's not connected anymore.

For the clock example something like (changed setInterval to a more manageable setTimeout).

document.addEventListener('click', ev => 
  ev.target.closest(`#clock`)?.remove());
let timer;
const log = t => document.querySelector(`#log2Screen`).textContent = t;
const clockElem = document.getElementById('clock');
const timefmt = new Intl.DateTimeFormat('default', { timeStyle: 'medium', });

run();

function run() {
  if (!clockElem.isConnected) {
    console.log(
      `div#clock exists in memory:`, 
      clockElem ); 
    // clear timer when #clock not in DOM
    return clearTimeout(timer); 
  }
  const d = timefmt.format(new Date);
  log(`tick ${d}`);
  clockElem.querySelector('p').textContent = d;
  timer = setTimeout(run, 1000);
}
#log2Screen {
  color: green;
}
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>

Alternative 1: you can end the timer when removing the element from DOM (so on removing div#clock).

let timer;
document.addEventListener('click', ev => {
  ev.target.closest(`#clock`)?.remove();
  clearTimeout(timer); // <= clear timer
} );

const log = t => document.querySelector(`#log2Screen`).textContent = t;
const clockElem = document.getElementById('clock');
const timefmt = new Intl.DateTimeFormat('default', { timeStyle: 'medium', });

run();

function run() {
  const d = timefmt.format(new Date);
  log(`tick ${d}`);
  clockElem.querySelector('p').textContent = d;
  timer = setTimeout(run, 1000);
}
#log2Screen {
  color: green;
}
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>

Alternative 2: you can position the assignment of the #clock element within the timer function and only continue the timer if div#clock is still connected to the DOM.

document.addEventListener('click', ev => {
  ev.target.closest(`#clock`)?.remove();
} );

let [timer, tick] = [, 0];
const log = t => document.querySelector(`#log2Screen`).textContent = t;
const timefmt = new Intl.DateTimeFormat('default', { timeStyle: 'medium', });

run();

function run() {
  const clockEl = document.querySelector('#clock p');
  
  if (clockEl) { // <= only run when #clock in DOM
    log(`tick ${++tick}`);
    clockEl.textContent = timefmt.format(new Date);
    return timer = setTimeout(run, 1000);
  }
  
  clearTimeout(timer);
  log(`Clock deactived on ${timefmt.format(new Date)}`);
}
#log2Screen {
  color: green;
}
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>

1
Rajnish Giri On

Here the callback used to remove the node the same can be used to clear the interval being it was stored while declaring.

{
  const clockElem = document.getElementById('clock');

  const timefmt = new Intl.DateTimeFormat(
    'default', { timeStyle: 'medium', });

  const interval = setInterval(() => {
    const d = new Date;
    console.log('tick', d, clockElem);
    clockElem.querySelector('p').innerHTML =
    timefmt.format(d);
  }, 1000);

  clockElem.querySelector('button')
    .addEventListener('click', ev => {
      clearInterval(interval);
      clockElem.remove();
  });
}
<div id="clock">
  <button>Remove</button>
  <p></p>
</div>