Enable full Shadow DOM support for child widgets This CL fixes an architectural issue in the Widget framework where child widgets were being incorrectly relocated from a parent's Shadow DOM to its Light DOM. Why the original code was insufficient: The Widget framework was built on the assumption of a linear parentElement hierarchy. This assumption failed for "pure" Shadow DOM widgets (where the contentElement is the ShadowRoot) for several reasons: 1. Hierarchy Blind Spot: In the DOM, a child of a ShadowRoot has parentElement === null. The framework's visibility and reparenting checks (this.element.parentElement === parentElement) always failed when the target parent was a ShadowRoot. 2. Protection Bypass: Monkey-patching of native DOM methods (like appendChild) was only applied to Element.prototype. Since ShadowRoot inherits from DocumentFragment (and not Element), widgets could be attached to a ShadowRoot without the framework's safety checks or hierarchy tracking triggering correctly. 3. Forced Relocation: During onConnect, the framework used parentElementOrShadowHost() to find the parent. For a widget in a Shadow Root, this skipped the ShadowRoot and returned the host element, leading to a widget.show(host) call that moved the widget into the Light DOM. In "pure" widgets (which lack slots), this made child widgets invisible and broke visual logging impressions. Bug: 407751340 Change-Id: Ie9f8b35c785a5fa8c341f78232af4b5bf11a0a73 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7785231 Auto-Submit: Danil Somsikov <dsv@chromium.org> Reviewed-by: Simon Zünd <szuend@chromium.org> Commit-Queue: Danil Somsikov <dsv@chromium.org>
diff --git a/front_end/core/dom_extension/DOMExtension.ts b/front_end/core/dom_extension/DOMExtension.ts index 8090c5f..23fb931 100644 --- a/front_end/core/dom_extension/DOMExtension.ts +++ b/front_end/core/dom_extension/DOMExtension.ts
@@ -159,7 +159,7 @@ return this.ownerDocument.defaultView as Window; }; -Element.prototype.removeChildren = function(): void { +Node.prototype.removeChildren = function(): void { if (this.firstChild) { this.textContent = ''; }
diff --git a/front_end/legacy/legacy-defs.d.ts b/front_end/legacy/legacy-defs.d.ts index 4743724..252cca6 100644 --- a/front_end/legacy/legacy-defs.d.ts +++ b/front_end/legacy/legacy-defs.d.ts
@@ -61,7 +61,6 @@ createChild<K extends keyof HTMLElementTagNameMap>(tagName: K, className?: string): HTMLElementTagNameMap[K]; hasFocus(): boolean; positionAt(x: (number|undefined), y: (number|undefined), relativeTo?: Element): void; - removeChildren(): void; scrollIntoViewIfNeeded(center?: boolean): void; } @@ -86,6 +85,7 @@ isSelfOrDescendant(node: Node|null): boolean; parentElementOrShadowHost(): Element|null; parentNodeOrShadowHost(): Node|null; + removeChildren(): void; setTextContentTruncatedIfNeeded(text: unknown, placeholder?: string): boolean; traverseNextNode(stayWithin?: Node): Node|null; traversePreviousNode(stayWithin?: Node): Node|null;
diff --git a/front_end/ui/legacy/Widget.test.ts b/front_end/ui/legacy/Widget.test.ts index b2c71aa..60164e9 100644 --- a/front_end/ui/legacy/Widget.test.ts +++ b/front_end/ui/legacy/Widget.test.ts
@@ -977,6 +977,14 @@ }); describe('Shadow DOM', () => { + it('throws error when using DocumentFragment.appendChild with a widget', () => { + const fragment = document.createDocumentFragment(); + const widget = new Widget(); + widget.markAsRoot(); + assert.throws( + () => fragment.appendChild(widget.element), /Attempt to modify widget with native DOM method `appendChild`/); + }); + it('keeps child widget in the Shadow Root when using pure shadow DOM', () => { const container = document.createElement('div'); renderElementIntoDOM(container);
diff --git a/front_end/ui/legacy/Widget.ts b/front_end/ui/legacy/Widget.ts index 6384132..5dca779 100644 --- a/front_end/ui/legacy/Widget.ts +++ b/front_end/ui/legacy/Widget.ts
@@ -42,10 +42,10 @@ // Remember the original DOM mutation methods here, since we // will override them below to sanity check the Widget system. -const originalAppendChild = Element.prototype.appendChild; -const originalInsertBefore = Element.prototype.insertBefore; -const originalRemoveChild = Element.prototype.removeChild; -const originalRemoveChildren = Element.prototype.removeChildren; +const originalAppendChild = Node.prototype.appendChild; +const originalInsertBefore = Node.prototype.insertBefore; +const originalRemoveChild = Node.prototype.removeChild; +const originalRemoveChildren = Node.prototype.removeChildren; function assert(condition: unknown, message: string): void { if (!condition) { @@ -218,8 +218,9 @@ (element.parentNode instanceof DocumentFragment) ? element.parentNode : element.parentElementOrShadowHost(); if (!parent) { widget.markAsRoot(); + } else { + widget.show(parent as HTMLElement, undefined, /* suppressOrphanWidgetError= */ true); } - widget.show(parent as HTMLElement, undefined, /* suppressOrphanWidgetError= */ true); }; } @@ -1311,28 +1312,28 @@ return new Error(`Attempt to modify widget with native DOM method \`${funcName}\``); } -Element.prototype.appendChild = function<T extends Node>(node: T): T { +Node.prototype.appendChild = function<T extends Node>(node: T): T { if (widgetMap.get(node) && node.parentNode !== this) { throw domOperationError('appendChild'); } return originalAppendChild.call(this, node) as T; }; -Element.prototype.insertBefore = function<T extends Node>(node: T, child: Node|null): T { +Node.prototype.insertBefore = function<T extends Node>(node: T, child: Node|null): T { if (widgetMap.get(node) && node.parentNode !== this) { throw domOperationError('insertBefore'); } return originalInsertBefore.call(this, node, child) as T; }; -Element.prototype.removeChild = function<T extends Node>(child: T): T { +Node.prototype.removeChild = function<T extends Node>(child: T): T { if (widgetCounterMap.get(child) || widgetMap.get(child)) { throw domOperationError('removeChild'); } return originalRemoveChild.call(this, child) as T; }; -Element.prototype.removeChildren = function(): void { +Node.prototype.removeChildren = function(): void { if (widgetCounterMap.get(this)) { throw domOperationError('removeChildren'); }