From 9bacacc60d739afefa24dc769700072bb22fe048 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:47:56 +0000 Subject: [PATCH] Run custom element upgrades through real constructors --- crates/obscura-js/js/bootstrap.js | 71 ++++++++++++----- crates/obscura-js/src/runtime.rs | 127 ++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 19 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 50376b4..e8f4d10 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1035,6 +1035,13 @@ function _eventTargetDispatch(target, event) { return !event.defaultPrevented; } +// During custom-element upgrade, HTMLElement's constructor must return the +// already-existing element being upgraded. A class constructor cannot be +// invoked with `.call(existingElement)`, so the registry and Element +// constructor coordinate through the same construction-stack shape used by +// browser custom-element implementations. +const _customElementConstructionStack = []; + class Node { static ELEMENT_NODE = 1; static ATTRIBUTE_NODE = 2; @@ -1746,7 +1753,21 @@ globalThis.NamedNodeMap = NamedNodeMap; class Element extends Node { constructor(nid) { - super(nid); + const entry = _customElementConstructionStack[_customElementConstructionStack.length - 1]; + const matchesUpgrade = entry && new.target === entry.constructor; + const upgrading = matchesUpgrade && !entry.constructed ? entry.element : null; + super(upgrading ? upgrading._nid : nid); + if (matchesUpgrade && entry.constructed) { + throw new TypeError("Custom element is already being constructed"); + } + if (upgrading) { + // Keep an already-constructed marker on the stack until the outer class + // constructor returns. Recursive `new`/`super` calls for the same + // definition must not steal the element currently being upgraded. + entry.constructed = true; + Object.setPrototypeOf(upgrading, new.target.prototype); + return upgrading; + } this._style = _styleProxy(new CSSStyleDeclaration(this)); } // Element wrappers always back a nodeType-1 node (_wrap/_wrapEl only build an @@ -3340,15 +3361,24 @@ class Document extends Node { return _makeXPathResult(type, _xpathFindNodes(expression, contextNode || this)); } createElement(t) { - const el = _wrapEl(+_dom("create_element", t.toLowerCase())); - if (el && t.toLowerCase() === 'template') { + const localName = String(t).toLowerCase(); + const el = _wrapEl(+_dom("create_element", localName)); + if (el && localName === 'template') { el._templateContent = this.createDocumentFragment(); el._templateContent._fragmentContext = 'template'; } + const definition = globalThis.customElements?._registry?.get(localName); + if (el && definition) globalThis.customElements._upgradeElement(el, definition); return el; } createElementNS(ns, t) { - const el = this.createElement(t); + const namespace = ns == null ? null : String(ns); + if (namespace === "http://www.w3.org/1999/xhtml") { + const el = this.createElement(t); + if (el) el._ns = namespace; + return el; + } + const el = _wrapEl(+_dom("create_element", String(t).toLowerCase())); if (el) el._ns = ns; return el; } @@ -5920,25 +5950,28 @@ class CustomElementRegistry { if (el.__customUpgraded) return; el.__customUpgraded = true; try { - // Web Components spec: copy own props from the prototype onto the - // element. JS-side classes define behavior via methods on the - // prototype; we don't truly swap prototypes (Element is shared), - // so attach the prototype methods directly to the instance. - const proto = cls.prototype; - for (const key of Object.getOwnPropertyNames(proto)) { - if (key === 'constructor') continue; - const desc = Object.getOwnPropertyDescriptor(proto, key); - if (desc) Object.defineProperty(el, key, desc); + // Upgrade preserves object identity but installs the definition's + // prototype before running its class constructor. HTMLElement's + // constructor consumes this entry and returns `el`, so derived class + // fields and constructor-side state initialize on the real DOM wrapper. + const constructionEntry = { element: el, constructor: cls, constructed: false }; + _customElementConstructionStack.push(constructionEntry); + let constructed; + try { + constructed = Reflect.construct(cls, []); + } finally { + const pending = _customElementConstructionStack.lastIndexOf(constructionEntry); + if (pending !== -1) _customElementConstructionStack.splice(pending, 1); + } + if (constructed !== el) { + throw new TypeError("Custom element constructor did not produce the element being upgraded"); } - // Run constructor-side init on the element. Real custom elements - // run the class constructor, but Element instances aren't a `cls` - // subclass here; calling `.call(el)` runs whatever init logic the - // class defines without needing a new allocation. - try { cls.call(el); } catch (e) {} if (typeof el.connectedCallback === 'function' && globalThis.document?.contains?.(el)) { try { el.connectedCallback(); } catch (e) {} } - } catch (e) {} + } catch (e) { + el.__customUpgradeFailed = true; + } } get(name) { return this._registry.get(name); } getName(cls) { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 5c6fd13..49aa206 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1897,6 +1897,133 @@ mod tests { assert_eq!(ok, serde_json::json!("OK")); } + #[test] + fn custom_element_upgrade_runs_class_constructor_on_existing_element() { + let mut rt = setup_runtime( + r#"