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
.
Top comments (0)