fix(dom): preserve registry boundaries while importing shadow trees

This commit is contained in:
ldm0
2026-10-11 18:06:49 +08:00
parent 4473041ab3
commit 8e6803f680
4 changed files with 156 additions and 36 deletions
@@ -21,24 +21,17 @@ pub(super) fn registry_association_for_import_clone_source(
host: &JsContextHost,
source: DomHandle,
target_document: DomHandle,
fallback_registry: Option<CustomElementRegistryAssociation>,
preserve_null_shadow_registry: bool,
) -> Option<CustomElementRegistryAssociation> {
if preserve_null_shadow_registry {
return None;
}
fallback_registry: CustomElementRegistryAssociation,
) -> CustomElementRegistryAssociation {
let target_default =
host.default_custom_element_registry_association_for_document(target_document);
match host.effective_custom_element_registry_association(source) {
CustomElementRegistryAssociation::Registry(CustomElementRegistryKey::Scoped(scoped_id)) => {
Some(CustomElementRegistryAssociation::Registry(
CustomElementRegistryKey::Scoped(scoped_id),
))
CustomElementRegistryAssociation::Registry(CustomElementRegistryKey::Scoped(scoped_id))
}
CustomElementRegistryAssociation::Null => Some(fallback_registry.unwrap_or(target_default)),
CustomElementRegistryAssociation::Null => fallback_registry,
CustomElementRegistryAssociation::Registry(
CustomElementRegistryKey::Global | CustomElementRegistryKey::Child(_),
) => Some(target_default),
) => target_default,
}
}
@@ -18,14 +18,17 @@ pub(crate) fn registry_association_retargets_for_import_clone(
target_document: DomHandle,
fallback_registry: Option<CustomElementRegistryAssociation>,
) -> Vec<RegistryAssociationRetarget> {
let host = unsafe { &*host_ptr };
let fallback_registry = fallback_registry.unwrap_or_else(|| {
host.default_custom_element_registry_association_for_document(target_document)
});
let mut retargets = Vec::new();
collect_registry_association_retargets_for_import_clone(
host_ptr,
host,
source_root,
clone_root,
target_document,
fallback_registry,
false,
&mut retargets,
);
retargets
@@ -60,26 +63,28 @@ pub(crate) fn registry_association_retargets_for_clone(
}
fn collect_registry_association_retargets_for_import_clone(
host_ptr: *mut JsContextHost,
host: &JsContextHost,
source: DomHandle,
clone: DomHandle,
target_document: DomHandle,
fallback_registry: Option<CustomElementRegistryAssociation>,
preserve_null_shadow_registry: bool,
fallback_registry: CustomElementRegistryAssociation,
retargets: &mut Vec<RegistryAssociationRetarget>,
) {
let host = unsafe { &*host_ptr };
let mut stack = vec![(source, clone, preserve_null_shadow_registry)];
while let Some((source, clone, preserve_null_shadow_registry)) = stack.pop() {
if should_record_import_clone_registry_association(host.dom_host(), clone)
&& let Some(association) = registry_association_for_import_clone_source(
host,
source,
target_document,
fallback_registry,
preserve_null_shadow_registry,
)
{
let mut stack = vec![(source, clone, fallback_registry)];
while let Some((source, clone, fallback_registry)) = stack.pop() {
if should_record_import_clone_registry_association(host.dom_host(), clone) {
// ShadowRoot's registry is copied independently of the fallback
// used to clone the host and its light children.
let association = if host.dom_host().is_shadow_root(source) {
registry_association_for_clone_source(host, source, target_document)
} else {
registry_association_for_import_clone_source(
host,
source,
target_document,
fallback_registry,
)
};
retargets.push(RegistryAssociationRetarget {
handle: clone,
association,
@@ -89,18 +94,20 @@ fn collect_registry_association_retargets_for_import_clone(
let source_children = host.dom_host().child_handles(source).collect::<Vec<_>>();
let clone_children = host.dom_host().child_handles(clone).collect::<Vec<_>>();
for (source_child, clone_child) in source_children.into_iter().zip(clone_children).rev() {
stack.push((source_child, clone_child, preserve_null_shadow_registry));
stack.push((source_child, clone_child, fallback_registry));
}
if let (Some(source_shadow), Some(clone_shadow)) = (
host.dom_host().shadow_root_handle(source),
host.dom_host().shadow_root_handle(clone),
) {
let shadow_preserves_null_registry = preserve_null_shadow_registry
|| host
.dom_host()
.shadow_root_uses_null_custom_element_registry(source_shadow)
.unwrap_or(false);
stack.push((source_shadow, clone_shadow, shadow_preserves_null_registry));
// The DOM cloning algorithm intentionally omits fallbackRegistry
// when recursively cloning shadow children. Explicit registries
// on those children still retain their own identity.
stack.push((
source_shadow,
clone_shadow,
CustomElementRegistryAssociation::Null,
));
}
}
}
@@ -0,0 +1,103 @@
(() => {
const checks = [], resources = [];
const check = (name, actual, expected) => checks.push({
name, actual, expected, passed: JSON.stringify(actual) === JSON.stringify(expected),
});
const frame = document.createElement('iframe');
document.body.appendChild(frame); resources.push(frame);
let serial = 0;
try {
const realms = [['main', window], ['iframe', frame.contentWindow]];
for (const [sourceName, sourceWindow] of realms) {
for (const [targetName, targetWindow] of realms) {
const sourceDocument = sourceWindow.document, targetDocument = targetWindow.document;
const registryGetter = Object.getOwnPropertyDescriptor(sourceWindow.Element.prototype, 'customElementRegistry').get;
for (const shadowKind of ['implicit', 'null', 'global', 'scoped']) {
for (const childKind of ['null', 'global', 'scoped']) {
for (const selfOnly of [false, true]) {
const tag = `x-clone-registry-${serial++}`;
const scoped = new sourceWindow.CustomElementRegistry();
const fallback = new targetWindow.CustomElementRegistry();
class SourceGlobal extends sourceWindow.HTMLElement { constructor() { super(); this.marker = 'source-global'; } }
class SourceScoped extends sourceWindow.HTMLElement { constructor() { super(); this.marker = 'source-scoped'; } }
class TargetGlobal extends targetWindow.HTMLElement { constructor() { super(); this.marker = 'target-global'; } }
class Fallback extends targetWindow.HTMLElement { constructor() { super(); this.marker = 'fallback'; } }
sourceWindow.customElements.define(tag, SourceGlobal);
scoped.define(tag, SourceScoped); fallback.define(tag, Fallback);
if (sourceWindow !== targetWindow) targetWindow.customElements.define(tag, TargetGlobal);
const source = sourceDocument.createElement('div', {customElementRegistry: null});
const options = {mode: 'open', clonable: true, serializable: true};
if (shadowKind !== 'implicit') options.customElementRegistry = shadowKind === 'null' ? null : shadowKind === 'global' ? sourceWindow.customElements : scoped;
const shadow = source.attachShadow(options);
const make = kind => {
const registry = kind === 'null' ? null : kind === 'global' ? sourceWindow.customElements : scoped;
const child = sourceDocument.createElement(tag, {customElementRegistry: registry});
child.textContent = '\ud800 value\udfff\r';
child.setAttribute('data-value', '\udfff attribute\ud800');
return child;
};
const light = make(childKind), shadowChild = make(childKind);
source.appendChild(light); shadow.appendChild(shadowChild);
const nestedHost = sourceDocument.createElement('div', {customElementRegistry: null});
shadow.appendChild(nestedHost);
const nestedRoot = nestedHost.attachShadow({mode: 'open', clonable: true, customElementRegistry: sourceWindow.customElements});
const nestedNull = make('null'), nestedScoped = make('scoped');
nestedRoot.append(nestedNull, nestedScoped);
const sourceRegistry = registryGetter.call(shadowChild);
const sourceLightRegistry = registryGetter.call(light);
const sourceRootRegistry = shadow.customElementRegistry;
const key = registry => registry === null ? 'null' : registry === scoped ? 'scoped' : registry === fallback ? 'fallback' : registry === sourceWindow.customElements ? 'source-global' : registry === targetWindow.customElements ? 'target-global' : String(registry);
const rebaseGlobal = registry => registry === sourceWindow.customElements ? targetWindow.customElements : registry;
const constructorFor = registry => registry === null ? null : registry.get(tag);
const poison = childKind === 'scoped' && selfOnly;
if (poison) {
for (const object of [source, shadow, shadowChild, nestedHost]) {
for (const property of ['customElementRegistry', 'ownerDocument', 'childNodes', 'firstChild', 'shadowRoot']) {
Object.defineProperty(object, property, {configurable: true, get() { throw Error(`author ${property} getter`); }});
}
}
}
const operations = [
['import-default', {selfOnly}, targetWindow.customElements],
['import-fallback', {selfOnly, customElementRegistry: fallback}, fallback],
];
if (sourceWindow === targetWindow) operations.push(['clone', !selfOnly, null]);
for (const [method, argument, lightFallback] of operations) {
const prefix = `${sourceName}->${targetName}/${shadowKind}/${childKind}/${selfOnly ? 'self' : 'deep'}/${method}`;
let clone;
try {
clone = method === 'clone' ? sourceWindow.Node.prototype.cloneNode.call(source, argument)
: targetWindow.Document.prototype.importNode.call(targetDocument, source, argument);
} catch (error) {
check(`${prefix}/operation succeeds`, String(error), 'success');
continue;
}
check(`${prefix}/root`, [clone !== source, clone.ownerDocument === targetDocument, clone.parentNode, key(clone.customElementRegistry)], [true, true, null, key(lightFallback)]);
check(`${prefix}/light children`, clone.childNodes.length, selfOnly ? 0 : 1);
const cloneRoot = clone.shadowRoot;
check(`${prefix}/shadow metadata`, [cloneRoot !== shadow, cloneRoot.host === clone, cloneRoot.ownerDocument === targetDocument, cloneRoot.clonable, cloneRoot.serializable, key(cloneRoot.customElementRegistry)], [true, true, true, true, true, key(rebaseGlobal(sourceRootRegistry))]);
const element = (label, node, registry) => {
const Constructor = constructorFor(registry);
check(`${prefix}/${label} registry`, key(node.customElementRegistry), key(registry));
check(`${prefix}/${label} constructor`, [node.marker ?? null, Object.getPrototypeOf(node) === (Constructor ? Constructor.prototype : targetWindow.HTMLElement.prototype)], [Constructor === SourceScoped ? 'source-scoped' : Constructor === Fallback ? 'fallback' : Constructor === SourceGlobal ? 'source-global' : Constructor === TargetGlobal ? 'target-global' : null, true]);
check(`${prefix}/${label} native data`, [node.ownerDocument === targetDocument, node.textContent, node.getAttribute('data-value')], [true, '\ud800 value\udfff\r', '\udfff attribute\ud800']);
};
if (!selfOnly) element('light child', clone.firstChild, sourceLightRegistry === null ? lightFallback : rebaseGlobal(sourceLightRegistry));
element('shadow child', cloneRoot.firstChild, rebaseGlobal(sourceRegistry));
const nested = cloneRoot.lastChild.shadowRoot;
check(`${prefix}/nested shadow registry`, key(nested.customElementRegistry), key(targetWindow.customElements));
element('nested null child', nested.firstChild, null);
element('nested scoped child', nested.lastChild, scoped);
check(`${prefix}/source registry unchanged`, key(registryGetter.call(shadowChild)), key(sourceRegistry));
check(`${prefix}/source native data unchanged`, [light.ownerDocument === sourceDocument, nestedScoped.ownerDocument === sourceDocument], [true, true]);
}
}
}
}
}
}
globalThis.__documentCloneRegistryResults = {complete: true, total: checks.length, passed: checks.filter(row => row.passed).length, checks};
globalThis.__uiEventResults = globalThis.__documentCloneRegistryResults;
return true;
} finally { for (const resource of resources.reverse()) resource.remove(); }
})()
@@ -1,5 +1,22 @@
use super::*;
#[test]
fn document_import_preserves_registry_scopes_when_cloning_shadow_trees() {
let mut vm = new_storage_page_task_executor_test_vm("https://document-clone-registry.test/");
vm.eval(include_str!("document_clone_registry_scopes_v2.js"))
.expect("Document clone registry matrix should evaluate");
assert_eq!(
vm.eval("JSON.stringify(__documentCloneRegistryResults.checks.filter(row => !row.passed))")
.unwrap(),
"[]"
);
assert_eq!(
vm.eval("__documentCloneRegistryResults.complete && __documentCloneRegistryResults.total === 3960 && __documentCloneRegistryResults.passed === 3960")
.unwrap(),
"true"
);
}
#[test]
fn document_import_and_adopt_use_webidl_node_and_dictionary_conversion() {
let mut vm = new_storage_page_task_executor_test_vm("https://document-import-adopt.test/");