skip to Main Content

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.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));

{
  const 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();
      }));
}
<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?

2

Answers


  1. And, of course, if the code hadn’t been relying on d and timefmt to survive, it would have been possible to just pass those as the parameters to setInterval instead of relying on weakCapture:

    {
      const clockElem = document.getElementById('clock');
      const timefmt = new Intl.DateTimeFormat(
        'default', { timeStyle: 'medium', });
      let cancelled = false;
      clockElem.data = { timefmt, cancelled };
      setInterval(() => {
        if (!clockElem.data.cancelled) {
          const d = new Date;
          clockElem.querySelector('p').innerHTML =
            clockElem.data.timefmt.format(d);
        }
      }, 1000);
      clockElem.querySelector('button')
        .addEventListener('click', ev => {
          clockElem.remove();
          cancelled = true;
        });
    }
    

    And if there was other code that needed both the dom node and those values, we could pass a weak data object to setInterval and update which values it held each time they changed, again using weakCapture. The circular reference wouldn’t matter, even assuming a browser did not have an explicit cycle collector, since, after all, regular garbage collection can manage circular references.

    {
      const clockElem = document.getElementById('clock');
      let timefmt = new Intl.DateTimeFormat(
        'default', { timeStyle: 'medium', });
      let cancelled = false;
      const update = weakCapture([clockElem, clockElem.data],
        ([clockElem, data]) => {
          const d = new Date;
          clockElem.querySelector('p')
            .innerHTML = data.timefmt.format(d);
        });
      setInterval(update, 1000);
      clockElem.querySelector('button')
        .addEventListener('click', ev => {
          clockElem.remove();
          cancelled = true;
        });
    }
    

    Or perhaps, just set explicit callbacks instead of using setInterval.

    {
      const clockElem = document.getElementById('clock');
      const setUpdate = weakCapture([clockElem], ([clockElem]) => {
        const d = new Date;
        clockElem.querySelector('p').innerHTML =
          timefmt.format(d);
      });
      let cancelled = false;
      clockElem.data = { setUpdate, cancelled };
      const update = () => {
        if (!clockElem.data.cancelled) {
          const d = new Date;
          clockElem.data.setUpdate();
          setTimeout(update, 1000 - d.getMilliseconds());
        }
      };
      update();
      clockElem.querySelector('button')
        .addEventListener('click', ev => {
          clockElem.remove();
          clockElem.data.cancelled = true;
        });
    }
    
    Login or Signup to reply.
  2. On many pages of MDN there are remarks that you should not use GC’s events and functionality to handle an app’s LOGIC.

    For example: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry

    Note: Cleanup callbacks should not be used for essential program logic. See Notes on cleanup callbacks for details.

    The WeakRef’s page says "Avoid where possible": https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef#avoid_where_possible

    Instead I suggest to use some design pattern which I call Object Tree. So you keep all your objects in a hierarchy and you can destroy any tree node and all of its descendants in any time with freeing all resources.

    Actually this is implemented in any UI framework with a component tree. For example in Vue you have mounted/unmounted hooks to create/free resources and Vue destroys all descendant components automatically:

    class ObjectNode{
      
      children = [];
      
      addChild(child, ...args){
      
        if(typeof child === 'function' && !child.destroy){
          child = new child(...args);
        }
      
        this.children.push(child);
      }
      
      destroy(){
        this.children.forEach(child => child?.destroy());
      }
    }
    
    class Interval{
      constructor(duration, cb){
        this.interval = setInterval(() => cb(new Date), duration);
      }
      destroy(){
        clearInterval(this.interval);
      }
    }
    
    class HTMLElementWrapper extends HTMLElement{
      destroy(){
        this.remove();
      }
    }
    
    class Clock extends ObjectNode{
      
      timefmt = new Intl.DateTimeFormat(
        'default', { timeStyle: 'medium', });
        
      constructor(clockElem){
        
        super();
        
        // add destroyable children
       
        this.addChild(Interval, 1000, d => {
          console.log('tick', d);
          clockElem.querySelector('p').innerHTML =
            this.timefmt.format(d);
        });  
        
        this.addChild(Object.setPrototypeOf(clockElem, HTMLElementWrapper.prototype));
        
        clockElem.querySelector('button')
        .addEventListener('click', ev => {
          this.destroy();
        });
       
      }
    
    
    }
    
    const clock = new Clock(document.getElementById('clock'));
    <div id="clock">
      <button>Remove</button>
      <p></p>
    </div>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search