#1001·obscura

HTML element interfaces are aliases of Element, so every brand check matches: style-loader cannot install any stylesheet

Author: Yubo-CaoCreated Sep 17, 2026Updated Sep 17, 2026

Summary

Most HTML element interfaces are aliases of Element in bootstrap.js (globalThis.HTMLIFrameElement = Element;, and 34 more). Web IDL gives each interface its own brand, so div instanceof HTMLIFrameElement must be false; with the alias it is true for every element and every interface. Any feature detection keyed on a brand therefore takes the wrong branch.

The concrete casualty is webpack's style-loader, which cannot install a single stylesheet. style-loader/lib/addStyles.js resolves its insert target and then checks whether that target is an iframe:

javascript
var styleTarget = document.querySelector(target);   // <head>
if (styleTarget instanceof window.HTMLIFrameElement) {   // true on obscura
  try { styleTarget = styleTarget.contentDocument.head } catch (e) { styleTarget = null }
}
memo[selector] = styleTarget;   // null, cached
...
if (!target) throw new Error("Couldn't find a style target. This probably means that the value for the 'insertInto' parameter is invalid.");

<head> is reported as an iframe, head.contentDocument throws, the catch caches null, and every subsequent style insertion fails with that message. So a webpack-built application renders with no CSS at all.

This is the same class of bug as the HTMLSlotElement comment already in bootstrap.js ("with HTMLSlotElement = Element every element was an instance, but assignedElements() did not exist … threw a TypeError on a plain <div>"), for the rest of the surface.

Reproduction

bash
obscura fetch --eval "JSON.stringify({headIsIframe: document.head instanceof HTMLIFrameElement, divIsAnchor: document.createElement('div') instanceof HTMLAnchorElement, inputIsSelect: document.createElement('input') instanceof HTMLSelectElement, headCtor: document.head.constructor.name, divCtor: document.createElement('div').constructor.name})" https://example.com

Obscura v0.2.2 (x86_64-linux release):

json
{"headIsIframe":true,"divIsAnchor":true,"inputIsSelect":true,"headCtor":"Element","divCtor":"Element"}

Chrome 151.0.7922.34:

json
{"headIsIframe":false,"divIsAnchor":false,"inputIsSelect":false,"headCtor":"HTMLHeadElement","divCtor":"HTMLDivElement"}

The prototype chain differs the same way — obscura: Element → Node → Object; Chrome: HTMLHeadElement → HTMLElement → Element → Node → EventTarget → Object.

Real-world impact

A production Angular + AngularJS console (webpack-built, style-loader in the bundle) went from unusable to fully rendered once brands answer per spec. Same commit, same page, only this fix applied on top of #1000:

before after
DOM nodes 33 151
form controls found 0 3 (email, password, checkbox)
document.body.innerText empty the real sign-in copy
screenshot blank correct, branded, laid out
Couldn't find a style target thrown gone

The Playwright locator layer becomes usable on that page as a result: page.fill() and locator.click() on the sign-in form now resolve and dispatch instead of timing out on actionability.

Proposed fix

Give each aliased interface a real brand while leaving element construction untouched:

  • Keep Element.prototype as the interface prototype, so HTMLDivElement.prototype.foo = … still patches the prototype real elements use, exactly as the alias did.
  • Answer instanceof from Symbol.hasInstance against the interface's tag names (and the XHTML namespace).
  • Skip any interface that is already a real subclass (globalThis[name] !== Element), so HTMLFormElement, HTMLTextAreaElement, HTMLSlotElement, HTMLImageElement and the media elements are untouched — and so this stays correct if one of the others grows behaviour later.

An interface that needs behaviour deserves a real subclass plus an _elementClassFor entry, as HTMLSlotElement has. These 33 need only identity, and identity is fixable without touching _wrap/_elementClassFor or the renderer.

HTMLElement and HTMLUnknownElement are deliberately left as they are: the first matches every HTML element (Element is the closest approximation available here), and the second matches exactly the tags no other interface claims, which needs the full known-element list rather than a tag list of its own.

I have this working with tests, all four release configurations building, and the obstacle course unchanged; PR to follow.

Environment

  • Obscura v0.2.2 release binary and main @ 4b70288
  • Linux x86_64
  • Playwright 1.62.1 via chromium.connectOverCDP
  • Chrome 151.0.7922.34 as the reference browser