HTML element interfaces are aliases of Element, so every brand check matches: style-loader cannot install any stylesheet
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:
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
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.comObscura v0.2.2 (x86_64-linux release):
{"headIsIframe":true,"divIsAnchor":true,"inputIsSelect":true,"headCtor":"Element","divCtor":"Element"}Chrome 151.0.7922.34:
{"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.prototypeas the interface prototype, soHTMLDivElement.prototype.foo = …still patches the prototype real elements use, exactly as the alias did. - Answer
instanceoffromSymbol.hasInstanceagainst the interface's tag names (and the XHTML namespace). - Skip any interface that is already a real subclass (
globalThis[name] !== Element), soHTMLFormElement,HTMLTextAreaElement,HTMLSlotElement,HTMLImageElementand 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
Source: h4ckf0r0day/obscura