diff --git a/moli-renderer-v8/src/custom_elements/registry_clone_association.rs b/moli-renderer-v8/src/custom_elements/registry_clone_association.rs index 8edcedfa5f..95640ea542 100644 --- a/moli-renderer-v8/src/custom_elements/registry_clone_association.rs +++ b/moli-renderer-v8/src/custom_elements/registry_clone_association.rs @@ -21,24 +21,17 @@ pub(super) fn registry_association_for_import_clone_source( host: &JsContextHost, source: DomHandle, target_document: DomHandle, - fallback_registry: Option, - preserve_null_shadow_registry: bool, -) -> Option { - 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, } } diff --git a/moli-renderer-v8/src/custom_elements/registry_clone_retarget.rs b/moli-renderer-v8/src/custom_elements/registry_clone_retarget.rs index e124d32bf6..8cdd576df0 100644 --- a/moli-renderer-v8/src/custom_elements/registry_clone_retarget.rs +++ b/moli-renderer-v8/src/custom_elements/registry_clone_retarget.rs @@ -18,14 +18,17 @@ pub(crate) fn registry_association_retargets_for_import_clone( target_document: DomHandle, fallback_registry: Option, ) -> Vec { + 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, - preserve_null_shadow_registry: bool, + fallback_registry: CustomElementRegistryAssociation, retargets: &mut Vec, ) { - 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::>(); let clone_children = host.dom_host().child_handles(clone).collect::>(); 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, + )); } } } diff --git a/moli-renderer-v8/src/script_vm/tests/document_clone_registry_scopes_v2.js b/moli-renderer-v8/src/script_vm/tests/document_clone_registry_scopes_v2.js new file mode 100644 index 0000000000..4ac6cd953d --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/document_clone_registry_scopes_v2.js @@ -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(); } +})() diff --git a/moli-renderer-v8/src/script_vm/tests/document_import_adopt_webidl.rs b/moli-renderer-v8/src/script_vm/tests/document_import_adopt_webidl.rs index 05d1456d44..4189578b13 100644 --- a/moli-renderer-v8/src/script_vm/tests/document_import_adopt_webidl.rs +++ b/moli-renderer-v8/src/script_vm/tests/document_import_adopt_webidl.rs @@ -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/");