Legacy Edge and the Global Environment

When Microsoft Edge switched to Chromium, I was both happy and sad about it. Happy because the Chromium projects (V8, Blink, ...) are high-quality, up-to-date, and quite standards-focussed (even if Google may get ahead of itself sometimes), so it's great that Windows users will (soon) have a really top-notch browser by default. Sad because the people working on the Chakra (JavaScript) and EdgeHTML (rendering) engines in Microsoft Edge had made great strides in bringing them up to date and pushing forward, and it's good for the web to have diversity in those areas.

But a couple of days ago I spent about 45 minutes debugging something that ultimately made me slightly less sorry Edge moved to Chromium. ;-) It turns out that this line of code at global scope fails in Legacy (pre-Chromium) Edge:

// Fails on Legacy Edge
const toString = Object.prototype.toString;

Why? Well, it turns out that Edge doesn't quite get the two aspects of the global environment defined by ES2015 right, and the way it gets it wrong bites me in that code. It's also downright odd — and odd interests me...

Some brief background: Until ES2015 you could say that all globals were properties of the global object (which is accessible via this at global scope, or the window global on browsers, or the relatively-new globalThis global in ES2020). In specification terms, the global lexical environment used an object environment record that used the global object as its binding object:

Before ES2015: Global environment using an object environment record using the global object for bindings

ES2015 changed that; it made the global environment record a composite of an object environment record using the global object for binding and a declarative environment record that held more bindings — specifically, the ones created by let, const, and class declarations that appear at global scope. So those declarations don't create properties on the global object.

ES2015+: Global environment composite of an object environment record using the global object for bindings and a declarative environment record with more bindings

When looking for a matching binding for an identifier, the global environment record first looks on the declarative environment record and uses its value if found; it only looks on the object environment record if a matching binding wasn't found in the declarative one. As a result, declarative global bindings shadow (override) object global bindings. That is, when you use an identifier, let/const/class variables win over a global object property.

So how does Legacy Edge get this wrong? For whatever reason, it won't let you do a lexical declaration (one that goes in the declarative global environment record) that conflicts with a non-configurable property of the global object. And on Edge, the global object has an own property called toString that isn't configurable. So const toString = ... at global scope fails.

To check my understanding of the behavior, I created my own non-configurable property:

<script>
Object.defineProperty(this, "example", {
    value: 42,
    configurable: false // false is the default, but for emphasis...
});
</script>
<script>
let example = "example"; // 0: Let/Const redeclaration
console.log(example);
</script>

Then made it configurable:

<script>
Object.defineProperty(this, "example", {
    value: 42,
    configurable: true
});
</script>
<script>
let example = "example"; // Works
console.log(example); // "example"
console.log(this.example); // 42
</script>

Based on the above, my first thought was that Edge was still using a single, non-composite record; that any attempt to do a lexical declaration conflicting with an existing global object property would fail. But to my surprise, this worked:

// Works on Legacy Edge
const name = 42;
console.log(name); // 42
console.log(typeof name); // number

There's a global object property called name (the name of the window) that's always a string. So I expected that to fail like toString does. So I went looking for the difference between them:

console.log(Object.getOwnPropertyDescriptor(this, "toString"));
// => {configurable: false, enumerable: false, value: function toString() { [native code] }, writable: true}
console.log(Object.getOwnPropertyDescriptor(this, "name"));
// => {configurable: true, enumerable: true, get: function name() { [native code] }, set: function name() { [native code] }}

Ah hah! The name property is configurable! So it must be that it just reconfigures the global property...right?

Er, no. Turns out it's not that simple:

const name = 42;
console.log(Object.getOwnPropertyDescriptor(this, "name"));
// => {configurable: true, enumerable: true, get: function name() { [native code] }, set: function name() { [native code] }}
console.log(JSON.stringify(name), typeof name);               // 42 number
console.log(JSON.stringify(window.name), typeof window.name); // "" string

If Edge were using a single, non-composite environment record and reconfiguring it, those would be the same. So it did create a declarative binding that shadows the object property, and didn't reconfigure the property.

So...huh.

The folks working on Chakra and EdgeHTML brought them forward a lot during the Edge project, blowing the old IE versions away, and I have great respect for what they achieved. Part of me still wishes the Edge project was continuing to use those engines, for diversity's sake. But that doesn't change the fact I find this behavior truly baffling. :-)

Happy Coding!

Have a question or comment about this post? Ping me on Mastodon at @tjcrowdertech@hachyderm.io!