We’ll take a look at this example. If you’re on Chrome Desktop you can try it online.
Inspecting the final body HTML leads us back to the source code:
Step 1: What are we looking at?
The user has selected an element from the DOM. Its outerHTML looks like this, and the “H” in “Hello World!” is selected.
The outerHTML came about as the combination of two events:
<div id="welcome"></div>in the initial page HTML
Since the user clicked on the “H” character in the tag content it’s straightforward which event we’ll need to look at in more detail: the
Step 2: Finding out where the innerHTML value was set
To track where in the code the
innerHTML assignment happened we need to run some code every time
innerHTML is updated.
This is possible by adding a property setter to the
innerHTML property of
Now, the downside is that we are no longer actually updating the
innerHTML of our element, because we overwrote the original setter function that did that.
We want to call this native setter function in addition to running our tracking code. The details of a property - such as its getter, setter, or whether it’s enumerable - are stored in something called a property descriptor. We can obtain the descriptor of a property using
Once we have the original property descriptor we can call the old setter code in the new setter. This will restore the ability to update the DOM by assigning to
Now, in the setter we want to record some metadata about the assignment. We put that data into an
__innerHTMLOrigin property that we store on the DOM element.
Most importantly, we want to capture a stack trace so we know where the assignment happened. We can obtain a stack trace by creating a new
Let’s run the “Hello World!” example code from earlier after overwriting the setter. We can now inspect the
#welcome element and see where its
innerHTML property is assigned:
Step 3: Going from “Hello World!” to “Hello”
We now have a starting point in our quest to find the origin of the “H” character in the
#example div. The
__innerHTMLOrigin object above will be the first step in on this journey back to the “Hello” string declaration.
__innerHTMLOrigin object keeps track of the HTML that was assigned. It’s actually an array of
inputValues - we’ll see why later.
Unfortunately, the assigned value is a plain string that doesn’t contain any metadata telling us where the string came from. Let’s change that!
This is a bit trickier than tracking the HTML assignments. We could try overriding the constructor of the
String object, but unfortunately that constructor is only called when we explicitly run
To capture a call stack when the string is created we need to make changes to the source code before running it.
Writing a Babel plugin that turns native string operations into function calls
Babel is usually used to compile ES 2015 code into ES5 code, but you can also write your own Babel plugins that contain custom code transformation rules.
Strings aren’t objects, so you can’t store metadata on them. Therefore, instead of creating a string literal we want to wrap each string in an object.
Rather than running the original code:
We replace every string literal with an object:
You can see that the object has the same structure we used to track the
Putting an object literal in the code is a bit verbose and generating code in Babel isn’t much fun. So instead of using an object literal we instead write a function that generates the object for us:
We do something similar for string concatenation.
greeting += " World!" becomes
greeting = f__add(greeting, " World!"). Or, since we’re replacing every string literal,
greeting = f__add(greeting, f__StringLiteral(" World!")).
After this, the value of
greeting is as follows:
greeting is then assigned to our element’s innerHTML property.
__innerHTMLOrigin.inputValues now stores a tracked string that tells us where it came from.
Step 4: Traversing the nested origin data to find the string literal
We can now track the character “H” character in “Hello World!” from the
Starting from the div’s
__innerHTMLOrigin we navigate through the metadata objects until we find the string literal. We do that by recursively looking at the
inputValues is an empty array.
Our first step is the
innerHTML assignment. It has only one
inputValue - the
greeting value shown above. The next step must therefore be the
greeting += " World!" string concatenation.
The object returned by
f__add has two input values, “Hello” and “ World!”. We need to figure out which of them contains the “H” character, that is, the character at index 0 in the string “Hello World!”.
This is not actually difficult. “Hello” has 5 characters, so the indices 0-4 in the concatenated string come from “Hello”. Everything after index 4 comes from the “ World!” string literal.
inputValues array of our object is now empty, which means we’ve reached the final step in our origin path. This is what it looks like in FromJS:
A few more details
How do the string wrapper objects interact with native code?
If you actually tried running the code above, you’d notice that it breaks the
innerHTML assignment. When we call the native innerHTML setter, rather than setting the content to the original string, it’s set to “[object Object]”.
innerHTML needs a string and all Chrome has is an object, so it converts the object into a string.
The solution is to add a toString method to our object. Something like this:
When we assign an object to the innerHTML property, Chrome calls
toString on that objects and assigns the result.
Now when we call code that’s unaware of our string wrappers the calls will still (mostly) work.
Writing the Babel plugin
I won’t go into too much detail about this, but the example below should give you a basic idea of how this works.
Call stacks and source maps
Because Chrome runs the compiled code rather than the original source code, the line and column numbers in the call stack will refer to the compiled code.
Luckily Babel generates a source map that lets us convert the stack trace to match the original code. FromJS uses StackTrace.JS to handle the source map logic.