blog@​arhan.sh:~$

Implementing === in JavaScript from Scratch

Published August 17, 2024 • more posts


Yes, I know exactly what you’re thinking.

Reinventing the wheel? That’s the most moronic idea I’ve ever heard since that guy who wanted to invade Russia in the winter.

And you’re not wrong. For all practical purposes, this blog is pointless. If you were looking for a small code snippet to show off on your quirky little website, I hate to break it to you, but what you’re about to read is so obscenely nutty that it only works on a legacy version of Chromium as an extension running millions of times slower.

The strict equality operator in JavaScript (===) compares two values for object equality. As you probably already know, this operator tests reference equality for non-primitive JavaScript values. In effect, because { foo: 42 } === { foo: 42 } is false, implementing spec-compliant functionality becomes much more interesting. I’m not talking about the obvious ways. Various other built-ins make object reference comparison trivial.

let obj1 = { foo: 42 };
let obj2 = obj1;

Object.is(obj1, obj2) && console.log("same");
[obj1].includes(obj2) && console.log("same");
switch (obj1) {
  case obj2:
    console.log("same");
}
new Set().add(obj1).has(obj2) && console.log("same");

We will implement this basic operation on a far lower level. But what does that mean, and how else could JavaScript differentiate two objects that hold the same data? Surface level research yields nothing, so we’re on our own. Let’s take a deep dive.

A few weeks ago I was looking into the Chromium DevTools heap profiler for a separate blog post of mine. I keenly noticed that the heap profiler distinguishes JavaScript objects by memory location. I might be able to utilize that to my advantage, thus revealing a glimmer of light at the end of the tunnel.

let obj1 = { foo: 42 };
let obj2 = { foo: 42 };
The heap profiler demonstrating that two objects have different memory locations

This isn’t useful if there isn’t a way to access these memory locations programmatically. Fortunately, there exists the Chrome DevTools Protocol, or CDP, a standardized JavaScript API that allows for direct inspection into the JavaScript runtime on Blink-based browsers. Sure enough, Chrome DevTools uses this protocol for the heap profiler.

The original question still stands; how does JavaScript access the CDP? The documentation endorses two means of access. First, open devtools-on-devtools and interface with the CDP like so.

let Main = await import('./devtools-frontend/front_end/entrypoints/main/main.js');
await Main.MainImpl.sendOverProtocol('Emulation.setDeviceMetricsOverride', {
  mobile: true,
  width: 412,
  height: 732,
  deviceScaleFactor: 2.625,
});

const data = await Main.MainImpl.sendOverProtocol("Page.captureScreenshot");

This requires explicit access to DevTools and some setup, meaning it wouldn’t work plainly on a website. The alternative option is the chrome.debugger API within a Chrome extension. Although this would be a bit harder to use, it was actually programmatic and eventually the option I worked with.

I hacked up a Chrome extension but was surprised when my testing errored with the following message.

{"code":-32601,"message":"'HeapProfiler.enable' wasn't found"}

What’s going on here? As it turns out, internal documentation reveals that specific protocol domains including the heap profiler are restricted for security reasons.

However, since the protocol is also exposed to chrome extensions through chrome.debugger API, the backend implements additional access control in some of the methods to prevent extensios form accessing file system or otherwise escaping the sandbox. These restrictions are not extended to other types of clients.

Here is the v8 commit that adds these restrictions, dating back to May 2022 and Chromium version v104. You guessed what’s happening next. The solution to our dilemma, of course, is time travel.

Google doesn’t distribute legacy versions of Chrome. We’ll have to be a bit more hands-on. Archives of older versions of Chrome exist online. However, I’m wary of downloading unknown files from the Internet. Instead, I installed the official v103 Chromium build from source following their guide. You may have to run /usr/bin/xattr -cr /Applications/Chromium.app on an M1 Mac to fix broken metadata preventing Chromium from launching.

My extension didn’t initially work because global content scripts were only supported in Chromium v111 and later, so I had to resort to an older content script hack to get it to work.

That was a lot, let’s take a small step back. To reiterate, the goal is to implement a spec-compliant strict equality operation in JavaScript from scratch. We’ve found a version of Chromium that enables programmatic access to the DevTools heap profiler. Let’s build the bridge between the CDP and JavaScript object memory addresses.

A good place to start is the HeapProfiler.takeHeapSnapshot method. This will profile the entire page, and is obviously cumbersome, but whatever. Next, the Runtime.evaluate method creates a unique object identifier from the evaluation result of a stringified JavaScript expression. The caveat is that this only evaluates the global scope, coercing some really bizarre JavaScript.

strictEquality.js

async function strictEquality(obj1, obj2) {
  window.__obj1 = obj1;
  window.__obj2 = obj2;
  // Communicate with the extension content script
  // and perform the strict equality (will be explained later)
  document.dispatchEvent(new CustomEvent("strictEquality"));
  let e = await new Promise((resolve) =>
    document.addEventListener("strictEqualityResponse", resolve, { once: true })
  );
  delete window.__obj1;
  delete window.__obj2;
  if (e.detail.length) {
    throw new Error(e.detail);
  } else {
    return e.detail;
  }
}

Ugh. It’s async not because of I/O, but because of the event loop. It’s also non-reentrant, in other words it must always be awaited. These types of unavoidable stateful functions are typically only present within C, not JavaScript!

Fine. Finally, we can pass that identifier to HeapProfiler.getHeapObjectId and earn our heap profiler memory address value.

Given the lengths I’ve taken to get this far, these are all compromises I’m willing to bear with xD. The full pipeline:

background.js

...
await chrome.debugger.attach({ tabId }, "1.3");
await chrome.debugger.sendCommand({ tabId }, "HeapProfiler.takeHeapSnapshot");
let objDetails = await Promise.all([
  chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
    expression: "window.__obj1",
  }),
  chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
    expression: "window.__obj2",
  }),
]);
let objHeapDetails = await Promise.all([
  chrome.debugger.sendCommand({ tabId }, "HeapProfiler.getHeapObjectId", {
    objectId: objDetails[0].result.objectId,
  }),
  chrome.debugger.sendCommand({ tabId }, "HeapProfiler.getHeapObjectId", {
    objectId: objDetails[1].result.objectId,
  }),
]);
await chrome.debugger.detach({ tabId: sender.tab.id });

sendResponse(
  !(
    objHeapDetails[0].heapSnapshotObjectId -
    objHeapDetails[1].heapSnapshotObjectId
  )
);
...

We’re almost done! We now just need to handle important edge cases. Because primitive types aren’t usually stored on the heap and instead on the stack (with the exception of Symbols), Runtime.evaluate treats them differently. The method skips to the last step and directly returns their memcpy-able value.

// `Runtime.evaluate` on `{ foo: 42 }`
{
  "className": "Object",
  "description": "Object",
  "objectId": "...",
  "type": "object"
}
// `Runtime.evaluate` on `42`
{
  "description": "42",
  "type": "number",
  "value": 42
}

While comparing two primitives would be a simple equality operation, I wanted to stay true to my word and avoid its use entirely. Rather, we need to implement the isStrictlyEqual ecma262 subroutine at the high level like so.

background.js

...
function primitiveEquality(p1, p2) {
  if (p1.subtype) {
    p1.type = p1.subtype;
  }
  if (p2.subtype) {
    p2.type = p2.subtype;
  }
  if (!stringEquality(p1.type, p2.type)) {
    return false;
  }
  if (stringEquality(p1.type, "number")) {
    if (
      stringEquality(p1.description, "NaN") ||
      stringEquality(p2.description, "NaN")
    ) {
      return false;
    }
    if (
      (stringEquality(p1.description, "0") &&
        stringEquality(p2.description, "-0")) ||
      (stringEquality(p1.description, "-0") &&
        stringEquality(p2.description, "0"))
    ) {
      return true;
    }
    if (p1.unserializableValue || p2.unserializableValue) {
      return stringEquality(p1.description, p2.description);
    }
    return !(p1.value - p2.value);
  }
  if (stringEquality(p1.type, "null") || stringEquality(p1.type, "undefined")) {
    return true;
  }
  if (stringEquality(p1.type, "bigint")) {
    return stringEquality(p1.description, p2.description);
  }
  if (stringEquality(p1.type, "string")) {
    return stringEquality(p1.value, p2.value);
  }
  if (stringEquality(p1.type, "boolean")) {
    return !(p1.value - p2.value);
  }
  throw new Error("Unsupported primitive type");
}

function stringEquality(str1, str2) {
  if (str1.length - str2.length) {
    return false;
  }
  for (let i = 0; i < str1.length; i++) {
    if (str1.charCodeAt(i) - str2.charCodeAt(i)) {
      return false;
    }
  }
  return true;
}

And… there we go. The full implementation is provided in a GitHub repository. Did I forget to mention that it doesn’t work on page load because content scripts aren’t available globally? You’ll have to have fun awaiting the strictEqualityLoaded event before usage.

await new Promise((resolve) => {
  document.addEventListener("strictEqualityLoaded", resolve, { once: true });
});
let obj = [];
(await strictEquality(obj, obj)) && console.log("same");
!(await strictEquality([], [])) && console.log("not same");

There’s no point in trying to sugarcoat it. This reimplementation of strict equality in JavaScript is on its last lifeline. It’s async, slow, outdated, awkward, and it displays the most annoying “This extension is debugging this browser” banner on the website. It can’t get any worse.

Hey, at least it was really cool. Make of this blog post what you will. Until next time!