Web IDL operations are not enumerable on interface prototypes, so zone.js patchClass finds no methods and Angular apps never render
Summary
Web IDL creates interface operations on the interface prototype object with { writable: true, enumerable: true, configurable: true } (spec). Obscura implements those interfaces as ES classes, and ES class methods are enumerable: false. So any library that discovers methods by enumerating an instance instead of looking them up by name finds none of them.
zone.js — which Angular installs on every page — does exactly that in patchClass(), which it applies to MutationObserver, WebKitMutationObserver, IntersectionObserver and FileReader. It builds a proxy prototype by walking for (prop in instance) and forwarding every function-valued property to the original instance. Under Obscura that loop sees no methods at all, so the patched class is assembled out of whatever instance fields happen to be enumerable and has no observe. Angular's router then dies on TypeError: n.observe is not a function, and the page never renders.
This is the same disagreement as #245 (XHR vs zone.js), but on the for-in path rather than the getOwnPropertyDescriptor path.
Reproduction
One command, no network fixture needed:
obscura fetch --eval "JSON.stringify({keys: Object.keys(MutationObserver.prototype), observeEnumerable: Object.getOwnPropertyDescriptor(MutationObserver.prototype,'observe').enumerable})" https://example.comObject.keys(MutationObserver.prototype) |
observe enumerable |
|
|---|---|---|
| Obscura v0.2.2 (x86_64-linux release) | [] |
false |
| Chrome 151.0.7922.34 | ["disconnect","observe","takeRecords"] |
true |
And the failure itself, which is what zone.js does in patchClass():
const Original = globalThis.MutationObserver;
const KEY = '__zone_symbol__originalInstance';
function Patched(...args) { this[KEY] = new Original(...args); }
const probe = new Original(function () {});
for (const prop in probe) { // zone.js discovery loop
if (typeof probe[prop] !== 'function') continue;
Patched.prototype[prop] = function () {
return this[KEY][prop].apply(this[KEY], arguments);
};
}
// Chrome: 'function'. Obscura: 'undefined' -> Angular dies here.
console.log(typeof new Patched(() => {}).observe);On Obscura the patched prototype comes out as _callback, _targets, _records — the engine's internal instance fields — with none of the three spec operations.
Real-world impact
A production Angular 21.2.21 SPA (a property-management console) does not render at all under Obscura v0.2.2 or main @ 4b70288: 33 DOM nodes, zero <input> elements, empty document.body.innerText, blank screenshot. Console:
TypeError: n.observe is not a function
Error during state change from to authentication.**. Error details: Transition Rejection(
$id: 0 type: 6, message: The transition errored,
detail: TypeError: n.observe is not a function)Playwright's own InjectedScript also uses MutationObserver, so once zone.js has replaced the class every locator call on such a page fails too:
locator.count: TypeError: (intermediate value).observe is not a function
at InjectedScript._setupGlobalListenersRemovalDetectionConfirming the diagnosis from the client side: injecting a shim via Page.addScriptToEvaluateOnNewDocument that only flips those prototype methods to enumerable: true, before any page script runs, makes observe reappear and removes that entire family of errors.
Proposed fix
Flip spec operations on the affected interface prototypes to enumerable: true in bootstrap.js, keeping _-prefixed internals hidden so Object.keys() output matches Chrome exactly (["disconnect","observe","takeRecords"], not more). This cannot be done in the existing _markBuiltinsNative() walk, since that walks every capitalized global constructor — including Array and Object, where making prototype methods enumerable would break for-in over ordinary objects and arrays.
I have this working with tests and can open a PR.
Two things deliberately left out of that fix, happy to fold either in if you prefer:
- Attributes. Web IDL makes attributes enumerable too, so Chrome reports
Object.keys(IntersectionObserver.prototype)as["root","rootMargin","scrollMargin","thresholds","delay","trackVisibility","disconnect","observe","takeRecords","unobserve"]. Obscura's accessors stayenumerable: falseunder the fix, so zone.js still won't forwardobserver.rootand friends. That is a much smaller failure than a missing method, and flipping accessors means side-effecting getters start running duringfor-in and object spread, which deserves its own evaluation. WebKitMutationObserver. zone.js patches it as well; Chrome exposes it as an alias ofMutationObserver. Obscura does not define it, which is harmless today (zone.js skips absent classes), but it is part of the same surface.
Environment
- Obscura v0.2.2 release binary (
obscura-x86_64-linux.tar.gz) andmain@4b70288 - Linux x86_64
- Playwright 1.62.1 via
chromium.connectOverCDP - Chrome 151.0.7922.34 as the reference browser
Source: h4ckf0r0day/obscura