#999·obscura

Web IDL operations are not enumerable on interface prototypes, so zone.js patchClass finds no methods and Angular apps never render

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

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:

bash
obscura fetch --eval "JSON.stringify({keys: Object.keys(MutationObserver.prototype), observeEnumerable: Object.getOwnPropertyDescriptor(MutationObserver.prototype,'observe').enumerable})" https://example.com
Object.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():

javascript
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._setupGlobalListenersRemovalDetection

Confirming 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:

  1. 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 stay enumerable: false under the fix, so zone.js still won't forward observer.root and friends. That is a much smaller failure than a missing method, and flipping accessors means side-effecting getters start running during for-in and object spread, which deserves its own evaluation.
  2. WebKitMutationObserver. zone.js patches it as well; Chrome exposes it as an alias of MutationObserver. 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) and main @ 4b70288
  • Linux x86_64
  • Playwright 1.62.1 via chromium.connectOverCDP
  • Chrome 151.0.7922.34 as the reference browser