Run custom element upgrades through real constructors

This commit is contained in:
SGavrl committed 2026-07-31 06:47:56 +00:00
1 parent af5ae70d31
commit 9bacacc60d
2 files changed
+178 -18

No files matched your search

+51 -18
View File
@@ -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 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) {
+127
View File
@@ -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#"<html><body><svelte-like id="component"></svelte-like></body></html>"#,
);
let result = rt
.evaluate(
r#"
const before = document.getElementById("component");
class SvelteLike extends HTMLElement {
constructor() {
super();
this.$$s = [];
this.attachShadow({ mode: "open" });
}
connectedCallback() {
for (const subscription of this.$$s) subscription();
this.$$s.push(() => {});
this.shadowRoot.textContent = "ready";
}
}
customElements.define("svelte-like", SvelteLike);
return [
document.getElementById("component") === before,
before instanceof SvelteLike,
before.constructor === SvelteLike,
before.$$s.length,
before.shadowRoot && before.shadowRoot.textContent
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
true,
true,
true,
1,
"ready"
])
);
}
#[test]
fn create_element_synchronously_constructs_an_existing_definition() {
let mut rt = setup_runtime("<html><body></body></html>");
let result = rt
.evaluate(
r#"
const testStart = true;
class CreatedLater extends HTMLElement {
constructor() {
super();
this.constructorState = ["initialized"];
this.attachShadow({ mode: "open" });
this.shadowRoot.textContent = "constructed";
}
connectedCallback() {
this.constructorState.push("connected");
}
}
customElements.define("created-later", CreatedLater);
const element = document.createElement("created-later");
const foreign = document.createElementNS(
"http://www.w3.org/2000/svg", "created-later"
);
return [
element instanceof CreatedLater,
element.constructor === CreatedLater,
element.localName,
element.constructorState,
element.shadowRoot && element.shadowRoot.textContent,
element.isConnected,
foreign instanceof CreatedLater
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
true,
true,
"created-later",
["initialized"],
"constructed",
false,
false
])
);
}
#[test]
fn throwing_custom_element_constructor_marks_upgrade_failed_without_connecting() {
let mut rt = setup_runtime(
r#"<html><body><throws-during-upgrade id="target"></throws-during-upgrade></body></html>"#,
);
let result = rt
.evaluate(
r#"
let constructorCalls = 0;
let connectedCalls = 0;
class ThrowsDuringUpgrade extends HTMLElement {
constructor() {
super();
constructorCalls++;
throw new Error("expected constructor failure");
}
connectedCallback() {
connectedCalls++;
}
}
customElements.define("throws-during-upgrade", ThrowsDuringUpgrade);
const element = document.getElementById("target");
customElements.upgrade(document);
return [
constructorCalls,
connectedCalls,
element.__customUpgradeFailed === true
];
"#,
)
.unwrap();
assert_eq!(result, serde_json::json!([1, 0, true]));
}
#[test]
fn test_document_title() {
let mut rt = setup_runtime("<html><head><title>Test</title></head><body></body></html>");