5
votes

My question is really "Is the lapsed listener problem preventable in javascript?" but apparently the word "problem" causes a problem.

The wikipedia page says the lapsed listener problem can be solved by the subject holding weak references to the observers. I've implemented that before in Java and it works nicely, and I thought I'd implement it in Javascript, but now I don't see how. Does javascript even have weak references? I see there are WeakSet and WeakMap which have "Weak" in their names, but they don't seem to be helpful for this, as far as I can see.

Here's a jsfiddle showing a typical case of the problem.

The html:

<div id="theCurrentValueDiv">current value: false</div>
<button id="thePlusButton">+</button>

The javascript:

'use strict';
console.log("starting");
let createListenableValue = function(initialValue) {
  let value = initialValue;
  let listeners = [];
  return {
    // Get the current value.
    get: function() {
      return value;
    },
    // Set the value to newValue, and call listener()
    // for each listener that has been added using addListener().
    set: function(newValue) {
      value = newValue;
      for (let listener of listeners) {
        listener();
      }
    },
    // Add a listener that set(newValue) will call with no args
    // after setting value to newValue.
    addListener: function(listener) {
      listeners.push(listener);
      console.log("and now there "+(listeners.length==1?"is":"are")+" "+listeners.length+" listener"+(listeners.length===1?"":"s"));
    },
  };
};  // createListenable

let theListenableValue = createListenableValue(false);

theListenableValue.addListener(function() {
  console.log("    label got value change to "+theListenableValue.get());
  document.getElementById("theCurrentValueDiv").innerHTML = "current value: "+theListenableValue.get();
});

let nextControllerId = 0;

let thePlusButton = document.getElementById("thePlusButton");
thePlusButton.addEventListener('click', function() {
  let thisControllerId = nextControllerId++;
  let anotherDiv = document.createElement('div');
  anotherDiv.innerHTML = '<button>x</button><input type="checkbox"> controller '+thisControllerId;
  let [xButton, valueCheckbox] = anotherDiv.children;
  valueCheckbox.checked = theListenableValue.get();
  valueCheckbox.addEventListener('change', function() {
    theListenableValue.set(valueCheckbox.checked);
  });

  theListenableValue.addListener(function() {
    console.log("    controller "+thisControllerId+" got value change to "+theListenableValue.get());
    valueCheckbox.checked = theListenableValue.get();
  });

  xButton.addEventListener('click', function() {
    anotherDiv.parentNode.removeChild(anotherDiv);
    // Oh no! Our listener on theListenableValue has now lapsed;
    // it will keep getting called and updating the checkbox that is no longer
    // in the DOM, and it will keep the checkbox object from ever being GCed.
  });

  document.body.insertBefore(anotherDiv, thePlusButton);
});

In this fiddle, the observable state is a boolean value, and you can add and remove checkboxes that view and control it, all kept in sync by listeners on it. The problem is that when you remove one of the controllers, its listener doesn't go away: the listener keeps getting called and updating the controller checkbox and prevents the checkbox from being GCed, even though the checkbox is no longer in the DOM and is otherwise GCable. You can see this happening in the javascript console since the listener callback prints a message to the console.

What I'd like instead is for the controller DOM node and its associated value listener to become GCable when I remove the node from the DOM. Conceptually, the DOM node should own the listener, and the observable should hold a weak reference to the listener. Is there a clean way to accomplish that?

I know I can fix the problem in my fiddle by making the x button explicitly remove the listener along with the DOM subtree, but that doesn't help in the case that some other code in the app later removes part of the DOM containing my controller node, e.g. by executing document.body.innerHTML = ''. I'd like set things up so that, when that happens, all the DOM nodes and listeners I created get released and become GCable. Is there a way?

2
"Conceptually, the DOM node should own the listener" - then why are you holding onto an array of listeners? That sounds like you don't have single-ownership. - the8472
@the8472 I'm having difficulty figuring out how to answer your question. Are you asking why the observable keeps an array of listeners in my fiddle? It's so that it remembers who to call when the value changes, of course. If I could code this up in the recommended way, that array of listeners would be replaced by an array of weak references to the listeners, which would not constitute ownership, and I'd also store a reference to the listener in the DOM node so that the DOM node will become the single owner of the respective listener. You know what a weak reference is, right? - Don Hatch
yeah, but without weak references that array effectively results in multiple ownership. so you either need to think of a way of severing those references manually (e.g. mutation observers) or avoiding their existence in the first place. - the8472
@the8472 You've jumped ahead to "without weak references". No one has stated whether or not it's possible to actually make them be weak references, yet; that would be the preferred option if it's possible. Re: "avoiding their [the array of listeners'] existence in the first place", if you know how to implement the observer pattern without the observed keeping a list of references to the listeners, please explain. Re: mutation observers, if you know how to make a mutation observer notify me when the given node's reachability from the DOM root changes, please explain and link to example code. - Don Hatch
mutation observers were just one example (but you can find plenty of tutorials and SO questions covering them), wrapping your checkboxes (as view) into a model class and controlling their removal through that is another option of manually severing such edges. lazy cleanup is yet another option. your problem is far from unsolvable, but the preferred approach would depend on circumstances in the actual application. for dom nodes specifically not keeping references at all and using selectors instead can work too - the8472

2 Answers

0
votes

Custom_elements offer a solution to the lapsed listener problem. They are supported in Chrome and Safari and (as of Aug 2018), are soon to be supported on Firefox and Edge.

I did a jsfiddle with HTML:

<div id="theCurrentValue">current value: false</div>
<button id="thePlusButton">+</button>

And a slightly modified listenableValue, which now has the ability to remove a listener:

"use strict";
function createListenableValue(initialValue) {
    let value = initialValue;
    const listeners = [];
    return {
        get() { // Get the current value.
            return value;
        },
        set(newValue) { // Set the value to newValue, and call all listeners.
            value = newValue;
            for (const listener of listeners) {
                listener();
            }
        },
        addListener(listener) { // Add a listener function to  call on set()
            listeners.push(listener);
            console.log("add: listener count now:  " + listeners.length);
            return () => { // Function to undo the addListener
                const index = listeners.indexOf(listener);
                if (index !== -1) {
                    listeners.splice(index, 1);
                }
                console.log("remove: listener count now:  " + listeners.length);
            };
        }
    };
};
const listenableValue = createListenableValue(false);
listenableValue.addListener(() => {
    console.log("label got value change to " + listenableValue.get());
    document.getElementById("theCurrentValue").innerHTML
        = "current value: " + listenableValue.get();
});
let nextControllerId = 0;

We can now define a custom HTML element <my-control>:

customElements.define("my-control", class extends HTMLElement {
    constructor() {
        super();
    }
    connectedCallback() {
        const n = nextControllerId++;
        console.log("Custom element " + n + " added to page.");
        this.innerHTML =
            "<button>x</button><input type=\"checkbox\"> controller "
            + n;
        this.style.display = "block";
        const [xButton, valueCheckbox] = this.children;
        xButton.addEventListener("click", () => {
            this.parentNode.removeChild(this);
        });
        valueCheckbox.checked = listenableValue.get();
        valueCheckbox.addEventListener("change", () => {
            listenableValue.set(valueCheckbox.checked);
        });
        this._removeListener = listenableValue.addListener(() => {
            console.log("controller " + n + " got value change to "
                + listenableValue.get());
            valueCheckbox.checked = listenableValue.get();
        });
    }
    disconnectedCallback() {
        console.log("Custom element removed from page.");
        this._removeListener();
    }
});

The key point here is that disconnectedCallback() is guaranteed to be called when the <my-control> is removed from the DOM whatever reason. We use it to remove the listener.

You can now add the first <my-control> with:

const plusButton = document.getElementById("thePlusButton");
plusButton.addEventListener("click", () => {
    const myControl = document.createElement("my-control");
    document.body.insertBefore(myControl, plusButton);
});

(This answer occurred to me while I was watching this video, where the speaker explains other reasons why custom elements could be useful.)

0
votes

You can use mutation observers which

provides the ability to watch for changes being made to the DOM tree. It is designed as a replacement for the older Mutation Events feature which was part of the DOM3 Events specification.

An example of how this can be used can be found in the code for on-load

if (window && window.MutationObserver) {
  var observer = new MutationObserver(function (mutations) {
    if (Object.keys(watch).length < 1) return
    for (var i = 0; i < mutations.length; i++) {
      if (mutations[i].attributeName === KEY_ATTR) {
        eachAttr(mutations[i], turnon, turnoff)
        continue
      }
      eachMutation(mutations[i].removedNodes, function (index, el) {
        if (!document.documentElement.contains(el)) turnoff(index, el)
      })
      eachMutation(mutations[i].addedNodes, function (index, el) {
        if (document.documentElement.contains(el)) turnon(index, el)
      })
    }
  })

  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeOldValue: true,
    attributeFilter: [KEY_ATTR]
  })
}