loading...
Cover image for A glimpse into the mind of a framework author

A glimpse into the mind of a framework author

carlmungazi profile image Carl Mungazi ・5 min read

This article was first published on Git Connected.

Have you ever read the source code of a popular library and come across comments which explain what the code is doing? Sometimes the explanation given is straightforward but other times it leaves you scratching your head. I had one such moment recently and after much head scratching, it left me with a deeper appreciation of the effort that goes into open source tools and inspired me to write this post.

I came across the code in question whilst investigating how frontend frameworks attach event handlers to DOM elements. Over the last few months, I have been rebuilding different parts of the frontend stack as a way of improving my knowledge, and part of that has involved creating a UI framework based on the virtual DOM paradigm.

The code I was looking at was from Mithril. Internally, it registers events on DOM elements by creating an object and passing it as the second argument to the document.addEventListener method. The relevant source code is:

// Here's an explanation of how this works:
// 1. The event names are always (by design) prefixed by `on`.
// 2. The EventListener interface accepts either a function or an object // with a `handleEvent` method.
// 3. The object does not inherit from `Object.prototype`, to avoid
//    any potential interference with that (e.g. setters).
// 4. The event name is remapped to the handler before calling it.
// 5. In function-based event handlers, `ev.target === this`. We replicate
//    that below.
// 6. In function-based event handlers, `return false` prevents the default
//    action and stops event propagation. We replicate that below.
function EventDict() {
  // Save this, so the current redraw is correctly tracked.
  this._ = currentRedraw
}
EventDict.prototype = Object.create(null)
EventDict.prototype.handleEvent = function (ev) {
  var handler = this["on" + ev.type]
  var result
  if (typeof handler === "function") result = handler.call(ev.currentTarget, ev)
  else if (typeof handler.handleEvent === "function") handler.handleEvent(ev)
  if (this._ && ev.redraw !== false) (0, this._)()
  if (result === false) {
    ev.preventDefault()
    ev.stopPropagation()
  }
}

The third point: “The object does not inherit from Object.prototype, to avoid any potential interference with that (e.g. setters).” is what caught my attention. I reached out to Mithril core maintainer Isiah Meadows via the Mithril Gitter chat and his explanation is what we will go into next.

Guarding against rogue third-party code

Imagine that instead of inheriting from null, EventDict's prototype is Object.prototype. A Mithril user then writes the following code or uses a library whch does the following:

Object.defineProperty(Object.prototype, "onsubmit", {
  get() {
    return (e) => { 
      e.preventDefault();
      console.log('I am on the setter');
    }
  },
  set(val) {
    console.log(val);
  }
})

The application is then written as follows:

m.render(document.getElementById('app'), 
  m('form', {
    onsubmit: (e) => { 
      e.preventDefault(); 
      console.log('I am on the form element')
    }
  }, [
    m('button', { type: 'submit'}, 'Submit')
  ])
)

If the form is submitted, I am on the setter will be logged to the console instead of I am on the form. The contents of the getter and setter functions are not important but their existence means the onsubmit event handler is not registered on the form as intended.

This is because when the event is triggered, the EventDict.prototype.handleEvent method is executed and this line var handler = this["on" + ev.type] returns the onsubmit function on Object.prototype instead of the function specified on the form element.

The problem above also crops up when Mithril runs in an environment where Object.prototype has been extended in a much simpler fashion like so:

Object.prototype.onsubmit = 1

And the application code is:

m.render(document.getElementById('app'), 
  m('form', {
    onreset: (e) => { 
      console.log('resetting form...')
    },
    onsubmit: (e) => { 
      e.preventDefault(); 
      console.log('submitting form...')
    },
  }, [
    m('button', { type: 'reset'}, 'Reset'),
    m('button', { type: 'submit'}, 'Submit')
  ])
)

In this example, we have added the onreset event to our form. For us to understand the problems it poses, we first have to look at Mithril's updateEvent method. This method runs whenever on-event handlers are being set or removed on DOM elements.

function updateEvent(vnode, key, value) {
  if (vnode.events != null) {
    if (vnode.events[key] === value) return
    if (value != null && (typeof value === "function" || typeof value === "object")) {
      if (vnode.events[key] == null) vnode.dom.addEventListener(key.slice(2), vnode.events, false)
      vnode.events[key] = value
    } else {
      if (vnode.events[key] != null) vnode.dom.removeEventListener(key.slice(2), vnode.events, false) 
      vnode.events[key] = undefined
    }
  } else if (value != null && (typeof value === "function" || typeof value === "object")) {
    vnode.events = new EventDict()
    vnode.dom.addEventListener(key.slice(2), vnode.events, false)
    vnode.events[key] = value
  }
}

Virtual DOM frameworks like Mithril turn calls such as m('button', { type: 'reset'}, 'Reset') into objects which represent a given DOM element. In Mithril, these objects are called vnodes (For comparison, in React those objects are called fibers. I have written about them here). The key is the event name and the value is whatever object or function assigned to handle that event.

When the code is executed, the vnode object for the form element is passed to the updateEvent function. Inside the function, the else if clause runs because the vnode's events property is undefined. Once the function has finished executing, the vnode object will look like this:

{
  attrs: {
    onreset: onreset(e) { /* ... */ }, 
    onsubmit: onsubmit(e) { /* ... */ },
  }
  children: [{}, {}]
  dom: form
  domSize: undefined
  events: EventDict {
    onreset: onreset(e) { /* ... */ },
    _: undefined
  }
  instance: undefined
  key: undefined
  state: {}
  tag: "form"
  text: undefined
}

The events property has been assigned an EventDict instance and that instance is then given a reference to the onreset function as one of its properties. Also, the form DOM element has an event listener attached for the onreset event. So far, so good.

updateEvent runs for every on-event present on a DOM element. The second time it is called, it is passed onsubmit as the key argument. This time, however, the first if clause runs because vnode.events is no longer null.

function updateEvent(vnode, key, value) {
  if (vnode.events != null) {
    if (vnode.events[key] === value) return
    if (value != null && (typeof value === "function" || typeof value === "object")) {
      if (vnode.events[key] == null) vnode.dom.addEventListener(key.slice(2), vnode.events, false)
      vnode.events[key] = value
    }
    // ...
  }
  // ...
}

Within this if statement are two other if statements. The first one is skipped because vnode.events[key] is not equal to value, which in this case is the onsubmit function. In fact, the value of vnode.events[key] is 1. Why? Remember that this code is running in an environment where somebody has written this: Object.prototype.onsubmit = 1. Since the form EventDict instance does not yet have an onsubmit property and it inherits from Object.prototype, the JavaScript engine will walk up the prototype chain and find that onsubmit exists on Object.prototype.

The next if clause then checks that value has a function or object. This check passes but crucially, an event listener for the onsubmit event is not then attached to the form element because vnode.events[key] is not null as it should be, its value is 1. The next line adds the onsubmit property to the EventDict instance on the form element vnode along with the related function.

Like the first example where Object.defineProperty was used to add the onsubmit property to Object.prototype, when the form is submitted it will not behave as the developer intended.

Summary

Both the examples above seem like bizarre things to guard against but that is the kind of defensive coding framework and library authors have to do. Using the constructor function approach to create a new object which does not inherit from null means the newly created object is at the mercy of whatever additions may have been made to Object.prototype.

Posted on by:

carlmungazi profile

Carl Mungazi

@carlmungazi

Frontend Dev with a penchant for reading source code

Discussion

markdown guide