Apply constructed styles across shadow roots

This commit is contained in:
SGavrl
2026-08-04 18:05:34 +00:00
parent 292ca755a7
commit e37936cd53
2 changed files with 221 additions and 24 deletions

View File

@@ -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']);

View File

@@ -10103,6 +10103,161 @@ mod tests {
);
}
#[test]
fn shadow_stylesheet_lists_and_adoption_are_live_across_roots() {
let mut rt = setup_runtime(
"<html><head></head><body><div id='one'></div><div id='two'></div></body></html>",
);
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#"<html style="margin:0"><head>
<style>.target { width:11px; height:10px }</style>
</head><body style="margin:0">
<div id="one"></div><div id="two"></div><div class="target" id="outside"></div>
</body></html>"#,
);
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 = '<style>.target { width:18px; height:10px }</style><div class="target"></div>';
second.innerHTML = '<style>.target { width:23px; height:10px }</style><div class="target"></div>';
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("<html><body><canvas></canvas></body></html>");