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 b68062d2..a1a10cb8 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 @@ -76,6 +76,8 @@ interface CustomHTMLElement { interface CustomElementRegistry { _getDefinition(tagName: string): CustomElementDefinition | undefined; + createElement(tagName: string): Node; + cloneSubtree(node: Node): Node; } interface CustomElementDefinition { @@ -106,13 +108,13 @@ interface CustomElementDefinition { // Note, `registry` matches proposal but `customElements` was previously // proposed. It's supported for back compat. interface ShadowRootWithSettableCustomElements extends ShadowRoot { - registry?: CustomElementRegistry; - customElements?: CustomElementRegistry; + registry?: CustomElementRegistry | null; + customElements: CustomElementRegistry | null; } interface ShadowRootInitWithSettableCustomElements extends ShadowRootInit { - registry?: CustomElementRegistry; - customElements?: CustomElementRegistry; + registry?: CustomElementRegistry | null; + customElements?: CustomElementRegistry | null; } type ParametersOf< @@ -137,12 +139,29 @@ 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(); + +const registryForElement = new WeakMap< + Node, + ShimmedCustomElementsRegistry | null +>(); +const registryToSubtree = ( + node: Node, + registry: ShimmedCustomElementsRegistry | null, + shouldUpgrade?: boolean +) => { + if (registryForElement.get(node) == null) { + registryForElement.set(node, registry); + } + if (shouldUpgrade && registryForElement.get(node) === registry) { + registry?._upgradeElement(node as HTMLElement); + } + const {children} = node as Element; + if (children?.length) { + Array.from(children).forEach((child) => + registryToSubtree(child, registry, shouldUpgrade) + ); + } +}; class AsyncInfo { readonly promise: Promise; @@ -251,8 +270,7 @@ class ShimmedCustomElementsRegistry implements CustomElementRegistry { if (awaiting) { this._awaitingUpgrade.delete(tagName); for (const element of awaiting) { - pendingRegistryForElement.delete(element); - customize(element, definition, true); + this._upgradeElement(element, definition); } } // Flush whenDefined callbacks @@ -268,6 +286,7 @@ class ShimmedCustomElementsRegistry implements CustomElementRegistry { creationContext.push(this); nativeRegistry.upgrade(...args); creationContext.pop(); + args.forEach((n) => registryToSubtree(n, this)); } get(tagName: string) { @@ -312,6 +331,39 @@ class ShimmedCustomElementsRegistry implements CustomElementRegistry { awaiting.delete(element); } } + + // upgrades the given element if defined or queues it for upgrade when defined. + _upgradeElement(element: HTMLElement, definition?: CustomElementDefinition) { + definition ??= this._getDefinition(element.localName); + if (definition !== undefined) { + pendingRegistryForElement.delete(element); + customize(element, definition!, true); + } else { + this._upgradeWhenDefined(element, element.localName, true); + } + } + + ['createElement'](localName: string) { + creationContext.push(this); + const el = document.createElement(localName); + creationContext.pop(); + registryToSubtree(el, this); + return el; + } + + ['cloneSubtree'](node: Node) { + creationContext.push(this); + // Note, cannot use `cloneNode` here becuase the node may not be in this document + const subtree = document.importNode(node, true); + creationContext.pop(); + registryToSubtree(subtree, this); + return subtree; + } + + ['initializeSubtree'](node: Node) { + registryToSubtree(node, this, true); + return node; + } } // User extends this HTMLElement, which returns the CE being upgraded @@ -345,35 +397,20 @@ window.HTMLElement = (function HTMLElement(this: 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; - } - // 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; - } +const registryFromContext = ( + node: Element +): ShimmedCustomElementsRegistry | null => { + const explicitRegistry = registryForElement.get(node); + if (explicitRegistry != null) { + return explicitRegistry; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (scope as any)['registry'] as ShimmedCustomElementsRegistry | null; + const context = creationContext[creationContext.length - 1]; + if (context instanceof CustomElementRegistry) { + return context as ShimmedCustomElementsRegistry; + } + const registry = (context as Element) + .customElements as ShimmedCustomElementsRegistry; + return registry ?? null; }; // Helper to create stand-in element for each tagName registered that delegates @@ -400,13 +437,12 @@ const createStandInElement = (tagName: string): CustomElementConstructor => { // 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); + const registry = registryFromContext(instance); + registryToSubtree(instance, registry); + const definition = registry?._getDefinition(tagName); if (definition) { customize(instance, definition); - } else { + } else if (registry) { pendingRegistryForElement.set(instance, registry); } return instance; @@ -423,10 +459,26 @@ const createStandInElement = (tagName: string): CustomElementConstructor => { 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) - pendingRegistryForElement - .get(this)! - ._upgradeWhenDefined(this, tagName, true); + const pendingRegistry = pendingRegistryForElement.get(this); + if (pendingRegistry !== undefined) { + pendingRegistry._upgradeWhenDefined(this, tagName, true); + } else { + const registry = + this.customElements ?? + (this.parentNode as Element | ShadowRoot)?.customElements; + if (registry) { + registryToSubtree( + this, + registry as ShimmedCustomElementsRegistry, + true + ); + } + } } } @@ -442,8 +494,8 @@ const createStandInElement = (tagName: string): CustomElementConstructor => { } else { // Un-register for upgrade when defined (so we don't leak) pendingRegistryForElement - .get(this)! - ._upgradeWhenDefined(this, tagName, false); + .get(this) + ?._upgradeWhenDefined(this, tagName, false); } } @@ -677,49 +729,106 @@ Element.prototype.attachShadow = function ( ...args, ] as unknown) as [init: ShadowRootInit]; const shadowRoot = nativeAttachShadow.apply(this, nativeArgs); - const registry = init['registry'] ?? init.customElements; + // Note, this allows a `null` customElements purely for testing. + const registry = + init['customElements'] === undefined + ? init['registry'] + : init['customElements']; if (registry !== undefined) { - (shadowRoot as ShadowRootWithSettableCustomElements).customElements = (shadowRoot as ShadowRootWithSettableCustomElements)[ - 'registry' - ] = registry; + registryForElement.set( + shadowRoot, + registry as ShimmedCustomElementsRegistry + ); + (shadowRoot as ShadowRootWithSettableCustomElements)['registry'] = registry; } return shadowRoot; }; +const customElementsDescriptor = { + 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, +}; + +Object.defineProperty( + Element.prototype, + 'customElements', + customElementsDescriptor +); +Object.defineProperty( + Document.prototype, + 'customElements', + customElementsDescriptor +); +Object.defineProperty( + ShadowRoot.prototype, + 'customElements', + customElementsDescriptor +); + // Install scoped creation API on Element & ShadowRoot const creationContext: Array< Document | CustomElementRegistry | Element | ShadowRoot > = [document]; -const installScopedCreationMethod = ( +const installScopedMethod = ( ctor: Function, method: string, - from?: Document + coda = function (this: Element, result: Node) { + registryToSubtree( + result ?? this, + this.customElements as ShimmedCustomElementsRegistry + ); + } ) => { - const native = (from ? Object.getPrototypeOf(from) : ctor.prototype)[method]; + const native = ctor.prototype[method]; + if (native === undefined) { + return; + } ctor.prototype[method] = function ( this: Element | ShadowRoot, ...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); - } + const ret = native.apply(this, args); creationContext.pop(); + coda?.call(this as Element, ret); return ret; }; }; -installScopedCreationMethod(ShadowRoot, 'createElement', document); -installScopedCreationMethod(ShadowRoot, 'createElementNS', document); -installScopedCreationMethod(ShadowRoot, 'importNode', document); -installScopedCreationMethod(Element, 'insertAdjacentHTML'); + +const applyScopeFromParent = function (this: Element) { + const scope = (this.parentNode ?? this) as Element; + registryToSubtree( + scope, + scope.customElements as ShimmedCustomElementsRegistry + ); +}; + +installScopedMethod(Element, 'insertAdjacentHTML', applyScopeFromParent); +installScopedMethod(Element, 'setHTMLUnsafe'); +installScopedMethod(ShadowRoot, 'setHTMLUnsafe'); + +// For setting null elements to this scope. +installScopedMethod(Node, 'appendChild'); +installScopedMethod(Node, 'insertBefore'); +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 installScopedCreationSetter = (ctor: Function, name: string) => { +const installScopedSetter = (ctor: Function, name: string) => { const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, name)!; Object.defineProperty(ctor.prototype, name, { ...descriptor, @@ -727,11 +836,12 @@ const installScopedCreationSetter = (ctor: Function, name: string) => { creationContext.push(this); descriptor.set!.call(this, value); creationContext.pop(); + registryToSubtree(this, this.customElements); }, }); }; -installScopedCreationSetter(Element, 'innerHTML'); -installScopedCreationSetter(ShadowRoot, 'innerHTML'); +installScopedSetter(Element, 'innerHTML'); +installScopedSetter(ShadowRoot, 'innerHTML'); // Install global registry Object.defineProperty(window, 'customElements', { @@ -759,10 +869,10 @@ if ( return internals; }; + const proto = window['ElementInternals'].prototype; + methods.forEach((method) => { - const proto = window['ElementInternals'].prototype; 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); diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index 94841877..77c1032a 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -1,7 +1,7 @@ export {}; declare global { - interface ShadowRoot { + interface CustomElementRegistry { // This overload is for roots that use the global registry createElement( tagName: K, @@ -16,14 +16,28 @@ declare global { tagName: string, options?: ElementCreationOptions ): HTMLElement; + cloneSubtree(node: Node): Node; + initializeSubtree: (node: Node) => Node; } interface ShadowRootInit { - customElements?: CustomElementRegistry; + customElements?: CustomElementRegistry | null; } interface ShadowRoot { - readonly customElements?: CustomElementRegistry; + readonly customElements: CustomElementRegistry | null; + } + + interface Document { + readonly customElements: CustomElementRegistry | null; + } + + interface Element { + readonly customElements: CustomElementRegistry | null; + } + + interface InitializeShadowRootInit { + customElements?: CustomElementRegistry; } /* 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..2d7c2310 100644 --- a/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js +++ b/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js @@ -23,19 +23,19 @@ describe('ShadowRoot', () => { }); describe('with custom registry', () => { - describe('importNode', () => { - it('should import a basic node', () => { + describe('cloneSubtree', () => { + it('should clone a basic node', () => { const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); const html = 'sample'; const $div = getHTML(html); - const $clone = shadowRoot.importNode($div, true); + const $clone = shadowRoot.customElements.cloneSubtree($div); expect($clone.outerHTML).to.be.equal(html); }); - it('should import a node tree with an upgraded custom element in global registry', () => { + it('should clone a node tree with an upgraded custom element in global registry', () => { const {tagName, CustomElementClass} = getTestElement(); customElements.define(tagName, CustomElementClass); @@ -46,14 +46,14 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = shadowRoot.customElements.cloneSubtree($el); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); expect($clone).not.to.be.instanceof(CustomElementClass); expect($clone).to.be.instanceof(AnotherCustomElementClass); }); - it('should import a node tree with an upgraded custom element from another shadowRoot', () => { + it('should clone a node tree with an upgraded custom element from another shadowRoot', () => { const {tagName, CustomElementClass} = getTestElement(); const firstRegistry = new CustomElementRegistry(); firstRegistry.define(tagName, CustomElementClass); @@ -65,25 +65,25 @@ describe('ShadowRoot', () => { secondRegistry.define(tagName, AnotherCustomElementClass); const secondShadowRoot = getShadowRoot(secondRegistry); - const $clone = secondShadowRoot.importNode($el, true); + const $clone = secondShadowRoot.customElements.cloneSubtree($el); expect($clone.outerHTML).to.be.equal($el.outerHTML); expect($clone).not.to.be.instanceof(CustomElementClass); expect($clone).to.be.instanceof(AnotherCustomElementClass); }); - it('should import a node tree with a non upgraded custom element', () => { + it('should clone a node tree with a non upgraded custom element', () => { const tagName = getTestTagName(); const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = shadowRoot.customElements.cloneSubtree($el); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); }); - it('should import a node tree with a non upgraded custom element defined in the custom registry', () => { + it('should clone a node tree with a non upgraded custom element defined in the custom registry', () => { const {tagName, CustomElementClass} = getTestElement(); const registry = new CustomElementRegistry(); registry.define(tagName, CustomElementClass); @@ -91,18 +91,20 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = shadowRoot.customElements.cloneSubtree($el); expect($clone).to.be.instanceof(CustomElementClass); }); - it('should import a template with an undefined custom element', () => { + it('should clone a template with an undefined custom element', () => { const {tagName} = getTestTagName(); const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); const $template = createTemplate(`<${tagName}>`); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = shadowRoot.customElements.cloneSubtree( + $template.content + ); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -110,14 +112,16 @@ describe('ShadowRoot', () => { ); }); - it('should import a template with a defined custom element', () => { + it('should clone a template with a defined custom element', () => { const {tagName, CustomElementClass} = getTestElement(); const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); const $template = createTemplate(`<${tagName}>`); registry.define(tagName, CustomElementClass); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = shadowRoot.customElements.cloneSubtree( + $template.content + ); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -132,46 +136,7 @@ describe('ShadowRoot', () => { 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 $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - 'div' - ); + const $el = shadowRoot.customElements.createElement('div'); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(HTMLDivElement); @@ -183,10 +148,7 @@ describe('ShadowRoot', () => { const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - tagName - ); + const $el = shadowRoot.customElements.createElement(tagName); expect($el).to.not.be.undefined; expect($el).to.not.be.instanceof(CustomElementClass); @@ -198,10 +160,7 @@ describe('ShadowRoot', () => { registry.define(tagName, CustomElementClass); const shadowRoot = getShadowRoot(registry); - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - tagName - ); + const $el = shadowRoot.customElements.createElement(tagName); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(CustomElementClass); @@ -238,31 +197,31 @@ describe('ShadowRoot', () => { }); describe('without custom registry', () => { - describe('importNode', () => { - it('should import a basic node', () => { + describe('cloneSubtree', () => { + it('should clone a basic node', () => { const shadowRoot = getShadowRoot(); const html = 'sample'; const $div = getHTML(html); - const $clone = shadowRoot.importNode($div, true); + const $clone = shadowRoot.customElements.cloneSubtree($div); expect($clone.outerHTML).to.be.equal(html); }); - it('should import a node tree with an upgraded custom element', () => { + it('should clone a node tree with an upgraded custom element', () => { const {tagName, CustomElementClass} = getTestElement(); customElements.define(tagName, CustomElementClass); const shadowRoot = getShadowRoot(); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = shadowRoot.customElements.cloneSubtree($el); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); expect($clone).to.be.instanceof(CustomElementClass); }); - it('should import a node tree with an upgraded custom element from another shadowRoot', () => { + it('should clone a node tree with an upgraded custom element from another shadowRoot', () => { const {tagName, CustomElementClass} = getTestElement(); const firstRegistry = new CustomElementRegistry(); firstRegistry.define(tagName, CustomElementClass); @@ -271,27 +230,29 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`, firstShadowRoot); const secondShadowRoot = getShadowRoot(); - const $clone = secondShadowRoot.importNode($el, true); + const $clone = secondShadowRoot.customElements.cloneSubtree($el); expect($clone.outerHTML).to.be.equal($el.outerHTML); }); - it('should import a node tree with a non upgraded custom element', () => { + it('should clone a node tree with a non upgraded custom element', () => { const tagName = getTestTagName(); const shadowRoot = getShadowRoot(); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.importNode($el, true); + const $clone = shadowRoot.customElements.cloneSubtree($el); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); }); - it('should import a template with an undefined custom element', () => { + it('should clone a template with an undefined custom element', () => { const {tagName} = getTestTagName(); const shadowRoot = getShadowRoot(); const $template = createTemplate(`<${tagName}>`); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = shadowRoot.customElements.cloneSubtree( + $template.content + ); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -299,13 +260,15 @@ describe('ShadowRoot', () => { ); }); - it('should import a template with a defined custom element', () => { + it('should clone a template with a defined custom element', () => { const {tagName, CustomElementClass} = getTestElement(); const shadowRoot = getShadowRoot(); const $template = createTemplate(`<${tagName}>`); customElements.define(tagName, CustomElementClass); - const $clone = shadowRoot.importNode($template.content, true); + const $clone = shadowRoot.customElements.cloneSubtree( + $template.content + ); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -319,32 +282,7 @@ describe('ShadowRoot', () => { 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' - ); + const $el = shadowRoot.customElements.createElement('div'); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(HTMLDivElement); @@ -355,10 +293,7 @@ describe('ShadowRoot', () => { customElements.define(tagName, CustomElementClass); const shadowRoot = getShadowRoot(); - const $el = shadowRoot.createElementNS( - 'http://www.w3.org/1999/xhtml', - tagName - ); + const $el = shadowRoot.customElements.createElement(tagName); expect($el).to.not.be.undefined; expect($el).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..e92023e2 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, + getUnitializedShadowRoot, +} from './utils.js'; export const commonRegistryTests = (registry) => { describe('define', () => { @@ -76,4 +80,361 @@ export const commonRegistryTests = (registry) => { expect(defined).to.be.true; }); }); + + describe('createElement', () => { + it('should create built-in elements', async () => { + const el = registry.createElement('div'); + expect(el).to.be.ok; + }); + + it('should create custom elements', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const el = registry.createElement(tagName); + expect(el).to.be.instanceOf(CustomElementClass); + }); + }); + + describe('cloneSubtree', () => { + it('should upgrade custom elements in cloned subtree', async () => { + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const clone = registry.cloneSubtree(template.content); + const els = clone.querySelectorAll(tagName); + expect(els.length).to.be.equal(4); + els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); + }); + }); + + describe('initializeSubtree', () => { + it('can create uninitialized roots', async () => { + const shadowRoot = getUnitializedShadowRoot(); + expect(shadowRoot.customElements).to.be.null; + shadowRoot.innerHTML = `
`; + const el = shadowRoot.firstElementChild; + expect(el.customElements).to.be.null; + }); + + it('initializeSubtree sets customElements', async () => { + const shadowRoot = getUnitializedShadowRoot(); + shadowRoot.innerHTML = `
`; + registry.initializeSubtree(shadowRoot); + expect(shadowRoot.customElements).to.be.equal(registry); + shadowRoot.innerHTML = `
`; + const el = shadowRoot.firstElementChild; + expect(el.customElements).to.be.equal(registry); + }); + + it('should not upgrade custom elements in uninitialized subtree', async () => { + const shadowRoot = getUnitializedShadowRoot(); + 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 upgrade custom elements in initialized subtree', async () => { + const shadowRoot = getUnitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + shadowRoot.innerHTML = `<${tagName}>
`; + registry.initializeSubtree(shadowRoot); + const el = shadowRoot.firstElementChild; + const container = shadowRoot.lastElementChild; + expect(el.localName).to.be.equal(tagName); + expect(el).to.be.instanceOf(CustomElementClass); + 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', () => { + const shadowRoot = getUnitializedShadowRoot(); + 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', () => { + const shadowRoot = getUnitializedShadowRoot(); + 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 (!(`setHTMLUnsafe` in Element.prototype)) { + this.skip(); + } + const shadowRoot = getUnitializedShadowRoot(); + 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 unitialized shadowRoot', async () => { + const shadowRoot = getUnitializedShadowRoot(); + const {tagName, CustomElementClass} = getTestElement(); + registry.define(tagName, CustomElementClass); + const container = registry.createElement('div'); + 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 = registry.createElement('div'); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const clone = template.content.cloneNode(true); + clone.querySelectorAll('*').forEach((el) => { + expect(el.customElements).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 = registry.createElement('div'); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElements).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 = registry.createElement('div'); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElements).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 = registry.createElement('div'); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElements).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 = registry.createElement('div'); + document.body.append(container); + const template = createTemplate(` + <${tagName}><${tagName}> + <${tagName}><${tagName}> + `); + const {content} = template; + content.querySelectorAll('*').forEach((el) => { + expect(el.customElements).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 = registry.createElement('div'); + const parent = registry.createElement('div'); + 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.customElements).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 = registry.createElement('div'); + const parent = registry.createElement('div'); + 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.customElements).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 = registry.createElement('div'); + const parent = registry.createElement('div'); + 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.customElements).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 = registry.createElement('div'); + const parent = registry.createElement('div'); + 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.customElements).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(); + }); + }); + }); }; diff --git a/packages/scoped-custom-element-registry/test/utils.js b/packages/scoped-custom-element-registry/test/utils.js index 809612a4..9ede4881 100644 --- a/packages/scoped-custom-element-registry/test/utils.js +++ b/packages/scoped-custom-element-registry/test/utils.js @@ -82,28 +82,18 @@ 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', customElements: customElementRegistry}); +}; - return shadowRoot; +/** + * Gets a shadowRoot with a null registry associated. + * + * @return {ShadowRoot} + */ +export const getUnitializedShadowRoot = () => { + const el = document.createElement('div'); + return el.attachShadow({mode: 'open', customElements: null}); }; /** @@ -114,7 +104,7 @@ export const getShadowRoot = (customElementRegistry) => { * @return {HTMLElement} */ export const getHTML = (html, root = document) => { - const div = root.createElement('div'); + const div = root.customElements.createElement('div'); div.innerHTML = html;