From e37936cd53ead64460edda74c81fb51cb9b657ec Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:05:34 +0000 Subject: [PATCH] Apply constructed styles across shadow roots --- crates/obscura-js/js/bootstrap.js | 90 ++++++++++++----- crates/obscura-js/src/runtime.rs | 155 ++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 24 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 99e42b1..61156f2 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -7654,6 +7654,7 @@ class CSSStyleSheet { this._sourceText = ""; this._rules = []; this._cssRules = new CSSRuleList(this); + this._adopters = new Set(); } get type() { return "text/css"; } get ownerNode() { return this._ownerNode; } @@ -7815,28 +7816,49 @@ globalThis.CSSStyleSheet = CSSStyleSheet; globalThis.StyleSheetList = StyleSheetList; function _syncAdoptedStyleSheet(sheet) { - const doc = globalThis.document; - if (doc && doc._adoptedStyleSheets?.includes(sheet)) { - _syncDocumentAdoptedStyles(doc); + for (const root of Array.from(sheet._adopters || [])) { + _syncAdoptedStyles(root); } } -function _syncDocumentAdoptedStyles(doc) { - const sheets = doc._adoptedStyleSheets || []; - const nodes = doc._adoptedStyleNodes || (doc._adoptedStyleNodes = new Map()); +function _reconcileAdoptedStyleSheetAdopters(root, sheets) { + const previous = root._registeredAdoptedStyleSheets + || (root._registeredAdoptedStyleSheets = new Set()); + const current = new Set(Array.from(sheets || []).filter(sheet => sheet instanceof CSSStyleSheet)); + for (const sheet of previous) { + if (!current.has(sheet)) sheet._adopters?.delete(root); + } + for (const sheet of current) { + if (!previous.has(sheet)) sheet._adopters.add(root); + } + root._registeredAdoptedStyleSheets = current; +} + +function _adoptedStyleTarget(root) { + if (!root) return null; + if (root.nodeType === 9) return root.head || root.documentElement; + return root instanceof globalThis.ShadowRoot ? root : null; +} + +function _syncAdoptedStyles(root) { + const sheets = root._adoptedStyleSheets || []; + _reconcileAdoptedStyleSheetAdopters(root, sheets); + const nodes = root._adoptedStyleNodes || (root._adoptedStyleNodes = new Map()); for (const [sheet, node] of Array.from(nodes.entries())) { if (!sheets.includes(sheet)) { node.remove(); nodes.delete(sheet); } } + const target = _adoptedStyleTarget(root); + if (!target) return; for (const sheet of sheets) { if (!(sheet instanceof CSSStyleSheet)) continue; let node = nodes.get(sheet); - if (!node || !node.parentNode) { - node = doc.createElement("style"); + if (!node || node.parentNode !== target) { + node = (root.ownerDocument || globalThis.document).createElement("style"); node.setAttribute("data-obscura-adopted", ""); - (doc.head || doc.documentElement).appendChild(node); + target.appendChild(node); nodes.set(sheet, node); } const css = Array.from(sheet.cssRules || [], rule => rule.cssText || "").join("\n"); @@ -7844,32 +7866,49 @@ function _syncDocumentAdoptedStyles(doc) { } } -function _makeAdoptedSheetList(doc, values) { +// Keep the [SameObject] array identity stable even when the IDL setter replaces +// its contents. Mutating the backing target directly avoids intermediate +// materializations while assignment is in progress; ordinary array mutations +// still pass through the proxy and synchronize immediately. +const _adoptedSheetListTargets = new WeakMap(); +function _makeAdoptedSheetList(root, values) { const target = Array.from(values || []); - return new Proxy(target, { + const list = new Proxy(target, { set(array, property, value) { Reflect.set(array, property, value); - _syncDocumentAdoptedStyles(doc); + _syncAdoptedStyles(root); return true; }, deleteProperty(array, property) { Reflect.deleteProperty(array, property); - _syncDocumentAdoptedStyles(doc); + _syncAdoptedStyles(root); return true; }, }); + _adoptedSheetListTargets.set(root, target); + return list; +} + +function _adoptedStyleSheetsFor(root) { + if (!root._adoptedStyleSheets) { + root._adoptedStyleSheets = _makeAdoptedSheetList(root, []); + } + return root._adoptedStyleSheets; +} + +function _replaceAdoptedStyleSheets(root, sheets) { + const list = _adoptedStyleSheetsFor(root); + const values = Array.from(sheets || []); + const target = _adoptedSheetListTargets.get(root); + target.splice(0, target.length, ...values); + _syncAdoptedStyles(root); + return list; } Object.defineProperty(Document.prototype, 'adoptedStyleSheets', { - get() { - if (!this._adoptedStyleSheets) { - this._adoptedStyleSheets = _makeAdoptedSheetList(this, []); - } - return this._adoptedStyleSheets; - }, + get() { return _adoptedStyleSheetsFor(this); }, set(sheets) { - this._adoptedStyleSheets = _makeAdoptedSheetList(this, sheets); - _syncDocumentAdoptedStyles(this); + _replaceAdoptedStyleSheets(this, sheets); }, }); @@ -8026,7 +8065,10 @@ globalThis.ShadowRoot = class ShadowRoot extends DocumentFragment { return options?.composed ? this._host.getRootNode(options) : this; } get activeElement() { return null; } - get styleSheets() { return []; } + get styleSheets() { + if (!this._styleSheetList) this._styleSheetList = new StyleSheetList(this); + return this._styleSheetList; + } cloneNode() { throw new DOMException( 'Failed to execute cloneNode on Node: ShadowRoot nodes are not clonable.', @@ -8038,8 +8080,8 @@ globalThis.ShadowRoot = class ShadowRoot extends DocumentFragment { }; // Constructible-stylesheet adoption, mirroring Document.adoptedStyleSheets. Object.defineProperty(globalThis.ShadowRoot.prototype, 'adoptedStyleSheets', { - get() { return this._adoptedStyleSheets || []; }, - set(sheets) { this._adoptedStyleSheets = sheets; }, + get() { return _adoptedStyleSheetsFor(this); }, + set(sheets) { _replaceAdoptedStyleSheets(this, sheets); }, configurable: true, }); globalThis.__obscura_shadowHostNames = new Set(['article','aside','blockquote','body','div','footer','h1','h2','h3','h4','h5','h6','header','main','nav','p','section','span']); diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 0364e74..1617ba0 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -10103,6 +10103,161 @@ mod tests { ); } + #[test] + fn shadow_stylesheet_lists_and_adoption_are_live_across_roots() { + let mut rt = setup_runtime( + "
", + ); + let result = rt + .evaluate( + r#" + (() => { + const first = document.getElementById('one').attachShadow({ mode: 'open' }); + const second = document.getElementById('two').attachShadow({ mode: 'open' }); + const inline = document.createElement('style'); + inline.textContent = '.local { width: 17px }'; + first.appendChild(inline); + const inlineSheet = inline.sheet; + const firstList = first.styleSheets; + const firstAdopted = first.adoptedStyleSheets; + const secondAdopted = second.adoptedStyleSheets; + const documentAdopted = document.adoptedStyleSheets; + + const shared = new CSSStyleSheet(); + first.adoptedStyleSheets = [shared]; + second.adoptedStyleSheets.push(shared); + document.adoptedStyleSheets = [shared]; + + const firstNode = first.querySelector('style[data-obscura-adopted]'); + const secondNode = second.querySelector('style[data-obscura-adopted]'); + const documentNode = document.querySelector('style[data-obscura-adopted]'); + const initial = [ + first.styleSheets === firstList, + firstList.length, + firstList[0] === inlineSheet, + firstList.item(0) === inlineSheet, + second.styleSheets === second.styleSheets, + second.styleSheets.length, + first.adoptedStyleSheets === firstAdopted, + second.adoptedStyleSheets === secondAdopted, + document.adoptedStyleSheets === documentAdopted, + firstAdopted.length, + secondAdopted.length, + documentAdopted.length, + firstNode.parentNode === first, + secondNode.parentNode === second, + documentNode.parentNode === document.head, + ]; + + shared.insertRule('.shared { width: 31px }', 0); + const synchronized = [firstNode, secondNode, documentNode] + .map(node => node.textContent.includes('width: 31px')); + + second.adoptedStyleSheets = []; + shared.replaceSync('.shared { width: 47px }'); + const afterRemoval = [ + second.adoptedStyleSheets === secondAdopted, + secondAdopted.length, + secondNode.parentNode, + second.querySelectorAll('style[data-obscura-adopted]').length, + firstNode.textContent.includes('width: 47px'), + documentNode.textContent.includes('width: 47px'), + secondNode.textContent.includes('width: 31px'), + ]; + + inline.remove(); + const inlineRemoval = [ + first.styleSheets === firstList, + firstList.length, + inlineSheet.ownerNode, + ]; + return { initial, synchronized, afterRemoval, inlineRemoval }; + })() + "#, + ) + .unwrap(); + + assert_eq!( + result, + serde_json::json!({ + "initial": [ + true, 1, true, true, true, 0, + true, true, true, 1, 1, 1, + true, true, true + ], + "synchronized": [true, true, true], + "afterRemoval": [true, 0, null, 0, true, true, true], + "inlineRemoval": [true, 0, null], + }) + ); + } + + #[cfg(feature = "render")] + #[test] + fn shadow_adopted_stylesheets_apply_and_sync_the_live_cascade() { + let mut rt = setup_runtime( + r#" + + + + "#, + ); + rt.set_viewport(200.0, 100.0); + let result = rt + .evaluate( + r#" + (() => { + const first = document.getElementById('one').attachShadow({ mode: 'open' }); + const second = document.getElementById('two').attachShadow({ mode: 'open' }); + first.innerHTML = ''; + second.innerHTML = ''; + const firstTarget = first.querySelector('.target'); + const secondTarget = second.querySelector('.target'); + const outside = document.getElementById('outside'); + const widths = () => [firstTarget, secondTarget, outside] + .map(node => node.getBoundingClientRect().width); + + const inline = widths(); + const shared = new CSSStyleSheet(); + shared.replaceSync('.target { width:42px; height:10px }'); + first.adoptedStyleSheets = [shared]; + second.adoptedStyleSheets = [shared]; + document.adoptedStyleSheets = [shared]; + const adopted = widths(); + + shared.cssRules[0].style.setProperty('width', '67px'); + const mutated = widths(); + + second.adoptedStyleSheets = []; + document.adoptedStyleSheets = []; + const selectivelyRemoved = widths(); + + shared.replaceSync('.target { width:81px; height:10px }'); + const remainingRootUpdated = widths(); + return [ + inline, adopted, mutated, selectivelyRemoved, remainingRootUpdated, + first.styleSheets.length, + second.styleSheets.length, + ]; + })() + "#, + ) + .unwrap(); + + assert_eq!( + result, + serde_json::json!([ + [18, 23, 11], + [42, 42, 42], + [67, 67, 67], + [67, 23, 11], + [81, 23, 11], + 1, + 1, + ]) + ); + } + #[test] fn unavailable_webgl_context_does_not_claim_success() { let mut rt = setup_runtime("");