diff --git a/packages/scoped-custom-element-registry/CHANGELOG.md b/packages/scoped-custom-element-registry/CHANGELOG.md index b0716009..045455a7 100644 --- a/packages/scoped-custom-element-registry/CHANGELOG.md +++ b/packages/scoped-custom-element-registry/CHANGELOG.md @@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## Unreleased --> + +### Changed + +- Updated to latest [proposed spec](https://github.com/whatwg/html/issues/10854) + +### Added + +- customElements.initialize: sets registry on a DOM tree +- document.createElement(NS): takes options with {customElementRegistry} +- document.importNode: takes options with {selfOnly, customElementRegistry} +- Node.customElementRegistry set to creating registry + ## [0.0.10] - 2025-02-26 ### Added diff --git a/packages/scoped-custom-element-registry/package-lock.json b/packages/scoped-custom-element-registry/package-lock.json index 73a95458..87dc119f 100644 --- a/packages/scoped-custom-element-registry/package-lock.json +++ b/packages/scoped-custom-element-registry/package-lock.json @@ -1,12 +1,12 @@ { "name": "@webcomponents/scoped-custom-element-registry", - "version": "0.0.9", + "version": "0.0.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@webcomponents/scoped-custom-element-registry", - "version": "0.0.9", + "version": "0.0.10", "license": "BSD-3-Clause", "devDependencies": { "@open-wc/testing": "^4.0.0", diff --git a/packages/scoped-custom-element-registry/src/scoped-custom-element-registry.ts b/packages/scoped-custom-element-registry/src/scoped-custom-element-registry.ts index 3607258e..aad05246 100644 --- a/packages/scoped-custom-element-registry/src/scoped-custom-element-registry.ts +++ b/packages/scoped-custom-element-registry/src/scoped-custom-element-registry.ts @@ -22,6 +22,8 @@ declare interface PolyfillWindow { CustomElementRegistryPolyfill: { formAssociated: Set; + nativeRegistry: CustomElementRegistry; + inUse: boolean; }; } @@ -39,14 +41,17 @@ const polyfillWindow = (window as unknown) as PolyfillWindow; * `CustomElementsRegistryPolyfill.add(tagName)` reserves the given tag * so it's always formAssociated. */ -if ( - polyfillWindow['CustomElementRegistryPolyfill']?.['formAssociated'] === - undefined -) { - polyfillWindow['CustomElementRegistryPolyfill'] = { - ['formAssociated']: new Set(), - }; -} +polyfillWindow[ + 'CustomElementRegistryPolyfill' +] ??= {} as PolyfillWindow['CustomElementRegistryPolyfill']; +polyfillWindow['CustomElementRegistryPolyfill'][ + 'formAssociated' +] ??= new Set(); +polyfillWindow['CustomElementRegistryPolyfill']['inUse'] = !( + 'customElementRegistry' in Element.prototype +); +polyfillWindow['CustomElementRegistryPolyfill']['nativeRegistry'] = + window.customElements; interface CustomElementConstructor { observedAttributes?: Array; @@ -76,6 +81,7 @@ interface CustomHTMLElement { interface CustomElementRegistry { _getDefinition(tagName: string): CustomElementDefinition | undefined; + initialize: (node: Node) => Node; } interface CustomElementDefinition { @@ -103,16 +109,17 @@ interface CustomElementDefinition { standInClass?: CustomElementConstructor; } -// Note, `registry` matches proposal but `customElements` was previously -// proposed. It's supported for back compat. -interface ShadowRootWithSettableCustomElements extends ShadowRoot { - registry?: CustomElementRegistry; - customElements?: CustomElementRegistry; +// Note, `customElementRegistry` matches spec, others provided for back compat. +interface ShadowRootWithSettableCustomElementRegistry extends ShadowRoot { + ['registry']?: CustomElementRegistry | null; + ['customElements']?: CustomElementRegistry | null; + ['customElementRegistry']: CustomElementRegistry | null; } interface ShadowRootInitWithSettableCustomElements extends ShadowRootInit { - registry?: CustomElementRegistry; - customElements?: CustomElementRegistry; + ['registry']?: CustomElementRegistry; + ['customElements']?: CustomElementRegistry; + ['customElementRegistry']?: CustomElementRegistry; } type ParametersOf< @@ -120,756 +127,1075 @@ type ParametersOf< T extends ((...args: any) => any) | undefined > = T extends Function ? Parameters : never; -const NativeHTMLElement = window.HTMLElement; -const nativeDefine = window.customElements.define; -const nativeGet = window.customElements.get; -const nativeRegistry = window.customElements; - -const definitionForElement = new WeakMap< - HTMLElement, - CustomElementDefinition ->(); -const pendingRegistryForElement = new WeakMap< - HTMLElement, - ShimmedCustomElementsRegistry ->(); -const globalDefinitionForConstructor = new WeakMap< - CustomElementConstructor, - CustomElementDefinition ->(); -// TBD: This part of the spec proposal is unclear: -// > Another option for looking up registries is to store an element's -// > originating registry with the element. The Chrome DOM team was concerned -// > about the small additional memory overhead on all elements. Looking up the -// > root avoids this. -const scopeForElement = new WeakMap(); - -class AsyncInfo { - readonly promise: Promise; - readonly resolve: (val: T) => void; - constructor() { - let resolve: (val: T) => void; - this.promise = new Promise((r) => { - resolve = r; - }); - this.resolve = resolve!; +// Use an IIFE to prevent polyfill use if native scoped registries are detected +(() => { + const hasNativeScopedRegistries = + polyfillWindow['CustomElementRegistryPolyfill']['inUse'] === false; + console.warn( + `Scoped custom element registries polyfill ${ + hasNativeScopedRegistries + ? 'detected browser support and did not load ' + : 'did *not* detect browser support and loaded.' + }` + ); + if (hasNativeScopedRegistries) { + return; } -} -// Constructable CE registry class, which uses the native CE registry to -// register stand-in elements that can delegate out to CE classes registered -// in scoped registries -class ShimmedCustomElementsRegistry implements CustomElementRegistry { - private readonly _definitionsByTag = new Map< - string, + const DSD_HOST_ATTRIBUTE = 'polyfill-shadowrootcustomelementregistry'; + const NativeHTMLElement = window.HTMLElement; + const nativeDefine = window.customElements.define; + const nativeGet = window.customElements.get; + const nativeRegistry = window.customElements; + + const definitionForElement = new WeakMap< + HTMLElement, CustomElementDefinition >(); - private readonly _definitionsByClass = new Map< + const pendingRegistryForElement = new WeakMap< + HTMLElement, + ShimmedCustomElementsRegistry + >(); + const globalDefinitionForConstructor = new WeakMap< CustomElementConstructor, CustomElementDefinition >(); - private readonly _whenDefinedPromises = new Map< - string, - AsyncInfo - >(); - private readonly _awaitingUpgrade = new Map>(); - define(tagName: string, elementClass: CustomElementConstructor) { - tagName = tagName.toLowerCase(); - if (this._getDefinition(tagName) !== undefined) { - throw new DOMException( - `Failed to execute 'define' on 'CustomElementRegistry': the name "${tagName}" has already been used with this registry` - ); + /** + * This WeakMap associates elements with registries. In general, an element's + * registry cannot change once set unless it is initially null. + * An element gets its registry from (1) the `customElementRegistry` provided + * via its DOM creation API, e.g. `createElement` or `importNode`, or (2) + * via a call to `customElementRegistry.initialize(node)` on an ancestor or the + * element, or (3) from root of the tree in which its created, e.g. + * `documentOrShadowRoot.customElementRegistry`, or (4) and importantly when + * created via an HTML string (e.g. innerHTML, insertAdjacentHTML), the + * *parent* element. + * + * See https://dom.spec.whatwg.org/#concept-create-element + */ + const registryForElement = new WeakMap< + Node, + ShimmedCustomElementsRegistry | null + >(); + const setRegistryForSubtree = ( + node: Node, + registry: ShimmedCustomElementsRegistry | null, + shouldUpgrade?: boolean, + allowChangeFromGlobal = false + ) => { + if ( + registryForElement.get(node) == null || + (allowChangeFromGlobal && + (node as Element)['customElementRegistry'] == + globalCustomElementRegistry) + ) { + registryForElement.set(node, registry); } - if (this._definitionsByClass.get(elementClass) !== undefined) { - throw new DOMException( - `Failed to execute 'define' on 'CustomElementRegistry': this constructor has already been used with this registry` - ); + if (shouldUpgrade && registryForElement.get(node) === registry) { + registry?._upgradeElement(node as HTMLElement); } - // Since observedAttributes can't change, we approximate it by patching - // set/remove/toggleAttribute on the user's class - const attributeChangedCallback = - elementClass.prototype.attributeChangedCallback; - const observedAttributes = new Set( - elementClass.observedAttributes || [] - ); - patchAttributes(elementClass, observedAttributes, attributeChangedCallback); - // Register a stand-in class which will handle the registry lookup & delegation - let standInClass = nativeGet.call(nativeRegistry, tagName); - // `formAssociated` cannot be scoped so it's set to true if - // the first defined element sets it or it's reserved in - // `CustomElementRegistryPolyfill.formAssociated`. - const formAssociated = - standInClass?.formAssociated ?? - (elementClass['formAssociated'] || - polyfillWindow['CustomElementRegistryPolyfill']['formAssociated'].has( - tagName - )); - if (formAssociated) { - polyfillWindow['CustomElementRegistryPolyfill']['formAssociated'].add( - tagName + const {children} = node as Element; + if (children?.length) { + Array.from(children).forEach((child) => + setRegistryForSubtree( + child, + registry, + shouldUpgrade, + allowChangeFromGlobal + ) ); } - // Sync the class value to the definition value for easier debuggability - if (formAssociated != elementClass['formAssociated']) { - try { - elementClass['formAssociated'] = formAssociated; - } catch (e) { - // squelch - } - } - // Register the definition - const definition: CustomElementDefinition = { - tagName, - elementClass, - connectedCallback: elementClass.prototype.connectedCallback, - disconnectedCallback: elementClass.prototype.disconnectedCallback, - adoptedCallback: elementClass.prototype.adoptedCallback, - attributeChangedCallback, - 'formAssociated': formAssociated, - 'formAssociatedCallback': - elementClass.prototype['formAssociatedCallback'], - 'formDisabledCallback': elementClass.prototype['formDisabledCallback'], - 'formResetCallback': elementClass.prototype['formResetCallback'], - 'formStateRestoreCallback': - elementClass.prototype['formStateRestoreCallback'], - observedAttributes, - }; - this._definitionsByTag.set(tagName, definition); - this._definitionsByClass.set(elementClass, definition); + }; - if (!standInClass) { - standInClass = createStandInElement(tagName); - nativeDefine.call(nativeRegistry, tagName, standInClass); - } - if (this === window.customElements) { - globalDefinitionForConstructor.set(elementClass, definition); - definition.standInClass = standInClass; - } - // Upgrade any elements created in this scope before define was called - const awaiting = this._awaitingUpgrade.get(tagName); - if (awaiting) { - this._awaitingUpgrade.delete(tagName); - for (const element of awaiting) { - pendingRegistryForElement.delete(element); - customize(element, definition, true); - } - } - // Flush whenDefined callbacks - const info = this._whenDefinedPromises.get(tagName); - if (info !== undefined) { - info.resolve(elementClass); - this._whenDefinedPromises.delete(tagName); + class AsyncInfo { + readonly promise: Promise; + readonly resolve: (val: T) => void; + constructor() { + let resolve: (val: T) => void; + this.promise = new Promise((r) => { + resolve = r; + }); + this.resolve = resolve!; } - return elementClass; - } - - upgrade(...args: Parameters) { - creationContext.push(this); - nativeRegistry.upgrade(...args); - creationContext.pop(); - } - - get(tagName: string) { - const definition = this._definitionsByTag.get(tagName); - return definition?.elementClass; - } - - getName(elementClass: CustomElementConstructor) { - const definition = this._definitionsByClass.get(elementClass); - return definition?.tagName ?? null; - } - - _getDefinition(tagName: string) { - return this._definitionsByTag.get(tagName); } - ['whenDefined'](tagName: string) { - const definition = this._getDefinition(tagName); - if (definition !== undefined) { - return Promise.resolve(definition.elementClass); - } - let info = this._whenDefinedPromises.get(tagName); - if (info === undefined) { - info = new AsyncInfo(); - this._whenDefinedPromises.set(tagName, info); + // Constructable CE registry class, which uses the native CE registry to + // register stand-in elements that can delegate out to CE classes registered + // in scoped registries + class ShimmedCustomElementsRegistry implements CustomElementRegistry { + private readonly _definitionsByTag = new Map< + string, + CustomElementDefinition + >(); + private readonly _definitionsByClass = new Map< + CustomElementConstructor, + CustomElementDefinition + >(); + private readonly _whenDefinedPromises = new Map< + string, + AsyncInfo + >(); + private readonly _awaitingUpgrade = new Map>(); + + define(tagName: string, elementClass: CustomElementConstructor) { + tagName = tagName.toLowerCase(); + if (this._getDefinition(tagName) !== undefined) { + throw new DOMException( + `Failed to execute 'define' on 'CustomElementRegistry': the name "${tagName}" has already been used with this registry` + ); + } + if (this._definitionsByClass.get(elementClass) !== undefined) { + throw new DOMException( + `Failed to execute 'define' on 'CustomElementRegistry': this constructor has already been used with this registry` + ); + } + // Since observedAttributes can't change, we approximate it by patching + // set/remove/toggleAttribute on the user's class + const attributeChangedCallback = + elementClass.prototype.attributeChangedCallback; + const observedAttributes = new Set( + elementClass.observedAttributes || [] + ); + patchAttributes( + elementClass, + observedAttributes, + attributeChangedCallback + ); + // Register a stand-in class which will handle the registry lookup & delegation + let standInClass = nativeGet.call(nativeRegistry, tagName); + // `formAssociated` cannot be scoped so it's set to true if + // the first defined element sets it or it's reserved in + // `CustomElementRegistryPolyfill.formAssociated`. + const formAssociated = + standInClass?.formAssociated ?? + (elementClass['formAssociated'] || + polyfillWindow['CustomElementRegistryPolyfill']['formAssociated'].has( + tagName + )); + if (formAssociated) { + polyfillWindow['CustomElementRegistryPolyfill']['formAssociated'].add( + tagName + ); + } + // Sync the class value to the definition value for easier debuggability + if (formAssociated != elementClass['formAssociated']) { + try { + elementClass['formAssociated'] = formAssociated; + } catch (e) { + // squelch + } + } + // Register the definition + const definition: CustomElementDefinition = { + tagName, + elementClass, + connectedCallback: elementClass.prototype.connectedCallback, + disconnectedCallback: elementClass.prototype.disconnectedCallback, + adoptedCallback: elementClass.prototype.adoptedCallback, + attributeChangedCallback, + 'formAssociated': formAssociated, + 'formAssociatedCallback': + elementClass.prototype['formAssociatedCallback'], + 'formDisabledCallback': elementClass.prototype['formDisabledCallback'], + 'formResetCallback': elementClass.prototype['formResetCallback'], + 'formStateRestoreCallback': + elementClass.prototype['formStateRestoreCallback'], + observedAttributes, + }; + this._definitionsByTag.set(tagName, definition); + this._definitionsByClass.set(elementClass, definition); + + if (!standInClass) { + standInClass = createStandInElement(tagName); + nativeDefine.call(nativeRegistry, tagName, standInClass); + } + if (this === globalCustomElementRegistry) { + globalDefinitionForConstructor.set(elementClass, definition); + definition.standInClass = standInClass; + } + // Upgrade any elements created in this scope before define was called + const awaiting = this._awaitingUpgrade.get(tagName); + if (awaiting) { + this._awaitingUpgrade.delete(tagName); + for (const element of awaiting) { + this._upgradeElement(element, definition); + } + } + // Flush whenDefined callbacks + const info = this._whenDefinedPromises.get(tagName); + if (info !== undefined) { + info.resolve(elementClass); + this._whenDefinedPromises.delete(tagName); + } + return elementClass; } - return info.promise; - } - _upgradeWhenDefined( - element: HTMLElement, - tagName: string, - shouldUpgrade: boolean - ) { - let awaiting = this._awaitingUpgrade.get(tagName); - if (!awaiting) { - this._awaitingUpgrade.set(tagName, (awaiting = new Set())); - } - if (shouldUpgrade) { - awaiting.add(element); - } else { - awaiting.delete(element); + // Note, this does *not* initialize the tree but just provokes upgrade + // and since the element may already have been natively upgraded, + // this must be done manually. + upgrade(root: Node) { + const registry = (root as Element)['customElementRegistry']; + if (registry === this && root.nodeType === Node.ELEMENT_NODE) { + (registry as ShimmedCustomElementsRegistry)._upgradeElement( + root as HTMLElement + ); + } + root.childNodes.forEach((n) => this.upgrade(n)); } - } -} -// User extends this HTMLElement, which returns the CE being upgraded -let upgradingInstance: HTMLElement | undefined; -window.HTMLElement = (function HTMLElement(this: HTMLElement) { - // Upgrading case: the StandInElement constructor was run by the browser's - // native custom elements and we're in the process of running the - // "constructor-call trick" on the natively constructed instance, so just - // return that here - let instance = upgradingInstance; - if (instance) { - upgradingInstance = undefined; - return instance; - } - // Construction case: we need to construct the StandInElement and return - // it; note the current spec proposal only allows new'ing the constructor - // of elements registered with the global registry - const definition = globalDefinitionForConstructor.get( - this.constructor as CustomElementConstructor - ); - if (!definition) { - throw new TypeError( - 'Illegal constructor (custom element class must be registered with global customElements registry to be newable)' - ); - } - instance = Reflect.construct(NativeHTMLElement, [], definition.standInClass); - Object.setPrototypeOf(instance, this.constructor.prototype); - definitionForElement.set(instance!, definition); - return instance; -} as unknown) as typeof HTMLElement; -window.HTMLElement.prototype = NativeHTMLElement.prototype; - -// Helpers to return the scope for a node where its registry would be located -const isValidScope = (node: Node) => - node === document || node instanceof ShadowRoot; -const registryForNode = (node: Node): ShimmedCustomElementsRegistry | null => { - // TODO: the algorithm for finding the scope is a bit up in the air; assigning - // a one-time scope at creation time would require walking every tree ever - // created, which is avoided for now - let scope = node.getRootNode(); - // If we're not attached to the document (i.e. in a disconnected tree or - // fragment), we need to get the scope from the creation context; that should - // be a Document or ShadowRoot, unless it was created via innerHTML - if (!isValidScope(scope)) { - const context = creationContext[creationContext.length - 1]; - // When upgrading via registry.upgrade(), the registry itself is put on the - // creationContext stack - if (context instanceof CustomElementRegistry) { - return context as ShimmedCustomElementsRegistry; + get(tagName: string) { + const definition = this._definitionsByTag.get(tagName); + return definition?.elementClass; } - // Otherwise, get the root node of the element this was created from - scope = context.getRootNode(); - // The creation context wasn't a Document or ShadowRoot or in one; this - // means we're being innerHTML'ed into a disconnected element; for now, we - // hope that root node was created imperatively, where we stash _its_ - // scopeForElement. Beyond that, we'd need more costly tracking. - if (!isValidScope(scope)) { - scope = scopeForElement.get(scope)?.getRootNode() || document; + + getName(elementClass: CustomElementConstructor) { + const definition = this._definitionsByClass.get(elementClass); + return definition?.tagName ?? null; } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (scope as any)['registry'] as ShimmedCustomElementsRegistry | null; -}; - -// Helper to create stand-in element for each tagName registered that delegates -// out to the registry for the given element -const createStandInElement = (tagName: string): CustomElementConstructor => { - return (class ScopedCustomElementBase { - // Note, this cannot be scoped so it's set based on a polyfill config - // option. When this config option isn't specified, it is set - // if the first defining element is formAssociated. - static get ['formAssociated']() { - return polyfillWindow['CustomElementRegistryPolyfill'][ - 'formAssociated' - ].has(tagName); + + _getDefinition(tagName: string) { + return this._definitionsByTag.get(tagName); } - constructor() { - // Create a raw HTMLElement first - const instance = Reflect.construct( - NativeHTMLElement, - [], - this.constructor - ); - // We need to install the minimum HTMLElement prototype so that - // scopeForNode can use DOM API to determine our construction scope; - // upgrade will eventually install the full CE prototype - Object.setPrototypeOf(instance, HTMLElement.prototype); - // Get the node's scope, and its registry (falls back to global registry) - const registry = - registryForNode(instance) || - (window.customElements as ShimmedCustomElementsRegistry); - const definition = registry._getDefinition(tagName); - if (definition) { - customize(instance, definition); - } else { - pendingRegistryForElement.set(instance, registry); + + ['whenDefined'](tagName: string) { + const definition = this._getDefinition(tagName); + if (definition !== undefined) { + return Promise.resolve(definition.elementClass); } - return instance; + let info = this._whenDefinedPromises.get(tagName); + if (info === undefined) { + info = new AsyncInfo(); + this._whenDefinedPromises.set(tagName, info); + } + return info.promise; } - connectedCallback( - this: HTMLElement, - ...args: ParametersOf + _upgradeWhenDefined( + element: HTMLElement, + tagName: string, + shouldUpgrade: boolean ) { - ensureAttributesCustomized(this); - const definition = definitionForElement.get(this); - if (definition) { - // Delegate out to user callback - definition.connectedCallback && - definition.connectedCallback.apply(this, args); + let awaiting = this._awaitingUpgrade.get(tagName); + if (!awaiting) { + this._awaitingUpgrade.set(tagName, (awaiting = new Set())); + } + if (shouldUpgrade) { + awaiting.add(element); } else { - // Register for upgrade when defined (only when connected, so we don't leak) - pendingRegistryForElement - .get(this)! - ._upgradeWhenDefined(this, tagName, true); + awaiting.delete(element); } } - disconnectedCallback( - this: HTMLElement, - ...args: ParametersOf + // upgrades the given element if defined or queues it for upgrade when defined. + _upgradeElement( + element: HTMLElement, + definition?: CustomElementDefinition ) { - const definition = definitionForElement.get(this); - if (definition) { - // Delegate out to user callback - definition.disconnectedCallback && - definition.disconnectedCallback.apply(this, args); + const registry = element['customElementRegistry']; + const canUpgrade = registry === null || registry === this; + if (!canUpgrade) { + return; + } + definition ??= this._getDefinition(element.localName); + if (definition !== undefined) { + pendingRegistryForElement.delete(element); + customize(element, definition!, true); } else { - // Un-register for upgrade when defined (so we don't leak) - pendingRegistryForElement - .get(this)! - ._upgradeWhenDefined(this, tagName, false); + this._upgradeWhenDefined(element, element.localName, true); } } - adoptedCallback( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - definition?.adoptedCallback?.apply(this, args); + ['initialize'](node: Node) { + setRegistryForSubtree(node, this); + return node; } + } - // Form-associated custom elements lifecycle methods - ['formAssociatedCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formAssociatedCallback']?.apply(this, args); - } + const globalCustomElementRegistry = new ShimmedCustomElementsRegistry(); + + // User extends this HTMLElement, which returns the CE being upgraded + let upgradingInstance: HTMLElement | undefined; + window.HTMLElement = (function HTMLElement(this: HTMLElement) { + // Upgrading case: the StandInElement constructor was run by the browser's + // native custom elements and we're in the process of running the + // "constructor-call trick" on the natively constructed instance, so just + // return that here + let instance = upgradingInstance; + if (instance) { + upgradingInstance = undefined; + return instance; + } + // Construction case: we need to construct the StandInElement and return + // it; note the current spec proposal only allows new'ing the constructor + // of elements registered with the global registry + const definition = globalDefinitionForConstructor.get( + this.constructor as CustomElementConstructor + ); + if (!definition) { + throw new TypeError( + 'Illegal constructor (custom element class must be registered with global customElements registry to be newable)' + ); } + instance = Reflect.construct( + NativeHTMLElement, + [], + definition.standInClass + ); + Object.setPrototypeOf(instance, this.constructor.prototype); + definitionForElement.set(instance!, definition); + return instance; + } as unknown) as typeof HTMLElement; + window.HTMLElement.prototype = NativeHTMLElement.prototype; - ['formDisabledCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formDisabledCallback']?.apply(this, args); - } + const creationContext: Array = [ + globalCustomElementRegistry, + ]; + + // Helpers to return the scope for a node where its registry would be located + const registryFromContext = ( + node: Element | null + ): ShimmedCustomElementsRegistry | null => { + const explicitRegistry = registryForElement.get(node as Node); + if (explicitRegistry != null) { + return explicitRegistry; } + return ( + (creationContext[ + creationContext.length - 1 + ] as ShimmedCustomElementsRegistry) ?? null + ); + }; - ['formResetCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formResetCallback']?.apply(this, args); + // Helper to create stand-in element for each tagName registered that delegates + // out to the registry for the given element + const createStandInElement = (tagName: string): CustomElementConstructor => { + return (class ScopedCustomElementBase { + // Note, this cannot be scoped so it's set based on a polyfill config + // option. When this config option isn't specified, it is set + // if the first defining element is formAssociated. + static get ['formAssociated']() { + return polyfillWindow['CustomElementRegistryPolyfill'][ + 'formAssociated' + ].has(tagName); + } + constructor() { + // Create a raw HTMLElement first + const instance = Reflect.construct( + NativeHTMLElement, + [], + this.constructor + ); + // We need to install the minimum HTMLElement prototype so that + // scopeForNode can use DOM API to determine our construction scope; + // upgrade will eventually install the full CE prototype + Object.setPrototypeOf(instance, HTMLElement.prototype); + // Get the node's scope, and its registry (falls back to global registry) + let registry = registryFromContext(instance); + if ( + registry === globalCustomElementRegistry && + maybeApplyNullScope(instance.getRootNode()) + ) { + registry = null; + } else { + setRegistryForSubtree(instance, registry); + } + const definition = (registry as null | ShimmedCustomElementsRegistry)?._getDefinition( + tagName + ); + if (definition) { + customize(instance, definition); + } else if (registry) { + pendingRegistryForElement.set(instance, registry); + } + return instance; } - } - ['formStateRestoreCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formStateRestoreCallback']?.apply(this, args); + connectedCallback( + this: HTMLElement, + ...args: ParametersOf + ) { + ensureAttributesCustomized(this); + const definition = definitionForElement.get(this); + if (definition) { + // Delegate out to user callback + definition.connectedCallback && + definition.connectedCallback.apply(this, args); + } else { + // NOTE, if this has a null registry, then it should be changed + // to the registry into which it's inserted. + // LIMITATION: this is only done for custom elements and not built-ins + // since we can't easily see their connection state changing. + // Register for upgrade when defined (only when connected, so we don't leak) + const pendingRegistry = pendingRegistryForElement.get(this); + if (pendingRegistry !== undefined) { + pendingRegistry._upgradeWhenDefined(this, tagName, true); + } else { + const registry = + this['customElementRegistry'] ?? + (this.parentNode as Element | ShadowRoot)?.[ + 'customElementRegistry' + ]; + if (registry) { + setRegistryForSubtree( + this, + registry as ShimmedCustomElementsRegistry, + true + ); + } + } + } } - } - // no attributeChangedCallback or observedAttributes since these - // are simulated via setAttribute/removeAttribute patches - } as unknown) as CustomElementConstructor; -}; -window.CustomElementRegistry = ShimmedCustomElementsRegistry; - -// Helper to patch CE class setAttribute/getAttribute/toggleAttribute to -// implement attributeChangedCallback -const patchAttributes = ( - elementClass: CustomElementConstructor, - observedAttributes: Set, - attributeChangedCallback?: CustomHTMLElement['attributeChangedCallback'] -) => { - if (observedAttributes.size === 0 || attributeChangedCallback === undefined) { - return; - } - const setAttribute = elementClass.prototype.setAttribute; - if (setAttribute) { - elementClass.prototype.setAttribute = function (n: string, value: string) { - ensureAttributesCustomized(this); - const name = n.toLowerCase(); - if (observedAttributes.has(name)) { - const old = this.getAttribute(name); - setAttribute.call(this, name, value); - attributeChangedCallback.call(this, name, old, value); - } else { - setAttribute.call(this, name, value); + disconnectedCallback( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition) { + // Delegate out to user callback + definition.disconnectedCallback && + definition.disconnectedCallback.apply(this, args); + } else { + // Un-register for upgrade when defined (so we don't leak) + pendingRegistryForElement + .get(this) + ?._upgradeWhenDefined(this, tagName, false); + } } - }; - } - const removeAttribute = elementClass.prototype.removeAttribute; - if (removeAttribute) { - elementClass.prototype.removeAttribute = function (n: string) { - ensureAttributesCustomized(this); - const name = n.toLowerCase(); - if (observedAttributes.has(name)) { - const old = this.getAttribute(name); - removeAttribute.call(this, name); - attributeChangedCallback.call(this, name, old, null); - } else { - removeAttribute.call(this, name); + + adoptedCallback( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + definition?.adoptedCallback?.apply(this, args); } - }; - } - const toggleAttribute = elementClass.prototype.toggleAttribute; - if (toggleAttribute) { - elementClass.prototype.toggleAttribute = function ( - n: string, - force?: boolean - ) { - ensureAttributesCustomized(this); - const name = n.toLowerCase(); - if (observedAttributes.has(name)) { - const old = this.getAttribute(name); - toggleAttribute.call(this, name, force); - const newValue = this.getAttribute(name); - if (old !== newValue) { - attributeChangedCallback.call(this, name, old, newValue); + + // Form-associated custom elements lifecycle methods + ['formAssociatedCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formAssociatedCallback']?.apply(this, args); } - } else { - toggleAttribute.call(this, name, force); } - }; - } -}; - -// Helper to defer initial attribute processing for parser generated -// custom elements. These elements are created without attributes -// so attributes cannot be processed in the constructor. Instead, -// these elements are customized at the first opportunity: -// 1. when the element is connected -// 2. when any attribute API is first used -// 3. when the document becomes readyState === interactive (the parser is done) -let elementsPendingAttributes: Set | undefined; -if (document.readyState === 'loading') { - elementsPendingAttributes = new Set(); - document.addEventListener( - 'readystatechange', - () => { - elementsPendingAttributes!.forEach((instance) => - customizeAttributes(instance, definitionForElement.get(instance)!) - ); - }, - {once: true} - ); -} -const ensureAttributesCustomized = ( - instance: CustomHTMLElement & HTMLElement -) => { - if (!elementsPendingAttributes?.has(instance)) { - return; - } - customizeAttributes(instance, definitionForElement.get(instance)!); -}; - -// Approximate observedAttributes from the user class, since the stand-in element had none -const customizeAttributes = ( - instance: CustomHTMLElement & HTMLElement, - definition: CustomElementDefinition -) => { - elementsPendingAttributes?.delete(instance); - if (!definition.attributeChangedCallback) { - return; - } - definition.observedAttributes.forEach((attr: string) => { - if (!instance.hasAttribute(attr)) { + ['formDisabledCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formDisabledCallback']?.apply(this, args); + } + } + + ['formResetCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formResetCallback']?.apply(this, args); + } + } + + ['formStateRestoreCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formStateRestoreCallback']?.apply(this, args); + } + } + + // no attributeChangedCallback or observedAttributes since these + // are simulated via setAttribute/removeAttribute patches + } as unknown) as CustomElementConstructor; + }; + window.CustomElementRegistry = ShimmedCustomElementsRegistry; + + // Helper to patch CE class setAttribute/getAttribute/toggleAttribute to + // implement attributeChangedCallback + const patchAttributes = ( + elementClass: CustomElementConstructor, + observedAttributes: Set, + attributeChangedCallback?: CustomHTMLElement['attributeChangedCallback'] + ) => { + if ( + observedAttributes.size === 0 || + attributeChangedCallback === undefined + ) { return; } - definition.attributeChangedCallback!.call( - instance, - attr, - null, - instance.getAttribute(attr) + const setAttribute = elementClass.prototype.setAttribute; + if (setAttribute) { + elementClass.prototype.setAttribute = function ( + n: string, + value: string + ) { + ensureAttributesCustomized(this); + const name = n.toLowerCase(); + if (observedAttributes.has(name)) { + const old = this.getAttribute(name); + setAttribute.call(this, name, value); + attributeChangedCallback.call(this, name, old, value); + } else { + setAttribute.call(this, name, value); + } + }; + } + const removeAttribute = elementClass.prototype.removeAttribute; + if (removeAttribute) { + elementClass.prototype.removeAttribute = function (n: string) { + ensureAttributesCustomized(this); + const name = n.toLowerCase(); + if (observedAttributes.has(name)) { + const old = this.getAttribute(name); + removeAttribute.call(this, name); + attributeChangedCallback.call(this, name, old, null); + } else { + removeAttribute.call(this, name); + } + }; + } + const toggleAttribute = elementClass.prototype.toggleAttribute; + if (toggleAttribute) { + elementClass.prototype.toggleAttribute = function ( + n: string, + force?: boolean + ) { + ensureAttributesCustomized(this); + const name = n.toLowerCase(); + if (observedAttributes.has(name)) { + const old = this.getAttribute(name); + toggleAttribute.call(this, name, force); + const newValue = this.getAttribute(name); + if (old !== newValue) { + attributeChangedCallback.call(this, name, old, newValue); + } + } else { + toggleAttribute.call(this, name, force); + } + }; + } + }; + + // Helper to defer initial attribute processing for parser generated + // custom elements. These elements are created without attributes + // so attributes cannot be processed in the constructor. Instead, + // these elements are customized at the first opportunity: + // 1. when the element is connected + // 2. when any attribute API is first used + // 3. when the document becomes readyState === interactive (the parser is done) + let elementsPendingAttributes: + | Set + | undefined; + if (document.readyState === 'loading') { + elementsPendingAttributes = new Set(); + document.addEventListener( + 'readystatechange', + () => { + elementsPendingAttributes!.forEach((instance) => + customizeAttributes(instance, definitionForElement.get(instance)!) + ); + }, + {once: true} ); - }); -}; + } + + const ensureAttributesCustomized = ( + instance: CustomHTMLElement & HTMLElement + ) => { + if (!elementsPendingAttributes?.has(instance)) { + return; + } + customizeAttributes(instance, definitionForElement.get(instance)!); + }; + + // Approximate observedAttributes from the user class, since the stand-in element had none + const customizeAttributes = ( + instance: CustomHTMLElement & HTMLElement, + definition: CustomElementDefinition + ) => { + elementsPendingAttributes?.delete(instance); + if (!definition.attributeChangedCallback) { + return; + } + definition.observedAttributes.forEach((attr: string) => { + if (!instance.hasAttribute(attr)) { + return; + } + definition.attributeChangedCallback!.call( + instance, + attr, + null, + instance.getAttribute(attr) + ); + }); + }; -// Helper to patch CE class hierarchy changing those CE classes created before applying the polyfill -// to make them work with the new patched CustomElementsRegistry -const patchHTMLElement = (elementClass: CustomElementConstructor): unknown => { - const parentClass = Object.getPrototypeOf(elementClass); + // Helper to patch CE class hierarchy changing those CE classes created before applying the polyfill + // to make them work with the new patched CustomElementsRegistry + const patchHTMLElement = ( + elementClass: CustomElementConstructor + ): unknown => { + const parentClass = Object.getPrototypeOf(elementClass); + + if (parentClass !== window.HTMLElement) { + if (parentClass === NativeHTMLElement) { + return Object.setPrototypeOf(elementClass, window.HTMLElement); + } - if (parentClass !== window.HTMLElement) { - if (parentClass === NativeHTMLElement) { - return Object.setPrototypeOf(elementClass, window.HTMLElement); + return patchHTMLElement(parentClass); } + return; + }; - return patchHTMLElement(parentClass); - } - return; -}; - -// Helper to upgrade an instance with a CE definition using "constructor call trick" -const customize = ( - instance: HTMLElement, - definition: CustomElementDefinition, - isUpgrade = false -) => { - Object.setPrototypeOf(instance, definition.elementClass.prototype); - definitionForElement.set(instance, definition); - upgradingInstance = instance; - try { - new definition.elementClass(); - } catch (_) { - patchHTMLElement(definition.elementClass); - new definition.elementClass(); - } - if (definition.attributeChangedCallback) { - // Note, these checks determine if the element is being parser created. - // and has no attributes when created. In this case, it may have attributes - // in HTML that are immediately processed. To handle this, the instance - // is added to a set and its attributes are customized at first - // opportunity (e.g. when connected or when the parser completes and the - // document becomes interactive). - if (elementsPendingAttributes !== undefined && !instance.hasAttributes()) { - elementsPendingAttributes.add(instance); - } else { - customizeAttributes(instance, definition); + // Helper to upgrade an instance with a CE definition using "constructor call trick" + const customize = ( + instance: HTMLElement, + definition: CustomElementDefinition, + isUpgrade = false + ) => { + // prevent double customization + if (definitionForElement.get(instance) === definition) { + return; } - } - if (isUpgrade && definition.connectedCallback && instance.isConnected) { - definition.connectedCallback.call(instance); - } -}; - -// Patch attachShadow to set customElements on shadowRoot when provided -const nativeAttachShadow = Element.prototype.attachShadow; -Element.prototype.attachShadow = function ( - init: ShadowRootInitWithSettableCustomElements, - ...args: Array -) { - // Note, We must remove `registry` from the init object to avoid passing it to - // the native implementation. Use string keys to avoid renaming in Closure. - const { - 'customElements': customElements, - 'registry': registry = customElements, - ...nativeInit - } = init; - const shadowRoot = nativeAttachShadow.call( - this, - nativeInit, - ...(args as []) - ) as ShadowRootWithSettableCustomElements; - if (registry !== undefined) { - shadowRoot['customElements'] = shadowRoot['registry'] = registry; - } - return shadowRoot; -}; - -// Install scoped creation API on Element & ShadowRoot -const creationContext: Array< - Document | CustomElementRegistry | Element | ShadowRoot -> = [document]; -const installScopedCreationMethod = ( - ctor: Function, - method: string, - from?: Document -) => { - const native = (from ? Object.getPrototypeOf(from) : ctor.prototype)[method]; - ctor.prototype[method] = function ( - this: Element | ShadowRoot, + Object.setPrototypeOf(instance, definition.elementClass.prototype); + definitionForElement.set(instance, definition); + upgradingInstance = instance; + try { + new definition.elementClass(); + } catch (_) { + patchHTMLElement(definition.elementClass); + new definition.elementClass(); + } + if (definition.attributeChangedCallback) { + // Note, these checks determine if the element is being parser created. + // and has no attributes when created. In this case, it may have attributes + // in HTML that are immediately processed. To handle this, the instance + // is added to a set and its attributes are customized at first + // opportunity (e.g. when connected or when the parser completes and the + // document becomes interactive). + if ( + elementsPendingAttributes !== undefined && + !instance.hasAttributes() + ) { + elementsPendingAttributes.add(instance); + } else { + customizeAttributes(instance, definition); + } + } + if (isUpgrade && definition.connectedCallback && instance.isConnected) { + definition.connectedCallback.call(instance); + } + }; + + // Patch attachShadow to set customElements on shadowRoot when provided + const nativeAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function ( + init: ShadowRootInitWithSettableCustomElements, ...args: Array ) { - creationContext.push(this); - const ret = native.apply(from || this, args); - // For disconnected elements, note their creation scope so that e.g. - // innerHTML into them will use the correct scope; note that - // insertAdjacentHTML doesn't return an element, but that's fine since - // it will have a parent that should have a scope - if (ret !== undefined) { - scopeForElement.set(ret, this); + // Note, We must remove `registry` from the init object to avoid passing it to + // the native implementation. Use string keys to avoid renaming in Closure. + const { + 'customElementRegistry': customElementRegistry, + 'registry': registry = customElementRegistry, + ...nativeInit + } = init; + const shadowRoot = nativeAttachShadow.call( + this, + nativeInit, + ...(args as []) + ) as ShadowRootWithSettableCustomElementRegistry; + if (registry !== undefined) { + registryForElement.set( + shadowRoot, + registry as ShimmedCustomElementsRegistry + ); + // for back compat, set both `registry` and `customElements` + (shadowRoot as ShadowRootInitWithSettableCustomElements)[ + 'registry' + ] = registry; + (shadowRoot as ShadowRootInitWithSettableCustomElements)[ + 'customElements' + ] = registry; } - creationContext.pop(); - return ret; + return shadowRoot; }; -}; -installScopedCreationMethod(ShadowRoot, 'createElement', document); -installScopedCreationMethod(ShadowRoot, 'createElementNS', document); -installScopedCreationMethod(ShadowRoot, 'importNode', document); -installScopedCreationMethod(Element, 'insertAdjacentHTML'); - -// Install scoped innerHTML on Element & ShadowRoot -const installScopedCreationSetter = (ctor: Function, name: string) => { - const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, name)!; - Object.defineProperty(ctor.prototype, name, { - ...descriptor, - set(value) { - creationContext.push(this); - descriptor.set!.call(this, value); - creationContext.pop(); + + const customElementRegistryDescriptor = { + get(this: Element) { + const registry = registryForElement.get(this); + return registry === undefined + ? ((this.nodeType === Node.DOCUMENT_NODE + ? this + : this.ownerDocument) as Document)?.defaultView?.customElements || + null + : registry; + }, + enumerable: true, + configurable: true, + }; + + const {createElement, createElementNS, importNode} = Document.prototype; + + Object.defineProperty( + Element.prototype, + 'customElementRegistry', + customElementRegistryDescriptor + ); + Object.defineProperties(Document.prototype, { + 'customElementRegistry': customElementRegistryDescriptor, + // https://dom.spec.whatwg.org/#dom-document-createelement + 'createElement': { + value( + this: Document, + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K] { + const customElementRegistry = + (options ?? {})['customElementRegistry'] ?? + globalCustomElementRegistry; + creationContext.push(customElementRegistry); + const el = createElement.call( + this, + tagName + ) as HTMLElementTagNameMap[K]; + creationContext.pop(); + setRegistryForSubtree( + el, + customElementRegistry as ShimmedCustomElementsRegistry + ); + return el; + }, + enumerable: true, + configurable: true, + }, + 'createElementNS': { + value( + this: Document, + namespace: string | null, + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K] { + const customElementRegistry = + (options ?? {})['customElementRegistry'] ?? + globalCustomElementRegistry; + creationContext.push(customElementRegistry); + const el = createElementNS.call( + this, + namespace, + tagName + ) as HTMLElementTagNameMap[K]; + creationContext.pop(); + setRegistryForSubtree( + el, + customElementRegistry as ShimmedCustomElementsRegistry + ); + return el; + }, + enumerable: true, + configurable: true, + }, + // https://dom.spec.whatwg.org/#dom-document-importnode + // Note, must always import shallow and do deep manually to set scopes + 'importNode': { + value( + this: Document, + node: T, + options?: boolean | ImportNodeOptions + ): T { + const deep = + typeof options === 'boolean' ? options : !options?.selfOnly; + const customElementRegistry = ((options ?? {}) as ImportNodeOptions)[ + 'customElementRegistry' + ]; + const performImport = (node: Node) => { + const registry = + (node as Element)['customElementRegistry'] ?? + customElementRegistry ?? + globalCustomElementRegistry; + creationContext.push(registry); + const imported = importNode.call(this, node); + creationContext.pop(); + setRegistryForSubtree( + imported, + registry as ShimmedCustomElementsRegistry + ); + if (deep) { + node.childNodes.forEach((n) => { + imported.appendChild(performImport(n)); + }); + } + return imported; + }; + return performImport(node) as T; + }, + enumerable: true, + configurable: true, }, }); -}; -installScopedCreationSetter(Element, 'innerHTML'); -installScopedCreationSetter(ShadowRoot, 'innerHTML'); - -// Install global registry -Object.defineProperty(window, 'customElements', { - value: new CustomElementRegistry(), - configurable: true, - writable: true, -}); - -if ( - !!window['ElementInternals'] && - !!window['ElementInternals'].prototype['setFormValue'] -) { - const internalsToHostMap = new WeakMap(); - const attachInternals = HTMLElement.prototype['attachInternals']; - const methods: Array = [ - 'setFormValue', - 'setValidity', - 'checkValidity', - 'reportValidity', - ]; + Object.defineProperty( + ShadowRoot.prototype, + 'customElementRegistry', + customElementRegistryDescriptor + ); - HTMLElement.prototype['attachInternals'] = function (...args) { - const internals = attachInternals.call(this, ...args); - internalsToHostMap.set(internals, this); - return internals; + // Install scoped creation API on Element & ShadowRoot + const installScopedMethod = ( + ctor: Function, + method: string, + coda = function (this: Element, result: Node) { + setRegistryForSubtree( + result ?? this, + this['customElementRegistry'] as ShimmedCustomElementsRegistry + ); + } + ) => { + const native = ctor.prototype[method]; + if (native === undefined) { + return; + } + ctor.prototype[method] = function ( + this: Element | ShadowRoot, + ...args: Array + ) { + creationContext.push(this['customElementRegistry']); + const ret = native.apply(this, args); + creationContext.pop(); + coda?.call(this as Element, ret); + return ret; + }; }; - methods.forEach((method) => { - const proto = window['ElementInternals'].prototype; - const originalMethod = proto[method] as Function; + const applyScopeFromParent = function (this: Element) { + const scope = (this.parentNode ?? this) as Element; + setRegistryForSubtree( + scope, + scope['customElementRegistry'] as ShimmedCustomElementsRegistry + ); + }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (proto as any)[method] = function (...args: Array) { - const host = internalsToHostMap.get(this); - const definition = definitionForElement.get(host!); - if ( - (definition as {formAssociated?: boolean})['formAssociated'] === true - ) { - return originalMethod?.call(this, ...args); - } else { - throw new DOMException( - `Failed to execute ${originalMethod} on 'ElementInternals': The target element is not a form-associated custom element.` - ); + const maybeApplyNullScope = function (shadowRoot: ShadowRoot) { + const {host} = shadowRoot ?? {}; + if (host?.hasAttribute(DSD_HOST_ATTRIBUTE)) { + host.removeAttribute(DSD_HOST_ATTRIBUTE); + setRegistryForSubtree(shadowRoot, null, false, true); + return true; + } + return false; + }; + + const setNullScopeWhenNeeded = function (this: Element | ShadowRoot) { + this.querySelectorAll(`[${DSD_HOST_ATTRIBUTE}]`).forEach((el) => { + maybeApplyNullScope(el.shadowRoot!); + }); + }; + + installScopedMethod(Element, 'insertAdjacentHTML', applyScopeFromParent); + installScopedMethod(Element, 'setHTMLUnsafe', setNullScopeWhenNeeded); + installScopedMethod(ShadowRoot, 'setHTMLUnsafe', setNullScopeWhenNeeded); + + // For setting null elements to this scope. + installScopedMethod(Node, 'appendChild'); + installScopedMethod(Node, 'insertBefore'); + + // Note, must always clone shallow and do deep manually to set scopes + const cloneNode = Node.prototype.cloneNode; + Node.prototype['cloneNode'] = function (this: Node, deep?: boolean) { + const cloneWithScope = (node: Node) => { + const registry = + node.nodeType === Node.ELEMENT_NODE + ? (node as Element)['customElementRegistry'] + : globalCustomElementRegistry; + creationContext.push(registry); + const cloned = cloneNode.call(node); + creationContext.pop(); + setRegistryForSubtree(cloned, registry as ShimmedCustomElementsRegistry); + if (deep) { + node.childNodes.forEach((n) => { + cloned.appendChild(cloneWithScope(n)); + }); } + return cloned; }; - }); + return cloneWithScope(this); + }; - // Emulate the native RadioNodeList object - const RadioNodeList = (class - extends Array - implements Omit { - private _elements: Array; + installScopedMethod(Element, 'append'); + installScopedMethod(Element, 'prepend'); + installScopedMethod(Element, 'insertAdjacentElement', applyScopeFromParent); + installScopedMethod(Element, 'replaceChild'); + installScopedMethod(Element, 'replaceChildren'); + installScopedMethod(DocumentFragment, 'append'); + installScopedMethod(Element, 'replaceWith', applyScopeFromParent); + + // Install scoped innerHTML on Element & ShadowRoot + const installScopedSetter = (ctor: Function, name: string) => { + const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, name)!; + Object.defineProperty(ctor.prototype, name, { + ...descriptor, + set(value) { + creationContext.push(this['customElementRegistry']); + descriptor.set!.call(this, value); + creationContext.pop(); + setRegistryForSubtree( + this, + this['customElementRegistry'] as ShimmedCustomElementsRegistry + ); + }, + }); + }; + installScopedSetter(Element, 'innerHTML'); + installScopedSetter(ShadowRoot, 'innerHTML'); + + // Install global registry + Object.defineProperty(window, 'customElements', { + value: globalCustomElementRegistry, + configurable: true, + writable: true, + }); - constructor(elements: Array) { - super(...elements); - this._elements = elements; - } - [index: number]: Node; + if ( + !!window['ElementInternals'] && + !!window['ElementInternals'].prototype['setFormValue'] + ) { + const internalsToHostMap = new WeakMap(); + const attachInternals = HTMLElement.prototype['attachInternals']; + const methods: Array = [ + 'setFormValue', + 'setValidity', + 'checkValidity', + 'reportValidity', + ]; + + HTMLElement.prototype['attachInternals'] = function (...args) { + const internals = attachInternals.call(this, ...args); + internalsToHostMap.set(internals, this); + return internals; + }; - item(index: number): Node | null { - return this[index]; - } + const proto = window['ElementInternals'].prototype; - get ['value']() { - return ( - this._elements.find((element) => element['checked'] === true)?.value || - '' - ); - } - } as unknown) as {new (elements: Array): RadioNodeList}; - - // Emulate the native HTMLFormControlsCollection object - const HTMLFormControlsCollection = class - implements HTMLFormControlsCollection { - length: number; - - constructor(elements: Array) { - const entries = new Map(); - elements.forEach((element, index) => { - const name = element.getAttribute('name'); - const nameReference = entries.get(name) || []; - this[+index] = element; - nameReference.push(element); - entries.set(name, nameReference); - }); - this['length'] = elements.length; - entries.forEach((value, name) => { - if (!value) return; - if (name === 'length' || name === 'item' || name === 'namedItem') - return; - if (value.length === 1) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this as any)[name!] = value[0]; + methods.forEach((method) => { + const originalMethod = proto[method] as Function; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (proto as any)[method] = function (...args: Array) { + const host = internalsToHostMap.get(this); + const definition = definitionForElement.get(host!); + if ( + (definition as {formAssociated?: boolean})['formAssociated'] === true + ) { + return originalMethod?.call(this, ...args); } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this as any)[name!] = new RadioNodeList(value as HTMLInputElement[]); + throw new DOMException( + `Failed to execute ${originalMethod} on 'ElementInternals': The target element is not a form-associated custom element.` + ); } - }); - } + }; + }); - [index: number]: Element; + // Emulate the native RadioNodeList object + const RadioNodeList = (class + extends Array + implements Omit { + private _elements: Array; - ['item'](index: number): Element | null { - return this[index] ?? null; - } + constructor(elements: Array) { + super(...elements); + this._elements = elements; + } + [index: number]: Node; - [Symbol.iterator](): IterableIterator { - throw new Error('Method not implemented.'); - } + item(index: number): Node | null { + return this[index]; + } - ['namedItem'](key: string): RadioNodeList | Element | null { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (this as any)[key] ?? null; - } - }; + get ['value']() { + return ( + this._elements.find((element) => element['checked'] === true) + ?.value || '' + ); + } + } as unknown) as {new (elements: Array): RadioNodeList}; + + // Emulate the native HTMLFormControlsCollection object + const HTMLFormControlsCollection = class + implements HTMLFormControlsCollection { + length: number; + + constructor(elements: Array) { + const entries = new Map(); + elements.forEach((element, index) => { + const name = element.getAttribute('name'); + const nameReference = entries.get(name) || []; + this[+index] = element; + nameReference.push(element); + entries.set(name, nameReference); + }); + this['length'] = elements.length; + entries.forEach((value, name) => { + if (!value) return; + if (name === 'length' || name === 'item' || name === 'namedItem') + return; + if (value.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this as any)[name!] = value[0]; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this as any)[name!] = new RadioNodeList( + value as HTMLInputElement[] + ); + } + }); + } - // Override the built-in HTMLFormElements.prototype.elements getter - const formElementsDescriptor = Object.getOwnPropertyDescriptor( - HTMLFormElement.prototype, - 'elements' - )!; + [index: number]: Element; + + ['item'](index: number): Element | null { + return this[index] ?? null; + } - Object.defineProperty(HTMLFormElement.prototype, 'elements', { - get: function () { - const nativeElements = formElementsDescriptor.get!.call(this); + [Symbol.iterator](): IterableIterator { + throw new Error('Method not implemented.'); + } + + ['namedItem'](key: string): RadioNodeList | Element | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this as any)[key] ?? null; + } + }; - const include: Array = []; + // Override the built-in HTMLFormElements.prototype.elements getter + const formElementsDescriptor = Object.getOwnPropertyDescriptor( + HTMLFormElement.prototype, + 'elements' + )!; - for (const element of nativeElements) { - const definition = definitionForElement.get(element); + Object.defineProperty(HTMLFormElement.prototype, 'elements', { + get: function () { + const nativeElements = formElementsDescriptor.get!.call(this); - // Only purposefully formAssociated elements or built-ins will feature in elements - if (!definition || definition['formAssociated'] === true) { - include.push(element); + const include: Array = []; + + for (const element of nativeElements) { + const definition = definitionForElement.get(element); + + // Only purposefully formAssociated elements or built-ins will feature in elements + if (!definition || definition['formAssociated'] === true) { + include.push(element); + } } - } - return new HTMLFormControlsCollection(include); - }, - }); -} + return new HTMLFormControlsCollection(include); + }, + }); + } +})(); diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index 94841877..92d447f8 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -1,29 +1,64 @@ export {}; declare global { + interface CustomElementRegistry { + // https://html.spec.whatwg.org/multipage/custom-elements.html#dom-customelementregistry-initialize + initialize: (node: Node) => Node; + } + + // https://dom.spec.whatwg.org/#documentorshadowroot interface ShadowRoot { - // This overload is for roots that use the global registry + readonly customElementRegistry: CustomElementRegistry | null; + } + + // https://dom.spec.whatwg.org/#documentorshadowroot + interface Document { + readonly customElementRegistry: CustomElementRegistry | null; + // https://dom.spec.whatwg.org/#dom-document-createelement createElement( tagName: K, options?: ElementCreationOptions ): HTMLElementTagNameMap[K]; - // This overload is for roots that use a scoped registry - createElement( + // https://dom.spec.whatwg.org/#dom-document-createelementns + createElementNS( + namespace: string | null, tagName: K, options?: ElementCreationOptions - ): BuiltInHTMLElementTagNameMap[K]; - createElement( - tagName: string, - options?: ElementCreationOptions - ): HTMLElement; + ): HTMLElementTagNameMap[K]; + // https://dom.spec.whatwg.org/#dom-document-importnode + importNode( + node: T, + options?: boolean | ImportNodeOptions + ): T; } - interface ShadowRootInit { - customElements?: CustomElementRegistry; + // https://dom.spec.whatwg.org/#element + interface Element { + readonly customElementRegistry: CustomElementRegistry | null; } - interface ShadowRoot { - readonly customElements?: CustomElementRegistry; + // https://dom.spec.whatwg.org/#dictdef-shadowrootinit + interface InitializeShadowRootInit { + customElementRegistry?: CustomElementRegistry; + } + + // https://dom.spec.whatwg.org/#dictdef-importnodeoptions + interface ImportNodeOptions { + /** + * A boolean flag, whose default value is `false`, which controls whether to include the entire DOM + * subtree of the `externalNode` in the import. `selfOnly` has the opposite effect of supplying a + * boolean as the `options` argument. + * + * If `selfOnly` is set to `false`, then `externalNode` and all of its descendants are copied. + * If `selfOnly` is set to `true`, then only `externalNode` is imported — the new node has no children. + */ + selfOnly?: boolean; + customElementRegistry?: CustomElementRegistry; + } + // https://dom.spec.whatwg.org/#dictdef-elementcreationoptions + interface ElementCreationOptions { + is?: string; + customElementRegistry?: CustomElementRegistry; } /* diff --git a/packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html b/packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html new file mode 100644 index 00000000..6fd0d961 --- /dev/null +++ b/packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html @@ -0,0 +1,32 @@ + + + +
+ +
+
+ +
+ + + diff --git a/packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html.js b/packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html.js new file mode 100644 index 00000000..fccf7917 --- /dev/null +++ b/packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html.js @@ -0,0 +1,46 @@ +import {expect} from '@open-wc/testing'; + +// prettier-ignore +import {getTestTagName, getTestElement, getShadowRoot, getHTML, createTemplate} from './utils.js'; + +describe('Declarative ShadowRoot', () => { + it('should customize elements in global registry', () => { + const host = document.getElementById('host1'); + expect(host.shadowRoot).not.to.be.null; + expect(host.shadowRoot.customElementRegistry).to.be.equal( + window.customElements + ); + const ce = host.shadowRoot.firstElementChild; + expect(ce.customElementRegistry).to.be.equal(window.customElements); + expect(ce).to.be.instanceOf(customElements.get(ce.localName)); + }); + + it('should *not* customize elements in null registry', () => { + const host = document.getElementById('host2'); + expect(host.shadowRoot).not.to.be.null; + expect(host.shadowRoot.customElementRegistry).to.be.null; + const ce = host.shadowRoot.firstElementChild; + expect(ce.customElementRegistry).to.be.null; + expect(ce).not.to.be.instanceOf(customElements.get(ce.localName)); + }); + + it('should customize when registry initializes', () => { + const host = document.getElementById('host2'); + const registry = new CustomElementRegistry(); + class RegistryDsdElement extends HTMLElement { + constructor() { + super(); + this.attachShadow({ + mode: 'open', + }).innerHTML = `${this.localName}: scoped`; + } + } + registry.define('dsd-element', RegistryDsdElement); + registry.initialize(host.shadowRoot); + expect(host.shadowRoot.customElementRegistry).to.be.equal(registry); + registry.upgrade(host.shadowRoot); + const ce = host.shadowRoot.firstElementChild; + expect(ce.customElementRegistry).to.be.equal(registry); + expect(ce).to.be.instanceOf(RegistryDsdElement); + }); +}); diff --git a/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js b/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js index 69ad914f..3f6af8c3 100644 --- a/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js +++ b/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js @@ -11,7 +11,7 @@ describe('ShadowRoot', () => { constructor() { super(); - this.attachShadow({mode: 'open', customElements: registry}); + this.attachShadow({mode: 'open', customElementRegistry: registry}); } }; customElements.define(tagName, CustomElementClass); @@ -19,10 +19,80 @@ describe('ShadowRoot', () => { const $el = new CustomElementClass(); expect($el).to.be.instanceof(CustomElementClass); - expect($el.shadowRoot.customElements).to.be.equal(registry); + expect($el.shadowRoot.customElementRegistry).to.be.equal(registry); }); describe('with custom registry', () => { + describe('createElement', () => { + it('should create a regular element', () => { + const registry = new CustomElementRegistry(); + const shadowRoot = getShadowRoot(registry); + + const $el = document.createElement('div', { + customElementRegistry: shadowRoot.customElementRegistry, + }); + + expect($el).to.not.be.undefined; + expect($el).to.be.instanceof(HTMLDivElement); + }); + + it(`shouldn't upgrade an element defined in the global registry`, () => { + const {tagName, CustomElementClass} = getTestElement(); + customElements.define(tagName, CustomElementClass); + const registry = new CustomElementRegistry(); + const shadowRoot = getShadowRoot(registry); + + const $el = document.createElement(tagName, { + customElementRegistry: shadowRoot.customElementRegistry, + }); + + expect($el).to.not.be.undefined; + expect($el).to.not.be.instanceof(CustomElementClass); + }); + + it(`should upgrade an element defined in the custom registry`, () => { + const {tagName, CustomElementClass} = getTestElement(); + const registry = new CustomElementRegistry(); + registry.define(tagName, CustomElementClass); + const shadowRoot = getShadowRoot(registry); + + const $el = document.createElement(tagName, { + customElementRegistry: shadowRoot.customElementRegistry, + }); + + expect($el).to.not.be.undefined; + expect($el).to.be.instanceof(CustomElementClass); + }); + }); + + describe('innerHTML', () => { + it(`shouldn't upgrade a defined custom element in the global registry`, () => { + const {tagName, CustomElementClass} = getTestElement(); + customElements.define(tagName, CustomElementClass); + const registry = new CustomElementRegistry(); + const shadowRoot = getShadowRoot(registry); + + shadowRoot.innerHTML = `<${tagName}>`; + + expect(shadowRoot.firstElementChild).to.not.be.instanceof( + CustomElementClass + ); + }); + + it('should upgrade a defined custom element in the custom registry', () => { + const {tagName, CustomElementClass} = getTestElement(); + const registry = new CustomElementRegistry(); + registry.define(tagName, CustomElementClass); + const shadowRoot = getShadowRoot(registry); + + shadowRoot.innerHTML = `<${tagName}>`; + + expect(shadowRoot.firstElementChild).to.be.instanceof( + CustomElementClass + ); + }); + }); + describe('importNode', () => { it('should import a basic node', () => { const registry = new CustomElementRegistry(); @@ -30,12 +100,39 @@ describe('ShadowRoot', () => { const html = 'sample'; const $div = getHTML(html); - const $clone = shadowRoot.importNode($div, true); + const $clone = document.importNode($div, { + customElementRegistry: shadowRoot.customELements, + }); expect($clone.outerHTML).to.be.equal(html); }); - it('should import a node tree with an upgraded custom element in global registry', () => { + it('should maintain registry on the cloned node', () => { + const registry = new CustomElementRegistry(); + const shadowRoot = getShadowRoot(registry); + document.createElement('div', { + customElementRegistry: shadowRoot.customElementRegistry, + }); + shadowRoot.innerHTML = '
'; + const globalDiv = document.createElement('div'); + shadowRoot.appendChild(globalDiv); + const div1 = shadowRoot.firstElementChild; + const div2 = shadowRoot.lastElementChild; + const clone1 = document.importNode(div1, { + customElementRegistry: shadowRoot.customElementRegistry, + }); + const clone2 = document.importNode(div2, { + customElementRegistry: shadowRoot.customElementRegistry, + }); + expect(clone1.customElementRegistry).to.be.equal( + div1.customElementRegistry + ); + expect(clone2.customElementRegistry).to.be.equal( + div2.customElementRegistry + ); + }); + + it('should import a node tree with an upgraded custom elements matching source registry', () => { const {tagName, CustomElementClass} = getTestElement(); customElements.define(tagName, CustomElementClass); @@ -44,13 +141,21 @@ describe('ShadowRoot', () => { registry.define(tagName, AnotherCustomElementClass); const shadowRoot = getShadowRoot(registry); - const $el = getHTML(`<${tagName}>`); - - const $clone = shadowRoot.importNode($el, true); - - expect($clone.outerHTML).to.be.equal(`<${tagName}>`); - expect($clone).not.to.be.instanceof(CustomElementClass); - expect($clone).to.be.instanceof(AnotherCustomElementClass); + const el1 = getHTML(`<${tagName}>`, shadowRoot); + const el2 = document.createElement(tagName); + el1.append(el2); + const clone1 = document.importNode(el1, { + customElementRegistry: shadowRoot.customElementRegistry, + }); + const clone2 = clone1.firstElementChild; + expect(clone1.customElementRegistry).to.be.equal( + el1.customElementRegistry + ); + expect(clone2.customElementRegistry).to.be.equal( + el2.customElementRegistry + ); + expect(clone1).to.be.instanceof(AnotherCustomElementClass); + expect(clone2).to.be.instanceof(CustomElementClass); }); it('should import a node tree with an upgraded custom element from another shadowRoot', () => { @@ -65,11 +170,14 @@ describe('ShadowRoot', () => { secondRegistry.define(tagName, AnotherCustomElementClass); const secondShadowRoot = getShadowRoot(secondRegistry); - const $clone = secondShadowRoot.importNode($el, true); - - expect($clone.outerHTML).to.be.equal($el.outerHTML); - expect($clone).not.to.be.instanceof(CustomElementClass); - expect($clone).to.be.instanceof(AnotherCustomElementClass); + const $clone = document.importNode($el, { + customElementRegistry: secondShadowRoot.customElementRegistry, + }); + expect($clone.customElementRegistry).to.be.equal( + $el.customElementRegistry + ); + expect($clone).to.be.instanceof(CustomElementClass); + expect($clone).not.to.be.instanceof(AnotherCustomElementClass); }); it('should import a node tree with a non upgraded custom element', () => { @@ -78,7 +186,9 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = document.importNode($el, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); }); @@ -89,9 +199,11 @@ describe('ShadowRoot', () => { registry.define(tagName, CustomElementClass); const shadowRoot = getShadowRoot(registry); - const $el = getHTML(`<${tagName}>`); + const $el = getHTML(`<${tagName}>`, shadowRoot); - const $clone = shadowRoot.importNode($el, true); + const $clone = document.importNode($el, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone).to.be.instanceof(CustomElementClass); }); @@ -102,7 +214,9 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $template = createTemplate(`<${tagName}>`); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = document.importNode($template.content, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -117,7 +231,9 @@ describe('ShadowRoot', () => { const $template = createTemplate(`<${tagName}>`); registry.define(tagName, CustomElementClass); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = document.importNode($template.content, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -126,82 +242,29 @@ describe('ShadowRoot', () => { expect($clone.firstElementChild).to.be.instanceof(CustomElementClass); }); }); + }); + describe('without custom registry', () => { describe('createElement', () => { it('should create a regular element', () => { - const registry = new CustomElementRegistry(); - const shadowRoot = getShadowRoot(registry); - - const $el = shadowRoot.createElement('div'); - - expect($el).to.not.be.undefined; - expect($el).to.be.instanceof(HTMLDivElement); - }); - - it(`shouldn't upgrade an element defined in the global registry`, () => { - const {tagName, CustomElementClass} = getTestElement(); - customElements.define(tagName, CustomElementClass); - const registry = new CustomElementRegistry(); - const shadowRoot = getShadowRoot(registry); - - const $el = shadowRoot.createElement(tagName); - - expect($el).to.not.be.undefined; - expect($el).to.not.be.instanceof(CustomElementClass); - }); - - it(`should upgrade an element defined in the custom registry`, () => { - const {tagName, CustomElementClass} = getTestElement(); - const registry = new CustomElementRegistry(); - registry.define(tagName, CustomElementClass); - const shadowRoot = getShadowRoot(registry); - - const $el = shadowRoot.createElement(tagName); - - expect($el).to.not.be.undefined; - expect($el).to.be.instanceof(CustomElementClass); - }); - }); - - describe('createElementNS', () => { - it('should create a regular element', () => { - const registry = new CustomElementRegistry(); - const shadowRoot = getShadowRoot(registry); + const shadowRoot = getShadowRoot(); - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - 'div' - ); + const $el = document.createElement('div', { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(HTMLDivElement); }); - it(`shouldn't upgrade an element defined in the global registry`, () => { + it(`should upgrade an element defined in the global registry`, () => { const {tagName, CustomElementClass} = getTestElement(); customElements.define(tagName, CustomElementClass); - const registry = new CustomElementRegistry(); - const shadowRoot = getShadowRoot(registry); - - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - tagName - ); - - expect($el).to.not.be.undefined; - expect($el).to.not.be.instanceof(CustomElementClass); - }); - - it(`should upgrade an element defined in the custom registry`, () => { - const {tagName, CustomElementClass} = getTestElement(); - const registry = new CustomElementRegistry(); - registry.define(tagName, CustomElementClass); - const shadowRoot = getShadowRoot(registry); + const shadowRoot = getShadowRoot(); - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - tagName - ); + const $el = document.createElement(tagName, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(CustomElementClass); @@ -209,11 +272,11 @@ describe('ShadowRoot', () => { }); describe('innerHTML', () => { - it(`shouldn't upgrade a defined custom element in the global registry`, () => { + it(`shouldn't upgrade a defined custom element in a custom registry`, () => { const {tagName, CustomElementClass} = getTestElement(); - customElements.define(tagName, CustomElementClass); const registry = new CustomElementRegistry(); - const shadowRoot = getShadowRoot(registry); + registry.define(tagName, CustomElementClass); + const shadowRoot = getShadowRoot(); shadowRoot.innerHTML = `<${tagName}>`; @@ -222,11 +285,10 @@ describe('ShadowRoot', () => { ); }); - it('should upgrade a defined custom element in the custom registry', () => { + it('should upgrade a defined custom element in the global registry', () => { const {tagName, CustomElementClass} = getTestElement(); - const registry = new CustomElementRegistry(); - registry.define(tagName, CustomElementClass); - const shadowRoot = getShadowRoot(registry); + customElements.define(tagName, CustomElementClass); + const shadowRoot = getShadowRoot(); shadowRoot.innerHTML = `<${tagName}>`; @@ -235,16 +297,16 @@ describe('ShadowRoot', () => { ); }); }); - }); - describe('without custom registry', () => { describe('importNode', () => { it('should import a basic node', () => { const shadowRoot = getShadowRoot(); const html = 'sample'; const $div = getHTML(html); - const $clone = shadowRoot.importNode($div, true); + const $clone = document.importNode($div, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone.outerHTML).to.be.equal(html); }); @@ -256,7 +318,9 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = document.importNode($el, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); expect($clone).to.be.instanceof(CustomElementClass); @@ -271,7 +335,9 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`, firstShadowRoot); const secondShadowRoot = getShadowRoot(); - const $clone = secondShadowRoot.importNode($el, true); + const $clone = document.importNode($el, { + customElementRegistry: secondShadowRoot.customElementRegistry, + }); expect($clone.outerHTML).to.be.equal($el.outerHTML); }); @@ -281,7 +347,9 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = document.importNode($el, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); }); @@ -291,7 +359,9 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(); const $template = createTemplate(`<${tagName}>`); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = document.importNode($template.content, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -305,7 +375,9 @@ describe('ShadowRoot', () => { const $template = createTemplate(`<${tagName}>`); customElements.define(tagName, CustomElementClass); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = document.importNode($template.content, { + customElementRegistry: shadowRoot.customElementRegistry, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -314,82 +386,5 @@ describe('ShadowRoot', () => { expect($clone.firstElementChild).to.be.instanceof(CustomElementClass); }); }); - - describe('createElement', () => { - it('should create a regular element', () => { - const shadowRoot = getShadowRoot(); - - const $el = shadowRoot.createElement('div'); - - expect($el).to.not.be.undefined; - expect($el).to.be.instanceof(HTMLDivElement); - }); - - it(`should upgrade an element defined in the global registry`, () => { - const {tagName, CustomElementClass} = getTestElement(); - customElements.define(tagName, CustomElementClass); - const shadowRoot = getShadowRoot(); - - const $el = shadowRoot.createElement(tagName); - - expect($el).to.not.be.undefined; - expect($el).to.be.instanceof(CustomElementClass); - }); - }); - - describe('createElementNS', () => { - it('should create a regular element', () => { - const shadowRoot = getShadowRoot(); - - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - 'div' - ); - - expect($el).to.not.be.undefined; - expect($el).to.be.instanceof(HTMLDivElement); - }); - - it(`should upgrade an element defined in the global registry`, () => { - const {tagName, CustomElementClass} = getTestElement(); - customElements.define(tagName, CustomElementClass); - const shadowRoot = getShadowRoot(); - - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - tagName - ); - - expect($el).to.not.be.undefined; - expect($el).to.be.instanceof(CustomElementClass); - }); - }); - - describe('innerHTML', () => { - it(`shouldn't upgrade a defined custom element in a custom registry`, () => { - const {tagName, CustomElementClass} = getTestElement(); - const registry = new CustomElementRegistry(); - registry.define(tagName, CustomElementClass); - const shadowRoot = getShadowRoot(); - - shadowRoot.innerHTML = `<${tagName}>`; - - expect(shadowRoot.firstElementChild).to.not.be.instanceof( - CustomElementClass - ); - }); - - it('should upgrade a defined custom element in the global registry', () => { - const {tagName, CustomElementClass} = getTestElement(); - customElements.define(tagName, CustomElementClass); - const shadowRoot = getShadowRoot(); - - shadowRoot.innerHTML = `<${tagName}>`; - - expect(shadowRoot.firstElementChild).to.be.instanceof( - CustomElementClass - ); - }); - }); }); }); diff --git a/packages/scoped-custom-element-registry/test/common-registry-tests.js b/packages/scoped-custom-element-registry/test/common-registry-tests.js index 30f0756b..5e90ff24 100644 --- a/packages/scoped-custom-element-registry/test/common-registry-tests.js +++ b/packages/scoped-custom-element-registry/test/common-registry-tests.js @@ -1,5 +1,9 @@ import {expect, nextFrame} from '@open-wc/testing'; -import {getTestElement} from './utils.js'; +import { + getTestElement, + createTemplate, + getUninitializedShadowRoot, +} from './utils.js'; export const commonRegistryTests = (registry) => { describe('define', () => { @@ -51,7 +55,9 @@ export const commonRegistryTests = (registry) => { describe('upgrade', () => { it('should upgrade a custom element directly', () => { const {tagName, CustomElementClass} = getTestElement(); - const $el = document.createElement(tagName); + const $el = document.createElement(tagName, { + customElementRegistry: registry, + }); registry.define(tagName, CustomElementClass); expect($el).to.not.be.instanceof(CustomElementClass); @@ -76,4 +82,613 @@ export const commonRegistryTests = (registry) => { expect(defined).to.be.true; }); }); + + describe('createElement', () => { + it('should create built-in elements', async () => { + const el = document.createElement('div', {}); + expect(el).to.be.ok; + }); + + it('should create custom elements', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const el = document.createElement(tagName, { + customElementRegistry: registry, + }); + expect(el).to.be.instanceOf(CustomElementClass); + }); + }); + + describe('importNode', () => { + it('should upgrade custom elements in an imported subtree', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const clone = document.importNode(template.content, { + customElementRegistry: registry, + }); + const els = clone.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + }); + }); + + describe('initialize', () => { + it('can create uninitialized roots', async () => { + const shadowRoot = getUninitializedShadowRoot(); + expect(shadowRoot.customElementRegistry).to.be.null; + shadowRoot.innerHTML = `
`; + const el = shadowRoot.firstElementChild; + expect(el.customElementRegistry).to.be.null; + }); + + it('initialize sets customElements', async () => { + const shadowRoot = getUninitializedShadowRoot(); + shadowRoot.innerHTML = `
`; + registry.initialize(shadowRoot); + expect(shadowRoot.customElementRegistry).to.be.equal(registry); + shadowRoot.innerHTML = `
`; + const el = shadowRoot.firstElementChild; + expect(el.customElementRegistry).to.be.equal(registry); + }); + + it('initialize sets customElements for entire subtree where null', async function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299299 + this.skip(); + } + const shadowRoot = getUninitializedShadowRoot(); + shadowRoot.innerHTML = `
`; + const el = shadowRoot.firstElementChild; + const registry2 = new CustomElementRegistry(); + registry2.initialize(el); + const expectRegistryForSubtree = (node, registry) => { + expect(node.customElementRegistry).to.be.equal(registry); + node.querySelectorAll('*').forEach((child) => { + expect(child.customElementRegistry).to.be.equal(registry); + }); + }; + el.innerHTML = `
`; + expectRegistryForSubtree(el, registry2); + el.insertAdjacentHTML('afterend', `
`); + const el2 = shadowRoot.lastChild; + expectRegistryForSubtree(el2, null); + registry.initialize(shadowRoot); + expectRegistryForSubtree(el, registry2); + expectRegistryForSubtree(el2, registry); + }); + + it('should not upgrade custom elements in uninitialized subtree', async () => { + const shadowRoot = getUninitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + shadowRoot.innerHTML = `<${tagName}>
`; + const el = shadowRoot.firstElementChild; + const container = shadowRoot.lastElementChild; + expect(el.localName).to.be.equal(tagName); + expect(el).not.to.be.instanceOf(CustomElementClass); + container.innerHTML = `<${tagName}>`; + const el2 = container.firstElementChild; + expect(el2.localName).to.be.equal(tagName); + expect(el2).not.to.be.instanceOf(CustomElementClass); + }); + + it('should not upgrade custom elements in initialized subtree', async () => { + const shadowRoot = getUninitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + shadowRoot.innerHTML = `<${tagName}>
`; + registry.initialize(shadowRoot); + const el = shadowRoot.firstElementChild; + const container = shadowRoot.lastElementChild; + expect(el.localName).to.be.equal(tagName); + expect(el).not.to.be.instanceOf(CustomElementClass); + // Note, with the tree initialized, the parent's registry is set + // even though it is not customized. So innerHTML uses the parent's + // registry. + container.innerHTML = `<${tagName}>`; + const el2 = container.firstElementChild; + expect(el2.localName).to.be.equal(tagName); + expect(el2).to.be.instanceOf(CustomElementClass); + }); + }); + + describe('null customElements', () => { + describe('do not customize when created', () => { + it('with innerHTML', function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299603 + this.skip(); + } + const shadowRoot = getUninitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + // globally define this + customElements.define(tagName, CustomElementClass); + document.body.append(shadowRoot.host); + shadowRoot.innerHTML = ` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `; + const els = shadowRoot.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => + expect(el).not.to.be.instanceOf(CustomElementClass) + ); + shadowRoot.host.remove(); + }); + it('with insertAdjacentHTML', function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299603 + this.skip(); + } + const shadowRoot = getUninitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + // globally define this + customElements.define(tagName, CustomElementClass); + document.body.append(shadowRoot.host); + shadowRoot.innerHTML = `
`; + shadowRoot.firstElementChild.insertAdjacentHTML( + 'afterbegin', + ` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + ` + ); + const els = shadowRoot.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => + expect(el).not.to.be.instanceOf(CustomElementClass) + ); + shadowRoot.host.remove(); + }); + it('with setHTMLUnsafe', function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299603 + this.skip(); + } + if (!(`setHTMLUnsafe` in Element.prototype)) { + this.skip(); + } + const shadowRoot = getUninitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + // globally define this + customElements.define(tagName, CustomElementClass); + document.body.append(shadowRoot.host); + shadowRoot.innerHTML = `
`; + shadowRoot.firstElementChild.setHTMLUnsafe(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const els = shadowRoot.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => + expect(el).not.to.be.instanceOf(CustomElementClass) + ); + shadowRoot.host.remove(); + }); + }); + describe('customize when connected', () => { + it('append from uninitialized shadowRoot', async function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299603 + this.skip(); + } + const shadowRoot = getUninitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + document.body.append(container); + shadowRoot.innerHTML = ` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `; + container.append(shadowRoot); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('cloned and appended from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const clone = template.content.cloneNode(true); + clone.querySelectorAll('*').forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + container.append(clone); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('append from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + container.append(content); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('appendChild from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + container.appendChild(content); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('insertBefore from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + container.insertBefore(content, null); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('prepend from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + container.prepend(content); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('insertAdjacentElement from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + const parent = document.createElement('div', { + customElementRegistry: registry, + }); + container.append(parent); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + const contentEls = Array.from(content.querySelectorAll('*')); + contentEls.forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + parent.insertAdjacentElement('beforebegin', contentEls[1]); + parent.insertAdjacentElement('afterend', contentEls[2]); + parent.insertAdjacentElement('afterbegin', contentEls[0]); + parent.insertAdjacentElement('beforeend', contentEls[3]); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('replaceChild from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + const parent = document.createElement('div', { + customElementRegistry: registry, + }); + container.append(parent); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + const contentEls = Array.from(content.querySelectorAll('*')); + contentEls.forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + container.replaceChild(content, parent); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('replaceChildren from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + const parent = document.createElement('div', { + customElementRegistry: registry, + }); + container.append(parent); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + const contentEls = Array.from(content.querySelectorAll('*')); + contentEls.forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + container.replaceChildren(...Array.from(content.childNodes)); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + + it('replaceWith from a template', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = document.createElement('div', { + customElementRegistry: registry, + }); + const parent = document.createElement('div', { + customElementRegistry: registry, + }); + container.append(parent); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + const contentEls = Array.from(content.querySelectorAll('*')); + contentEls.forEach((el) => { + expect(el.customElementRegistry).to.be.null; + }); + parent.replaceWith(content); + const els = container.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + container.remove(); + }); + }); + }); + + describe('mixed registries', () => { + it('uses root registry when appending', async function () { + const shadowRoot = getUninitializedShadowRoot(); + const {host} = shadowRoot; + document.body.append(host); + const registry2 = new CustomElementRegistry(); + shadowRoot.innerHTML = `
`; + const el = shadowRoot.firstElementChild; + registry2.initialize(el); + registry.initialize(shadowRoot); + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + class CustomElementClass2 extends CustomElementClass {} + registry2.define(tagName, CustomElementClass2); + const template = createTemplate(` + <${tagName}> + <${tagName}> + `); + const c1 = template.content.firstElementChild; + const c2 = template.content.lastElementChild; + el.appendChild(c1); + expect(c1).to.be.instanceOf(CustomElementClass); + shadowRoot.appendChild(c2); + expect(c2).to.be.instanceOf(CustomElementClass); + host.remove(); + }); + + it('uses parent registry when parsing from HTML', async function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299299 + this.skip(); + } + const shadowRoot = getUninitializedShadowRoot(); + const registry2 = new CustomElementRegistry(); + shadowRoot.innerHTML = `
`; + const el = shadowRoot.firstElementChild; + registry2.initialize(el); + registry.initialize(shadowRoot); + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + class CustomElementClass2 extends CustomElementClass {} + registry2.define(tagName, CustomElementClass2); + el.innerHTML = `<${tagName}>`; + el.insertAdjacentHTML('beforeend', `<${tagName}>`); + expect(el.firstElementChild).to.be.instanceOf(CustomElementClass2); + expect(el.lastElementChild).to.be.instanceOf(CustomElementClass2); + el.insertAdjacentHTML('beforebegin', `<${tagName}>`); + el.insertAdjacentHTML('afterend', `<${tagName}>`); + expect(el.previousElementSibling).to.be.instanceOf(CustomElementClass); + expect(el.nextElementSibling).to.be.instanceOf(CustomElementClass); + }); + }); + + it('uses source registry when importing', function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299299 + this.skip(); + } + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + + const registry1 = new CustomElementRegistry(); + const Registry1CustomElementClass = class extends HTMLElement {}; + registry1.define(tagName, Registry1CustomElementClass); + + const registry2 = new CustomElementRegistry(); + const Registry2CustomElementClass = class extends HTMLElement {}; + registry2.define(tagName, Registry2CustomElementClass); + + const registry3 = new CustomElementRegistry(); + const Registry3CustomElementClass = class extends HTMLElement {}; + registry3.define(tagName, Registry3CustomElementClass); + + const shadowRoot = getUninitializedShadowRoot(); + shadowRoot.innerHTML = `
<${tagName}>
`; + const container = shadowRoot.firstElementChild; + const er = container.firstElementChild; + const er1 = document.createElement(tagName, { + customElementRegistry: registry1, + }); + const er2 = document.createElement(tagName, { + customElementRegistry: registry2, + }); + const er3 = document.createElement(tagName, { + customElementRegistry: registry3, + }); + container.append(er1, er2); + er2.append(er3); + + expect(er.customElementRegistry).to.be.equal(null); + expect(er).not.to.be.instanceof(CustomElementClass); + expect(er1.customElementRegistry).to.be.equal(registry1); + expect(er1).to.be.instanceof(Registry1CustomElementClass); + expect(er2.customElementRegistry).to.be.equal(registry2); + expect(er2).to.be.instanceof(Registry2CustomElementClass); + expect(er3.customElementRegistry).to.be.equal(registry3); + expect(er3).to.be.instanceof(Registry3CustomElementClass); + const imported = document.importNode(container, { + customElementRegistry: registry, + }); + + const ier = imported.firstElementChild; + const ier1 = ier.nextElementSibling; + const ier2 = ier1.nextElementSibling; + const ier3 = ier2.firstElementChild; + expect(ier.customElementRegistry).to.be.equal(registry); + expect(ier).to.be.instanceof(CustomElementClass); + expect(ier1.customElementRegistry).to.be.equal(registry1); + expect(ier1).to.be.instanceof(Registry1CustomElementClass); + expect(ier2.customElementRegistry).to.be.equal(registry2); + expect(ier2).to.be.instanceof(Registry2CustomElementClass); + expect(ier3.customElementRegistry).to.be.equal(registry3); + expect(ier3).to.be.instanceof(Registry3CustomElementClass); + }); + + it('uses source registry when cloning', function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + // https://bugs.webkit.org/show_bug.cgi?id=299299 + this.skip(); + } + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + + const registry1 = new CustomElementRegistry(); + const Registry1CustomElementClass = class extends HTMLElement {}; + registry1.define(tagName, Registry1CustomElementClass); + + const registry2 = new CustomElementRegistry(); + const Registry2CustomElementClass = class extends HTMLElement {}; + registry2.define(tagName, Registry2CustomElementClass); + + const registry3 = new CustomElementRegistry(); + const Registry3CustomElementClass = class extends HTMLElement {}; + registry3.define(tagName, Registry3CustomElementClass); + + const shadowRoot = getUninitializedShadowRoot(); + shadowRoot.innerHTML = `
<${tagName}>
`; + const container = shadowRoot.firstElementChild; + const er = container.firstElementChild; + const er1 = document.createElement(tagName, { + customElementRegistry: registry1, + }); + const er2 = document.createElement(tagName, { + customElementRegistry: registry2, + }); + const er3 = document.createElement(tagName, { + customElementRegistry: registry3, + }); + container.append(er1, er2); + er2.append(er3); + + expect(er.customElementRegistry).to.be.equal(null); + expect(er).not.to.be.instanceof(CustomElementClass); + expect(er1.customElementRegistry).to.be.equal(registry1); + expect(er1).to.be.instanceof(Registry1CustomElementClass); + expect(er2.customElementRegistry).to.be.equal(registry2); + expect(er2).to.be.instanceof(Registry2CustomElementClass); + expect(er3.customElementRegistry).to.be.equal(registry3); + expect(er3).to.be.instanceof(Registry3CustomElementClass); + const cloned = container.cloneNode(true); + + const ier = cloned.firstElementChild; + const ier1 = ier.nextElementSibling; + const ier2 = ier1.nextElementSibling; + const ier3 = ier2.firstElementChild; + expect(ier.customElementRegistry).to.be.null; + expect(ier).not.to.be.instanceof(CustomElementClass); + expect(ier1.customElementRegistry).to.be.equal(registry1); + expect(ier1).to.be.instanceof(Registry1CustomElementClass); + expect(ier2.customElementRegistry).to.be.equal(registry2); + expect(ier2).to.be.instanceof(Registry2CustomElementClass); + expect(ier3.customElementRegistry).to.be.equal(registry3); + expect(ier3).to.be.instanceof(Registry3CustomElementClass); + }); }; diff --git a/packages/scoped-custom-element-registry/test/form-associated.test.js b/packages/scoped-custom-element-registry/test/form-associated.test.js index c0a73518..651ec7f0 100644 --- a/packages/scoped-custom-element-registry/test/form-associated.test.js +++ b/packages/scoped-custom-element-registry/test/form-associated.test.js @@ -46,8 +46,8 @@ export const commonRegistryTests = (registry) => { form.append(element); form.append(element2); document.body.append(form); - expect(form.elements[name].includes(element)).to.be.true; - expect(form.elements[name].includes(element2)).to.be.true; + expect(Array.from(form.elements[name]).includes(element)).to.be.true; + expect(Array.from(form.elements[name]).includes(element2)).to.be.true; expect(form.elements[name].value).to.equal(''); }); @@ -120,14 +120,20 @@ export const commonRegistryTests = (registry) => { }); describe('formAssociated scoping limitations', () => { - it('is formAssociated if set in CustomElementRegistryPolyfill.formAssociated', () => { + it('is formAssociated if set in CustomElementRegistryPolyfill.formAssociated', function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + this.skip(); + } const tagName = getTestTagName(); window.CustomElementRegistryPolyfill.formAssociated.add(tagName); class El extends HTMLElement {} customElements.define(tagName, El); expect(customElements.get(tagName).formAssociated).to.be.true; }); - it('is always formAssociated if first defined tag is formAssociated', () => { + it('is always formAssociated if first defined tag is formAssociated', function () { + if (!window.CustomElementRegistryPolyfill.inUse) { + this.skip(); + } const tagName = getTestTagName(); class FormAssociatedEl extends HTMLElement { static formAssociated = true; diff --git a/packages/scoped-custom-element-registry/test/utils.js b/packages/scoped-custom-element-registry/test/utils.js index 809612a4..432d566b 100644 --- a/packages/scoped-custom-element-registry/test/utils.js +++ b/packages/scoped-custom-element-registry/test/utils.js @@ -82,28 +82,22 @@ export const getFormAssociatedErrorTestElement = () => ({ * @return {ShadowRoot} */ export const getShadowRoot = (customElementRegistry) => { - const tagName = getTestTagName(); - const CustomElementClass = class extends HTMLElement { - constructor() { - super(); - - const initOptions = { - mode: 'open', - }; - - if (customElementRegistry) { - initOptions.registry = customElementRegistry; - } - - this.attachShadow(initOptions); - } - }; - - window.customElements.define(tagName, CustomElementClass); - - const {shadowRoot} = new CustomElementClass(); + const el = document.createElement('div'); + return el.attachShadow({mode: 'open', customElementRegistry}); +}; - return shadowRoot; +/** + * Gets a shadowRoot with a null registry associated. + * + * @return {ShadowRoot} + */ +export const getUninitializedShadowRoot = () => { + const el = document.createElement('div'); + // note: using polyfill-specific host attribute + el.setHTMLUnsafe( + `
` + ); + return /** @type {ShadowRoot} */ el.firstElementChild.shadowRoot; }; /** @@ -114,7 +108,9 @@ export const getShadowRoot = (customElementRegistry) => { * @return {HTMLElement} */ export const getHTML = (html, root = document) => { - const div = root.createElement('div'); + const div = document.createElement('div', { + customElementRegistry: root.customElementRegistry, + }); div.innerHTML = html;