From 5658304113559314ab338cc21abb80425293ca53 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 19 Dec 2024 10:01:37 -0800 Subject: [PATCH 01/15] Update to sport revamped proposal in https://github.com/whatwg/html/issues/10854 --- .../src/scoped-custom-element-registry.ts | 204 +++++++++++++----- .../src/types.d.ts | 20 +- .../test/ShadowRoot.test.html.js | 147 ++++--------- .../test/common-registry-tests.js | 103 ++++++++- .../test/utils.js | 34 ++- 5 files changed, 319 insertions(+), 189 deletions(-) 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 b68062d27..108f3c662 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,23 @@ 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 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 context = creationContext[creationContext.length - 1]; + if (context instanceof CustomElementRegistry) { + return context as ShimmedCustomElementsRegistry; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (scope as any)['registry'] as ShimmedCustomElementsRegistry | null; + if ( + context?.nodeType === Node.ELEMENT_NODE || + context?.nodeType === Node.DOCUMENT_FRAGMENT_NODE + ) { + return context.customElements as ShimmedCustomElementsRegistry; + } + return node.nodeType === Node.ELEMENT_NODE + ? ((node as Element).customElements as ShimmedCustomElementsRegistry) ?? + null + : null; }; // Helper to create stand-in element for each tagName registered that delegates @@ -400,13 +440,11 @@ 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 = registryForNode(instance); + const definition = registry?._getDefinition(tagName); if (definition) { customize(instance, definition); - } else { + } else if (registry) { pendingRegistryForElement.set(instance, registry); } return instance; @@ -423,10 +461,25 @@ 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.parentElement?.customElements; + if (registry) { + registryToSubtree( + this, + registry as ShimmedCustomElementsRegistry, + true + ); + } + } } } @@ -677,15 +730,51 @@ 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 @@ -707,15 +796,15 @@ const installScopedCreationMethod = ( // 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); + registryToSubtree( + ret, + this.customElements as ShimmedCustomElementsRegistry + ); } creationContext.pop(); return ret; }; }; -installScopedCreationMethod(ShadowRoot, 'createElement', document); -installScopedCreationMethod(ShadowRoot, 'createElementNS', document); -installScopedCreationMethod(ShadowRoot, 'importNode', document); installScopedCreationMethod(Element, 'insertAdjacentHTML'); // Install scoped innerHTML on Element & ShadowRoot @@ -727,6 +816,7 @@ const installScopedCreationSetter = (ctor: Function, name: string) => { creationContext.push(this); descriptor.set!.call(this, value); creationContext.pop(); + registryToSubtree(this, this.customElements); }, }); }; @@ -759,10 +849,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 948418772..77c1032ac 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 69ad914f0..2d7c23105 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 30f0756bb..d5edc7ae7 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,101 @@ 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); + }); + + it('should upgrade custom elements connected to subtree with registry', 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(); + }); + }); }; diff --git a/packages/scoped-custom-element-registry/test/utils.js b/packages/scoped-custom-element-registry/test/utils.js index 809612a43..9ede48813 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; From 38138890d477c09ed0cbdde89b0a322b7c7f8c9d Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Fri, 20 Dec 2024 10:11:01 -0800 Subject: [PATCH 02/15] handle connection and add more tests --- .../src/scoped-custom-element-registry.ts | 90 +++--- .../test/common-registry-tests.js | 290 +++++++++++++++++- 2 files changed, 330 insertions(+), 50 deletions(-) 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 108f3c662..a1a10cb81 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 @@ -397,23 +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 => { +const registryFromContext = ( + node: Element +): ShimmedCustomElementsRegistry | null => { + const explicitRegistry = registryForElement.get(node); + if (explicitRegistry != null) { + return explicitRegistry; + } const context = creationContext[creationContext.length - 1]; if (context instanceof CustomElementRegistry) { return context as ShimmedCustomElementsRegistry; } - if ( - context?.nodeType === Node.ELEMENT_NODE || - context?.nodeType === Node.DOCUMENT_FRAGMENT_NODE - ) { - return context.customElements as ShimmedCustomElementsRegistry; - } - return node.nodeType === Node.ELEMENT_NODE - ? ((node as Element).customElements as ShimmedCustomElementsRegistry) ?? - null - : null; + const registry = (context as Element) + .customElements as ShimmedCustomElementsRegistry; + return registry ?? null; }; // Helper to create stand-in element for each tagName registered that delegates @@ -440,7 +437,8 @@ 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); + const registry = registryFromContext(instance); + registryToSubtree(instance, registry); const definition = registry?._getDefinition(tagName); if (definition) { customize(instance, definition); @@ -471,7 +469,8 @@ const createStandInElement = (tagName: string): CustomElementConstructor => { pendingRegistry._upgradeWhenDefined(this, tagName, true); } else { const registry = - this.customElements ?? this.parentElement?.customElements; + this.customElements ?? + (this.parentNode as Element | ShadowRoot)?.customElements; if (registry) { registryToSubtree( this, @@ -495,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); } } @@ -779,36 +778,57 @@ Object.defineProperty( 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) { - registryToSubtree( - ret, - this.customElements as ShimmedCustomElementsRegistry - ); - } + const ret = native.apply(this, args); creationContext.pop(); + coda?.call(this as Element, ret); return ret; }; }; -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, @@ -820,8 +840,8 @@ const installScopedCreationSetter = (ctor: Function, name: string) => { }, }); }; -installScopedCreationSetter(Element, 'innerHTML'); -installScopedCreationSetter(ShadowRoot, 'innerHTML'); +installScopedSetter(Element, 'innerHTML'); +installScopedSetter(ShadowRoot, 'innerHTML'); // Install global registry Object.defineProperty(window, 'customElements', { 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 d5edc7ae7..e92023e24 100644 --- a/packages/scoped-custom-element-registry/test/common-registry-tests.js +++ b/packages/scoped-custom-element-registry/test/common-registry-tests.js @@ -159,22 +159,282 @@ export const commonRegistryTests = (registry) => { expect(el2.localName).to.be.equal(tagName); expect(el2).to.be.instanceOf(CustomElementClass); }); + }); - it('should upgrade custom elements connected to subtree with registry', 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(); + 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(); + }); }); }); }; From 974ce1a1e22c0563cfd523b01b05677e04e31dc4 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 27 Feb 2025 05:20:23 -0800 Subject: [PATCH 03/15] update to latest proposed spec --- .../src/scoped-custom-element-registry.ts | 107 +++++++++++++---- .../src/types.d.ts | 41 ++++--- .../test/ShadowRoot.test.html.js | 110 +++++++++++------- .../test/common-registry-tests.js | 76 ++++++++---- .../test/utils.js | 4 +- 5 files changed, 233 insertions(+), 105 deletions(-) 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 a1a10cb81..479ea38c1 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,8 +76,7 @@ interface CustomHTMLElement { interface CustomElementRegistry { _getDefinition(tagName: string): CustomElementDefinition | undefined; - createElement(tagName: string): Node; - cloneSubtree(node: Node): Node; + initialize: (node: Node) => Node; } interface CustomElementDefinition { @@ -343,24 +342,7 @@ class ShimmedCustomElementsRegistry implements CustomElementRegistry { } } - ['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) { + ['initialize'](node: Node) { registryToSubtree(node, this, true); return node; } @@ -758,16 +740,91 @@ const customElementsDescriptor = { configurable: true, }; +const {createElement, createElementNS, importNode} = Document.prototype; + Object.defineProperty( Element.prototype, 'customElements', customElementsDescriptor ); -Object.defineProperty( - Document.prototype, - 'customElements', - customElementsDescriptor -); +Object.defineProperties(Document.prototype, { + 'customElements': customElementsDescriptor, + 'createElement': { + value( + this: Document, + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K] { + const {customElements} = options ?? {}; + if (customElements === undefined) { + return createElement.call(this, tagName) as HTMLElementTagNameMap[K]; + } else { + creationContext.push(customElements); + const el = createElement.call( + this, + tagName + ) as HTMLElementTagNameMap[K]; + creationContext.pop(); + registryToSubtree(el, customElements as ShimmedCustomElementsRegistry); + return el; + } + }, + enumerable: true, + configurable: true, + }, + 'createElementNS': { + value( + this: Document, + namespace: string | null, + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K] { + const {customElements} = options ?? {}; + if (customElements === undefined) { + return createElementNS.call( + this, + namespace, + tagName + ) as HTMLElementTagNameMap[K]; + } else { + creationContext.push(customElements); + const el = createElementNS.call( + this, + namespace, + tagName + ) as HTMLElementTagNameMap[K]; + creationContext.pop(); + registryToSubtree(el, customElements as ShimmedCustomElementsRegistry); + return el; + } + }, + enumerable: true, + configurable: true, + }, + 'importNode': { + value( + this: Document, + node: T, + options?: boolean | ImportNodeOptions + ): T { + const deep = typeof options === 'boolean' ? options : !options?.selfOnly; + const {customElements} = (options ?? {}) as ImportNodeOptions; + if (customElements === undefined) { + return importNode.call(this, node, deep) as T; + } + creationContext.push(customElements); + const imported = importNode.call(this, node, deep) as T; + creationContext.pop(); + registryToSubtree( + imported, + customElements as ShimmedCustomElementsRegistry + ); + return imported; + }, + enumerable: true, + configurable: true, + }, +}); Object.defineProperty( ShadowRoot.prototype, 'customElements', diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index 77c1032ac..94d9607c3 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -3,21 +3,7 @@ export {}; declare global { interface CustomElementRegistry { // This overload is for roots that use the global registry - createElement( - tagName: K, - options?: ElementCreationOptions - ): HTMLElementTagNameMap[K]; - // This overload is for roots that use a scoped registry - createElement( - tagName: K, - options?: ElementCreationOptions - ): BuiltInHTMLElementTagNameMap[K]; - createElement( - tagName: string, - options?: ElementCreationOptions - ): HTMLElement; - cloneSubtree(node: Node): Node; - initializeSubtree: (node: Node) => Node; + initialize: (node: Node) => Node; } interface ShadowRootInit { @@ -30,6 +16,21 @@ declare global { interface Document { readonly customElements: CustomElementRegistry | null; + createElement( + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K]; + + createElementNS( + namespace: string | null, + tagName: K, + options?: ElementCreationOptions + ): HTMLElementTagNameMap[K]; + + importNode( + node: T, + options?: boolean | ImportNodeOptions + ): T; } interface Element { @@ -40,6 +41,16 @@ declare global { customElements?: CustomElementRegistry; } + interface ImportNodeOptions { + selfOnly?: boolean; + customElements?: CustomElementRegistry; + } + + interface ElementCreationOptions { + is?: string; + customElements?: CustomElementRegistry; + } + /* * Many custom element definitions will add themselves to the global * HTMLElementTagNameMap interface. Using that interface in 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 2d7c23105..3dcf0863f 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,21 @@ describe('ShadowRoot', () => { }); describe('with custom registry', () => { - describe('cloneSubtree', () => { - it('should clone a basic node', () => { + describe('importNode', () => { + it('should import a basic node', () => { const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); const html = 'sample'; const $div = getHTML(html); - const $clone = shadowRoot.customElements.cloneSubtree($div); + const $clone = document.importNode($div, { + customElements: shadowRoot.customELements, + }); expect($clone.outerHTML).to.be.equal(html); }); - it('should clone a node tree with an upgraded custom element in global registry', () => { + it('should import a node tree with an upgraded custom element in global registry', () => { const {tagName, CustomElementClass} = getTestElement(); customElements.define(tagName, CustomElementClass); @@ -46,14 +48,16 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.customElements.cloneSubtree($el); + const $clone = document.importNode($el, { + customElements: shadowRoot.customElements, + }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); expect($clone).not.to.be.instanceof(CustomElementClass); expect($clone).to.be.instanceof(AnotherCustomElementClass); }); - it('should clone a node tree with an upgraded custom element from another shadowRoot', () => { + it('should import 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 +69,29 @@ describe('ShadowRoot', () => { secondRegistry.define(tagName, AnotherCustomElementClass); const secondShadowRoot = getShadowRoot(secondRegistry); - const $clone = secondShadowRoot.customElements.cloneSubtree($el); + const $clone = document.importNode($el, { + customElements: secondShadowRoot.customElements, + }); expect($clone.outerHTML).to.be.equal($el.outerHTML); expect($clone).not.to.be.instanceof(CustomElementClass); expect($clone).to.be.instanceof(AnotherCustomElementClass); }); - it('should clone a node tree with a non upgraded custom element', () => { + it('should import 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.customElements.cloneSubtree($el); + const $clone = document.importNode($el, { + customElements: shadowRoot.customElements, + }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); }); - it('should clone a node tree with a non upgraded custom element defined in the custom registry', () => { + it('should import 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,20 +99,22 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.customElements.cloneSubtree($el); + const $clone = document.importNode($el, { + customElements: shadowRoot.customElements, + }); expect($clone).to.be.instanceof(CustomElementClass); }); - it('should clone a template with an undefined custom element', () => { + it('should import 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.customElements.cloneSubtree( - $template.content - ); + const $clone = document.importNode($template.content, { + customElements: shadowRoot.customElements, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -112,16 +122,16 @@ describe('ShadowRoot', () => { ); }); - it('should clone a template with a defined custom element', () => { + it('should import 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.customElements.cloneSubtree( - $template.content - ); + const $clone = document.importNode($template.content, { + customElements: shadowRoot.customElements, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -136,7 +146,9 @@ describe('ShadowRoot', () => { const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); - const $el = shadowRoot.customElements.createElement('div'); + const $el = document.createElement('div', { + customElements: shadowRoot.customElements, + }); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(HTMLDivElement); @@ -148,7 +160,9 @@ describe('ShadowRoot', () => { const registry = new CustomElementRegistry(); const shadowRoot = getShadowRoot(registry); - const $el = shadowRoot.customElements.createElement(tagName); + const $el = document.createElement(tagName, { + customElements: shadowRoot.customElements, + }); expect($el).to.not.be.undefined; expect($el).to.not.be.instanceof(CustomElementClass); @@ -160,7 +174,9 @@ describe('ShadowRoot', () => { registry.define(tagName, CustomElementClass); const shadowRoot = getShadowRoot(registry); - const $el = shadowRoot.customElements.createElement(tagName); + const $el = document.createElement(tagName, { + customElements: shadowRoot.customElements, + }); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(CustomElementClass); @@ -197,31 +213,35 @@ describe('ShadowRoot', () => { }); describe('without custom registry', () => { - describe('cloneSubtree', () => { - it('should clone a basic node', () => { + describe('importNode', () => { + it('should import a basic node', () => { const shadowRoot = getShadowRoot(); const html = 'sample'; const $div = getHTML(html); - const $clone = shadowRoot.customElements.cloneSubtree($div); + const $clone = document.importNode($div, { + customElements: shadowRoot.customElements, + }); expect($clone.outerHTML).to.be.equal(html); }); - it('should clone a node tree with an upgraded custom element', () => { + it('should import 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.customElements.cloneSubtree($el); + const $clone = document.importNode($el, { + customElements: shadowRoot.customElements, + }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); expect($clone).to.be.instanceof(CustomElementClass); }); - it('should clone a node tree with an upgraded custom element from another shadowRoot', () => { + it('should import a node tree with an upgraded custom element from another shadowRoot', () => { const {tagName, CustomElementClass} = getTestElement(); const firstRegistry = new CustomElementRegistry(); firstRegistry.define(tagName, CustomElementClass); @@ -230,29 +250,33 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`, firstShadowRoot); const secondShadowRoot = getShadowRoot(); - const $clone = secondShadowRoot.customElements.cloneSubtree($el); + const $clone = document.importNode($el, { + customElements: secondShadowRoot.customElements, + }); expect($clone.outerHTML).to.be.equal($el.outerHTML); }); - it('should clone a node tree with a non upgraded custom element', () => { + it('should import a node tree with a non upgraded custom element', () => { const tagName = getTestTagName(); const shadowRoot = getShadowRoot(); const $el = getHTML(`<${tagName}>`); - const $clone = shadowRoot.customElements.cloneSubtree($el); + const $clone = document.importNode($el, { + customElements: shadowRoot.customElements, + }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); }); - it('should clone a template with an undefined custom element', () => { + it('should import a template with an undefined custom element', () => { const {tagName} = getTestTagName(); const shadowRoot = getShadowRoot(); const $template = createTemplate(`<${tagName}>`); - const $clone = shadowRoot.customElements.cloneSubtree( - $template.content - ); + const $clone = document.importNode($template.content, { + customElements: shadowRoot.customElements, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -260,15 +284,15 @@ describe('ShadowRoot', () => { ); }); - it('should clone a template with a defined custom element', () => { + it('should import 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.customElements.cloneSubtree( - $template.content - ); + const $clone = document.importNode($template.content, { + customElements: shadowRoot.customElements, + }); expect($clone).to.be.instanceof(DocumentFragment); expect($clone.firstElementChild.outerHTML).to.be.equal( @@ -282,7 +306,9 @@ describe('ShadowRoot', () => { it('should create a regular element', () => { const shadowRoot = getShadowRoot(); - const $el = shadowRoot.customElements.createElement('div'); + const $el = document.createElement('div', { + customElements: shadowRoot.customElements, + }); expect($el).to.not.be.undefined; expect($el).to.be.instanceof(HTMLDivElement); @@ -293,7 +319,9 @@ describe('ShadowRoot', () => { customElements.define(tagName, CustomElementClass); const shadowRoot = getShadowRoot(); - const $el = shadowRoot.customElements.createElement(tagName); + const $el = document.createElement(tagName, { + customElements: shadowRoot.customElements, + }); 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 e92023e24..d0489c0b1 100644 --- a/packages/scoped-custom-element-registry/test/common-registry-tests.js +++ b/packages/scoped-custom-element-registry/test/common-registry-tests.js @@ -83,34 +83,36 @@ export const commonRegistryTests = (registry) => { describe('createElement', () => { it('should create built-in elements', async () => { - const el = registry.createElement('div'); + 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 = registry.createElement(tagName); + const el = document.createElement(tagName, {customElements: registry}); expect(el).to.be.instanceOf(CustomElementClass); }); }); - describe('cloneSubtree', () => { - it('should upgrade custom elements in cloned subtree', async () => { + 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 = registry.cloneSubtree(template.content); + const clone = document.importNode(template.content, { + customElements: registry, + }); const els = clone.querySelectorAll(tagName); expect(els.length).to.be.equal(4); els.forEach((el) => expect(el).to.be.instanceOf(CustomElementClass)); }); }); - describe('initializeSubtree', () => { + describe('initialize', () => { it('can create uninitialized roots', async () => { const shadowRoot = getUnitializedShadowRoot(); expect(shadowRoot.customElements).to.be.null; @@ -119,10 +121,10 @@ export const commonRegistryTests = (registry) => { expect(el.customElements).to.be.null; }); - it('initializeSubtree sets customElements', async () => { + it('initialize sets customElements', async () => { const shadowRoot = getUnitializedShadowRoot(); shadowRoot.innerHTML = `
`; - registry.initializeSubtree(shadowRoot); + registry.initialize(shadowRoot); expect(shadowRoot.customElements).to.be.equal(registry); shadowRoot.innerHTML = `
`; const el = shadowRoot.firstElementChild; @@ -149,7 +151,7 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); shadowRoot.innerHTML = `<${tagName}>
`; - registry.initializeSubtree(shadowRoot); + registry.initialize(shadowRoot); const el = shadowRoot.firstElementChild; const container = shadowRoot.lastElementChild; expect(el.localName).to.be.equal(tagName); @@ -228,7 +230,9 @@ export const commonRegistryTests = (registry) => { const shadowRoot = getUnitializedShadowRoot(); const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); document.body.append(container); shadowRoot.innerHTML = ` <${tagName}><${tagName}> @@ -244,7 +248,9 @@ export const commonRegistryTests = (registry) => { it('cloned and appended from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); document.body.append(container); const template = createTemplate(` <${tagName}><${tagName}> @@ -264,7 +270,9 @@ export const commonRegistryTests = (registry) => { it('append from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); document.body.append(container); const template = createTemplate(` <${tagName}><${tagName}> @@ -284,7 +292,9 @@ export const commonRegistryTests = (registry) => { it('appendChild from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); document.body.append(container); const template = createTemplate(` <${tagName}><${tagName}> @@ -304,7 +314,9 @@ export const commonRegistryTests = (registry) => { it('insertBefore from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); document.body.append(container); const template = createTemplate(` <${tagName}><${tagName}> @@ -324,7 +336,9 @@ export const commonRegistryTests = (registry) => { it('prepend from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); document.body.append(container); const template = createTemplate(` <${tagName}><${tagName}> @@ -344,8 +358,12 @@ export const commonRegistryTests = (registry) => { it('insertAdjacentElement from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); - const parent = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); + const parent = document.createElement('div', { + customElements: registry, + }); container.append(parent); document.body.append(container); const template = createTemplate(` @@ -370,8 +388,12 @@ export const commonRegistryTests = (registry) => { it('replaceChild from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); - const parent = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); + const parent = document.createElement('div', { + customElements: registry, + }); container.append(parent); document.body.append(container); const template = createTemplate(` @@ -393,8 +415,12 @@ export const commonRegistryTests = (registry) => { it('replaceChildren from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); - const parent = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); + const parent = document.createElement('div', { + customElements: registry, + }); container.append(parent); document.body.append(container); const template = createTemplate(` @@ -416,8 +442,12 @@ export const commonRegistryTests = (registry) => { it('replaceWith from a template', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const container = registry.createElement('div'); - const parent = registry.createElement('div'); + const container = document.createElement('div', { + customElements: registry, + }); + const parent = document.createElement('div', { + customElements: registry, + }); container.append(parent); document.body.append(container); const template = createTemplate(` diff --git a/packages/scoped-custom-element-registry/test/utils.js b/packages/scoped-custom-element-registry/test/utils.js index 9ede48813..2ba02b63a 100644 --- a/packages/scoped-custom-element-registry/test/utils.js +++ b/packages/scoped-custom-element-registry/test/utils.js @@ -104,7 +104,9 @@ export const getUnitializedShadowRoot = () => { * @return {HTMLElement} */ export const getHTML = (html, root = document) => { - const div = root.customElements.createElement('div'); + const div = document.createElement('div', { + customElements: root.customElements, + }); div.innerHTML = html; From 233206599b61e35c8c5892371bdecdb43075fc93 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 27 Feb 2025 05:34:35 -0800 Subject: [PATCH 04/15] amended changelog --- .../scoped-custom-element-registry/CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/scoped-custom-element-registry/CHANGELOG.md b/packages/scoped-custom-element-registry/CHANGELOG.md index b0716009d..10c501ed3 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 {customElements} +- document.importNode: takes options with {selfOnly, customElements} +- Node.customElements set to creating registry + ## [0.0.10] - 2025-02-26 ### Added From c8c5724270889b5062b6e4ee967127d527b38d1d Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 27 Feb 2025 05:42:54 -0800 Subject: [PATCH 05/15] update package-lock --- packages/scoped-custom-element-registry/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/scoped-custom-element-registry/package-lock.json b/packages/scoped-custom-element-registry/package-lock.json index 73a95458f..87dc119ff 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", From 0dd39fac983f2f3daca0f0757f7cde9d7fddb601 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Tue, 16 Sep 2025 11:41:42 -0700 Subject: [PATCH 06/15] update to current spec property `customElementRegistry` --- .../src/scoped-custom-element-registry.ts | 93 ++++++++++++------- .../src/types.d.ts | 16 ++-- .../test/ShadowRoot.test.html.js | 40 ++++---- .../test/common-registry-tests.js | 60 ++++++------ .../test/utils.js | 6 +- 5 files changed, 117 insertions(+), 98 deletions(-) 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 582651804..3fb0a0adf 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 @@ -104,16 +104,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 | null; - customElements: CustomElementRegistry | null; +// 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 | null; - customElements?: CustomElementRegistry | null; + ['registry']?: CustomElementRegistry; + ['customElements']?: CustomElementRegistry; + ['customElementRegistry']?: CustomElementRegistry; } type ParametersOf< @@ -390,8 +391,9 @@ const registryFromContext = ( if (context instanceof CustomElementRegistry) { return context as ShimmedCustomElementsRegistry; } - const registry = (context as Element) - .customElements as ShimmedCustomElementsRegistry; + const registry = (context as Element)[ + 'customElementRegistry' + ] as ShimmedCustomElementsRegistry; return registry ?? null; }; @@ -451,8 +453,10 @@ const createStandInElement = (tagName: string): CustomElementConstructor => { pendingRegistry._upgradeWhenDefined(this, tagName, true); } else { const registry = - this.customElements ?? - (this.parentNode as Element | ShadowRoot)?.customElements; + this['customElementRegistry'] ?? + (this.parentNode as Element | ShadowRoot)?.[ + 'customElementRegistry' + ]; if (registry) { registryToSubtree( this, @@ -705,26 +709,32 @@ Element.prototype.attachShadow = function ( // 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, + 'customElementRegistry': customElementRegistry, + 'registry': registry = customElementRegistry, ...nativeInit } = init; const shadowRoot = nativeAttachShadow.call( this, nativeInit, ...(args as []) - ) as ShadowRootWithSettableCustomElements; + ) as ShadowRootWithSettableCustomElementRegistry; if (registry !== undefined) { registryForElement.set( shadowRoot, registry as ShimmedCustomElementsRegistry ); - (shadowRoot as ShadowRootWithSettableCustomElements)['registry'] = registry; + // for back compat, set both `registry` and `customElements` + (shadowRoot as ShadowRootInitWithSettableCustomElements)[ + 'registry' + ] = registry; + (shadowRoot as ShadowRootInitWithSettableCustomElements)[ + 'customElements' + ] = registry; } return shadowRoot; }; -const customElementsDescriptor = { +const customElementRegistryDescriptor = { get(this: Element) { const registry = registryForElement.get(this); return registry === undefined @@ -742,28 +752,31 @@ const {createElement, createElementNS, importNode} = Document.prototype; Object.defineProperty( Element.prototype, - 'customElements', - customElementsDescriptor + 'customElementRegistry', + customElementRegistryDescriptor ); Object.defineProperties(Document.prototype, { - 'customElements': customElementsDescriptor, + 'customElementRegistry': customElementRegistryDescriptor, 'createElement': { value( this: Document, tagName: K, options?: ElementCreationOptions ): HTMLElementTagNameMap[K] { - const {customElements} = options ?? {}; - if (customElements === undefined) { + const customElementRegistry = (options ?? {})['customElementRegistry']; + if (customElementRegistry === undefined) { return createElement.call(this, tagName) as HTMLElementTagNameMap[K]; } else { - creationContext.push(customElements); + creationContext.push(customElementRegistry); const el = createElement.call( this, tagName ) as HTMLElementTagNameMap[K]; creationContext.pop(); - registryToSubtree(el, customElements as ShimmedCustomElementsRegistry); + registryToSubtree( + el, + customElementRegistry as ShimmedCustomElementsRegistry + ); return el; } }, @@ -777,22 +790,25 @@ Object.defineProperties(Document.prototype, { tagName: K, options?: ElementCreationOptions ): HTMLElementTagNameMap[K] { - const {customElements} = options ?? {}; - if (customElements === undefined) { + const customElementRegistry = (options ?? {})['customElementRegistry']; + if (customElementRegistry === undefined) { return createElementNS.call( this, namespace, tagName ) as HTMLElementTagNameMap[K]; } else { - creationContext.push(customElements); + creationContext.push(customElementRegistry); const el = createElementNS.call( this, namespace, tagName ) as HTMLElementTagNameMap[K]; creationContext.pop(); - registryToSubtree(el, customElements as ShimmedCustomElementsRegistry); + registryToSubtree( + el, + customElementRegistry as ShimmedCustomElementsRegistry + ); return el; } }, @@ -806,16 +822,18 @@ Object.defineProperties(Document.prototype, { options?: boolean | ImportNodeOptions ): T { const deep = typeof options === 'boolean' ? options : !options?.selfOnly; - const {customElements} = (options ?? {}) as ImportNodeOptions; - if (customElements === undefined) { + const customElementRegistry = ((options ?? {}) as ImportNodeOptions)[ + 'customElementRegistry' + ]; + if (customElementRegistry === undefined) { return importNode.call(this, node, deep) as T; } - creationContext.push(customElements); + creationContext.push(customElementRegistry); const imported = importNode.call(this, node, deep) as T; creationContext.pop(); registryToSubtree( imported, - customElements as ShimmedCustomElementsRegistry + customElementRegistry as ShimmedCustomElementsRegistry ); return imported; }, @@ -825,8 +843,8 @@ Object.defineProperties(Document.prototype, { }); Object.defineProperty( ShadowRoot.prototype, - 'customElements', - customElementsDescriptor + 'customElementRegistry', + customElementRegistryDescriptor ); // Install scoped creation API on Element & ShadowRoot @@ -839,7 +857,7 @@ const installScopedMethod = ( coda = function (this: Element, result: Node) { registryToSubtree( result ?? this, - this.customElements as ShimmedCustomElementsRegistry + this['customElementRegistry'] as ShimmedCustomElementsRegistry ); } ) => { @@ -863,7 +881,7 @@ const applyScopeFromParent = function (this: Element) { const scope = (this.parentNode ?? this) as Element; registryToSubtree( scope, - scope.customElements as ShimmedCustomElementsRegistry + scope['customElementRegistry'] as ShimmedCustomElementsRegistry ); }; @@ -891,7 +909,10 @@ const installScopedSetter = (ctor: Function, name: string) => { creationContext.push(this); descriptor.set!.call(this, value); creationContext.pop(); - registryToSubtree(this, this.customElements); + registryToSubtree( + this, + this['customElementRegistry'] as ShimmedCustomElementsRegistry + ); }, }); }; diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index 94d9607c3..c58d0b19a 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -6,16 +6,12 @@ declare global { initialize: (node: Node) => Node; } - interface ShadowRootInit { - customElements?: CustomElementRegistry | null; - } - interface ShadowRoot { - readonly customElements: CustomElementRegistry | null; + readonly ['customElementRegistry']: CustomElementRegistry | null; } interface Document { - readonly customElements: CustomElementRegistry | null; + readonly ['customElementRegistry']: CustomElementRegistry | null; createElement( tagName: K, options?: ElementCreationOptions @@ -34,21 +30,21 @@ declare global { } interface Element { - readonly customElements: CustomElementRegistry | null; + readonly ['customElementRegistry']: CustomElementRegistry | null; } interface InitializeShadowRootInit { - customElements?: CustomElementRegistry; + ['customElementRegistry']?: CustomElementRegistry; } interface ImportNodeOptions { selfOnly?: boolean; - customElements?: CustomElementRegistry; + ['customElementRegistry']?: CustomElementRegistry; } interface ElementCreationOptions { is?: string; - customElements?: CustomElementRegistry; + ['customElementRegistry']?: 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 3dcf0863f..5702a323e 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,7 +19,7 @@ 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', () => { @@ -31,7 +31,7 @@ describe('ShadowRoot', () => { const $div = getHTML(html); const $clone = document.importNode($div, { - customElements: shadowRoot.customELements, + customElementRegistry: shadowRoot.customELements, }); expect($clone.outerHTML).to.be.equal(html); @@ -49,7 +49,7 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`); const $clone = document.importNode($el, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); @@ -70,7 +70,7 @@ describe('ShadowRoot', () => { const secondShadowRoot = getShadowRoot(secondRegistry); const $clone = document.importNode($el, { - customElements: secondShadowRoot.customElements, + customElementRegistry: secondShadowRoot.customElementRegistry, }); expect($clone.outerHTML).to.be.equal($el.outerHTML); @@ -85,7 +85,7 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`); const $clone = document.importNode($el, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); @@ -100,7 +100,7 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`); const $clone = document.importNode($el, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone).to.be.instanceof(CustomElementClass); @@ -113,7 +113,7 @@ describe('ShadowRoot', () => { const $template = createTemplate(`<${tagName}>`); const $clone = document.importNode($template.content, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone).to.be.instanceof(DocumentFragment); @@ -130,7 +130,7 @@ describe('ShadowRoot', () => { registry.define(tagName, CustomElementClass); const $clone = document.importNode($template.content, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone).to.be.instanceof(DocumentFragment); @@ -147,7 +147,7 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = document.createElement('div', { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($el).to.not.be.undefined; @@ -161,7 +161,7 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = document.createElement(tagName, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($el).to.not.be.undefined; @@ -175,7 +175,7 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(registry); const $el = document.createElement(tagName, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($el).to.not.be.undefined; @@ -220,7 +220,7 @@ describe('ShadowRoot', () => { const $div = getHTML(html); const $clone = document.importNode($div, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone.outerHTML).to.be.equal(html); @@ -234,7 +234,7 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`); const $clone = document.importNode($el, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); @@ -251,7 +251,7 @@ describe('ShadowRoot', () => { const secondShadowRoot = getShadowRoot(); const $clone = document.importNode($el, { - customElements: secondShadowRoot.customElements, + customElementRegistry: secondShadowRoot.customElementRegistry, }); expect($clone.outerHTML).to.be.equal($el.outerHTML); @@ -263,7 +263,7 @@ describe('ShadowRoot', () => { const $el = getHTML(`<${tagName}>`); const $clone = document.importNode($el, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone.outerHTML).to.be.equal(`<${tagName}>`); @@ -275,7 +275,7 @@ describe('ShadowRoot', () => { const $template = createTemplate(`<${tagName}>`); const $clone = document.importNode($template.content, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone).to.be.instanceof(DocumentFragment); @@ -291,7 +291,7 @@ describe('ShadowRoot', () => { customElements.define(tagName, CustomElementClass); const $clone = document.importNode($template.content, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($clone).to.be.instanceof(DocumentFragment); @@ -307,7 +307,7 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(); const $el = document.createElement('div', { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($el).to.not.be.undefined; @@ -320,7 +320,7 @@ describe('ShadowRoot', () => { const shadowRoot = getShadowRoot(); const $el = document.createElement(tagName, { - customElements: shadowRoot.customElements, + customElementRegistry: shadowRoot.customElementRegistry, }); expect($el).to.not.be.undefined; 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 d0489c0b1..79e09f265 100644 --- a/packages/scoped-custom-element-registry/test/common-registry-tests.js +++ b/packages/scoped-custom-element-registry/test/common-registry-tests.js @@ -90,7 +90,9 @@ export const commonRegistryTests = (registry) => { it('should create custom elements', async () => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); - const el = document.createElement(tagName, {customElements: registry}); + const el = document.createElement(tagName, { + customElementRegistry: registry, + }); expect(el).to.be.instanceOf(CustomElementClass); }); }); @@ -104,7 +106,7 @@ export const commonRegistryTests = (registry) => { <${tagName}><${tagName}> `); const clone = document.importNode(template.content, { - customElements: registry, + customElementRegistry: registry, }); const els = clone.querySelectorAll(tagName); expect(els.length).to.be.equal(4); @@ -115,20 +117,20 @@ export const commonRegistryTests = (registry) => { describe('initialize', () => { it('can create uninitialized roots', async () => { const shadowRoot = getUnitializedShadowRoot(); - expect(shadowRoot.customElements).to.be.null; + expect(shadowRoot.customElementRegistry).to.be.null; shadowRoot.innerHTML = `
`; const el = shadowRoot.firstElementChild; - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); it('initialize sets customElements', async () => { const shadowRoot = getUnitializedShadowRoot(); shadowRoot.innerHTML = `
`; registry.initialize(shadowRoot); - expect(shadowRoot.customElements).to.be.equal(registry); + expect(shadowRoot.customElementRegistry).to.be.equal(registry); shadowRoot.innerHTML = `
`; const el = shadowRoot.firstElementChild; - expect(el.customElements).to.be.equal(registry); + expect(el.customElementRegistry).to.be.equal(registry); }); it('should not upgrade custom elements in uninitialized subtree', async () => { @@ -231,7 +233,7 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); document.body.append(container); shadowRoot.innerHTML = ` @@ -249,7 +251,7 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); document.body.append(container); const template = createTemplate(` @@ -258,7 +260,7 @@ export const commonRegistryTests = (registry) => { `); const clone = template.content.cloneNode(true); clone.querySelectorAll('*').forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); container.append(clone); const els = container.querySelectorAll(tagName); @@ -271,7 +273,7 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); document.body.append(container); const template = createTemplate(` @@ -280,7 +282,7 @@ export const commonRegistryTests = (registry) => { `); const {content} = template; content.querySelectorAll('*').forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); container.append(content); const els = container.querySelectorAll(tagName); @@ -293,7 +295,7 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); document.body.append(container); const template = createTemplate(` @@ -302,7 +304,7 @@ export const commonRegistryTests = (registry) => { `); const {content} = template; content.querySelectorAll('*').forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); container.appendChild(content); const els = container.querySelectorAll(tagName); @@ -315,7 +317,7 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); document.body.append(container); const template = createTemplate(` @@ -324,7 +326,7 @@ export const commonRegistryTests = (registry) => { `); const {content} = template; content.querySelectorAll('*').forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); container.insertBefore(content, null); const els = container.querySelectorAll(tagName); @@ -337,7 +339,7 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); document.body.append(container); const template = createTemplate(` @@ -346,7 +348,7 @@ export const commonRegistryTests = (registry) => { `); const {content} = template; content.querySelectorAll('*').forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); container.prepend(content); const els = container.querySelectorAll(tagName); @@ -359,10 +361,10 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); const parent = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); container.append(parent); document.body.append(container); @@ -373,7 +375,7 @@ export const commonRegistryTests = (registry) => { const {content} = template; const contentEls = Array.from(content.querySelectorAll('*')); contentEls.forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); parent.insertAdjacentElement('beforebegin', contentEls[1]); parent.insertAdjacentElement('afterend', contentEls[2]); @@ -389,10 +391,10 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); const parent = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); container.append(parent); document.body.append(container); @@ -403,7 +405,7 @@ export const commonRegistryTests = (registry) => { const {content} = template; const contentEls = Array.from(content.querySelectorAll('*')); contentEls.forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); container.replaceChild(content, parent); const els = container.querySelectorAll(tagName); @@ -416,10 +418,10 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); const parent = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); container.append(parent); document.body.append(container); @@ -430,7 +432,7 @@ export const commonRegistryTests = (registry) => { const {content} = template; const contentEls = Array.from(content.querySelectorAll('*')); contentEls.forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); container.replaceChildren(...Array.from(content.childNodes)); const els = container.querySelectorAll(tagName); @@ -443,10 +445,10 @@ export const commonRegistryTests = (registry) => { const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); const container = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); const parent = document.createElement('div', { - customElements: registry, + customElementRegistry: registry, }); container.append(parent); document.body.append(container); @@ -457,7 +459,7 @@ export const commonRegistryTests = (registry) => { const {content} = template; const contentEls = Array.from(content.querySelectorAll('*')); contentEls.forEach((el) => { - expect(el.customElements).to.be.null; + expect(el.customElementRegistry).to.be.null; }); parent.replaceWith(content); const els = container.querySelectorAll(tagName); diff --git a/packages/scoped-custom-element-registry/test/utils.js b/packages/scoped-custom-element-registry/test/utils.js index 2ba02b63a..12686048a 100644 --- a/packages/scoped-custom-element-registry/test/utils.js +++ b/packages/scoped-custom-element-registry/test/utils.js @@ -83,7 +83,7 @@ export const getFormAssociatedErrorTestElement = () => ({ */ export const getShadowRoot = (customElementRegistry) => { const el = document.createElement('div'); - return el.attachShadow({mode: 'open', customElements: customElementRegistry}); + return el.attachShadow({mode: 'open', customElementRegistry}); }; /** @@ -93,7 +93,7 @@ export const getShadowRoot = (customElementRegistry) => { */ export const getUnitializedShadowRoot = () => { const el = document.createElement('div'); - return el.attachShadow({mode: 'open', customElements: null}); + return el.attachShadow({mode: 'open', customElementRegistry: null}); }; /** @@ -105,7 +105,7 @@ export const getUnitializedShadowRoot = () => { */ export const getHTML = (html, root = document) => { const div = document.createElement('div', { - customElements: root.customElements, + customElementRegistry: root.customElementRegistry, }); div.innerHTML = html; From 6debe16ff0ace0d3a20202ff6b4b16be267d8c86 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Tue, 16 Sep 2025 11:45:38 -0700 Subject: [PATCH 07/15] Update changelog --- packages/scoped-custom-element-registry/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/scoped-custom-element-registry/CHANGELOG.md b/packages/scoped-custom-element-registry/CHANGELOG.md index 10c501ed3..045455a76 100644 --- a/packages/scoped-custom-element-registry/CHANGELOG.md +++ b/packages/scoped-custom-element-registry/CHANGELOG.md @@ -19,9 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - customElements.initialize: sets registry on a DOM tree -- document.createElement(NS): takes options with {customElements} -- document.importNode: takes options with {selfOnly, customElements} -- Node.customElements set to creating registry +- 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 From f4f20f43c897149c69c567fb123ab3109e3b091e Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sat, 20 Sep 2025 11:33:52 -0700 Subject: [PATCH 08/15] Update packages/scoped-custom-element-registry/src/types.d.ts Co-authored-by: Justin Fagnani --- packages/scoped-custom-element-registry/src/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index c58d0b19a..cc3fe11c5 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -7,7 +7,7 @@ declare global { } interface ShadowRoot { - readonly ['customElementRegistry']: CustomElementRegistry | null; + readonly customElementRegistry: CustomElementRegistry | null; } interface Document { From a115843ba5f10132358d56c0589b07c6aaf80c40 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sat, 20 Sep 2025 11:34:31 -0700 Subject: [PATCH 09/15] Update packages/scoped-custom-element-registry/src/types.d.ts Co-authored-by: Justin Fagnani --- packages/scoped-custom-element-registry/src/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index cc3fe11c5..8732ffb77 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -11,7 +11,7 @@ declare global { } interface Document { - readonly ['customElementRegistry']: CustomElementRegistry | null; + readonly customElementRegistry: CustomElementRegistry | null; createElement( tagName: K, options?: ElementCreationOptions From 057092fc4645483466439196546ef49243a8575e Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sat, 20 Sep 2025 11:34:44 -0700 Subject: [PATCH 10/15] Update packages/scoped-custom-element-registry/src/types.d.ts Co-authored-by: Justin Fagnani --- packages/scoped-custom-element-registry/src/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index 8732ffb77..142909c96 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -30,7 +30,7 @@ declare global { } interface Element { - readonly ['customElementRegistry']: CustomElementRegistry | null; + readonly customElementRegistry: CustomElementRegistry | null; } interface InitializeShadowRootInit { From 9995a9ed191ead36c95436b7ec2e37a165569dc7 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sat, 20 Sep 2025 11:35:05 -0700 Subject: [PATCH 11/15] Update packages/scoped-custom-element-registry/src/types.d.ts Co-authored-by: Justin Fagnani --- packages/scoped-custom-element-registry/src/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index 142909c96..a1aeb1272 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -34,7 +34,7 @@ declare global { } interface InitializeShadowRootInit { - ['customElementRegistry']?: CustomElementRegistry; + customElementRegistry?: CustomElementRegistry; } interface ImportNodeOptions { From 436c5a7a67521ffd5a3bd04411c39d62989aed4f Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sat, 20 Sep 2025 11:50:41 -0700 Subject: [PATCH 12/15] Add comments with spec links --- .../src/types.d.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index a1aeb1272..58527f076 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -2,49 +2,55 @@ export {}; declare global { interface CustomElementRegistry { - // This overload is for roots that use the global registry + // https://html.spec.whatwg.org/multipage/custom-elements.html#dom-customelementregistry-initialize initialize: (node: Node) => Node; } + // https://dom.spec.whatwg.org/#documentorshadowroot interface ShadowRoot { 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]; - + // https://dom.spec.whatwg.org/#dom-document-createelementns createElementNS( namespace: string | null, tagName: K, options?: ElementCreationOptions ): HTMLElementTagNameMap[K]; - + // https://dom.spec.whatwg.org/#dom-document-importnode importNode( node: T, options?: boolean | ImportNodeOptions ): T; } + // https://dom.spec.whatwg.org/#element interface Element { readonly customElementRegistry: CustomElementRegistry | null; } + // https://dom.spec.whatwg.org/#dictdef-shadowrootinit interface InitializeShadowRootInit { customElementRegistry?: CustomElementRegistry; } + // https://dom.spec.whatwg.org/#dictdef-importnodeoptions interface ImportNodeOptions { selfOnly?: boolean; - ['customElementRegistry']?: CustomElementRegistry; + customElementRegistry?: CustomElementRegistry; } - + // https://dom.spec.whatwg.org/#dictdef-elementcreationoptions interface ElementCreationOptions { is?: string; - ['customElementRegistry']?: CustomElementRegistry; + customElementRegistry?: CustomElementRegistry; } /* From d2a52ceb7d56cbe03db2090c5a12c9f8ce1d2e3d Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sat, 20 Sep 2025 11:51:05 -0700 Subject: [PATCH 13/15] Update packages/scoped-custom-element-registry/src/types.d.ts Co-authored-by: Justin Fagnani --- packages/scoped-custom-element-registry/src/types.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/scoped-custom-element-registry/src/types.d.ts b/packages/scoped-custom-element-registry/src/types.d.ts index 58527f076..92d447f89 100644 --- a/packages/scoped-custom-element-registry/src/types.d.ts +++ b/packages/scoped-custom-element-registry/src/types.d.ts @@ -44,6 +44,14 @@ declare global { // 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; } From 6671d59514125a76f084f42850a1ea81f4019dba Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sat, 20 Sep 2025 12:05:26 -0700 Subject: [PATCH 14/15] Address feedback: remove unneeded fallback --- .../src/scoped-custom-element-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3fb0a0adf..0b18ad04a 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 @@ -394,7 +394,7 @@ const registryFromContext = ( const registry = (context as Element)[ 'customElementRegistry' ] as ShimmedCustomElementsRegistry; - return registry ?? null; + return registry; }; // Helper to create stand-in element for each tagName registered that delegates From 41b04964ed8f04c910894c2d34bb233df5e8ed11 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sun, 28 Sep 2025 09:59:45 -0700 Subject: [PATCH 15/15] Change to an actual polyfill * status reported in CustomElementRegistryPolyfill.inUse * support DSD null registries via host attribute `polyfill-shadowrootcustomelementregistry` * add additional tests to address feedback * re-order some tests * tracked scoped context now only supports valid customElementRegistry values * added ability to test native impl on Safari + bail outs for known bugs --- .../src/scoped-custom-element-registry.ts | 1806 +++++++++-------- .../test/DeclarativeShadowRoot.test.html | 32 + .../test/DeclarativeShadowRoot.test.html.js | 46 + .../test/ShadowRoot.test.html.js | 220 +- .../test/common-registry-tests.js | 252 ++- .../test/form-associated.test.js | 14 +- .../test/utils.js | 8 +- 7 files changed, 1430 insertions(+), 948 deletions(-) create mode 100644 packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html create mode 100644 packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html.js 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 0b18ad04a..aad052463 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; @@ -122,682 +127,767 @@ 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 ->(); - -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; - 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; + 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!; } - // 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); + } + + // 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; } - // Flush whenDefined callbacks - const info = this._whenDefinedPromises.get(tagName); - if (info !== undefined) { - info.resolve(elementClass); - this._whenDefinedPromises.delete(tagName); - } - return elementClass; - } - upgrade(...args: Parameters) { - creationContext.push(this); - nativeRegistry.upgrade(...args); - creationContext.pop(); - args.forEach((n) => registryToSubtree(n, this)); - } + // 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)); + } - get(tagName: string) { - const definition = this._definitionsByTag.get(tagName); - return definition?.elementClass; - } + 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; - } + getName(elementClass: CustomElementConstructor) { + const definition = this._definitionsByClass.get(elementClass); + return definition?.tagName ?? null; + } - _getDefinition(tagName: string) { - return this._definitionsByTag.get(tagName); - } + _getDefinition(tagName: string) { + return this._definitionsByTag.get(tagName); + } - ['whenDefined'](tagName: string) { - const definition = this._getDefinition(tagName); - if (definition !== undefined) { - return Promise.resolve(definition.elementClass); + ['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); + } + return info.promise; } - let info = this._whenDefinedPromises.get(tagName); - if (info === undefined) { - info = new AsyncInfo(); - this._whenDefinedPromises.set(tagName, info); + + _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); + } } - 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())); + // upgrades the given element if defined or queues it for upgrade when defined. + _upgradeElement( + element: HTMLElement, + definition?: CustomElementDefinition + ) { + 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 { + this._upgradeWhenDefined(element, element.localName, true); + } } - if (shouldUpgrade) { - awaiting.add(element); - } else { - awaiting.delete(element); + + ['initialize'](node: Node) { + setRegistryForSubtree(node, this); + return node; } } - // 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); + 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; - ['initialize'](node: Node) { - registryToSubtree(node, this, true); - return node; - } -} + const creationContext: Array = [ + globalCustomElementRegistry, + ]; -// 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 registryFromContext = ( - node: Element -): ShimmedCustomElementsRegistry | null => { - const explicitRegistry = registryForElement.get(node); - if (explicitRegistry != null) { - return explicitRegistry; - } - const context = creationContext[creationContext.length - 1]; - if (context instanceof CustomElementRegistry) { - return context as ShimmedCustomElementsRegistry; - } - const registry = (context as Element)[ - 'customElementRegistry' - ] as ShimmedCustomElementsRegistry; - return registry; -}; - -// 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); + // 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; } - 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 = registryFromContext(instance); - registryToSubtree(instance, registry); - const definition = registry?._getDefinition(tagName); - if (definition) { - customize(instance, definition); - } else if (registry) { - pendingRegistryForElement.set(instance, registry); + return ( + (creationContext[ + creationContext.length - 1 + ] 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); + } + 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; } - return instance; - } - 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); + 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 { - const registry = - this['customElementRegistry'] ?? - (this.parentNode as Element | ShadowRoot)?.[ - 'customElementRegistry' - ]; - if (registry) { - registryToSubtree( - this, - registry as ShimmedCustomElementsRegistry, - true - ); + // 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 + ); + } } } } - } - 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); + 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); + } } - } - - adoptedCallback( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - definition?.adoptedCallback?.apply(this, args); - } - // Form-associated custom elements lifecycle methods - ['formAssociatedCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formAssociatedCallback']?.apply(this, args); + adoptedCallback( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + definition?.adoptedCallback?.apply(this, args); } - } - ['formDisabledCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formDisabledCallback']?.apply(this, args); + // Form-associated custom elements lifecycle methods + ['formAssociatedCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formAssociatedCallback']?.apply(this, args); + } } - } - ['formResetCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formResetCallback']?.apply(this, args); + ['formDisabledCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formDisabledCallback']?.apply(this, args); + } } - } - ['formStateRestoreCallback']( - this: HTMLElement, - ...args: ParametersOf - ) { - const definition = definitionForElement.get(this); - if (definition?.['formAssociated']) { - definition?.['formStateRestoreCallback']?.apply(this, args); + ['formResetCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formResetCallback']?.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; - } - 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); + ['formStateRestoreCallback']( + this: HTMLElement, + ...args: ParametersOf + ) { + const definition = definitionForElement.get(this); + if (definition?.['formAssociated']) { + definition?.['formStateRestoreCallback']?.apply(this, args); + } } - }; - } - const toggleAttribute = elementClass.prototype.toggleAttribute; - if (toggleAttribute) { - elementClass.prototype.toggleAttribute = function ( - n: string, - force?: boolean + + // 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 ) { - 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); + 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); } - } 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 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); + } + }; + } + }; -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; + // 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} + ); } - definition.observedAttributes.forEach((attr: string) => { - if (!instance.hasAttribute(attr)) { + + const ensureAttributesCustomized = ( + instance: CustomHTMLElement & HTMLElement + ) => { + if (!elementsPendingAttributes?.has(instance)) { return; } - definition.attributeChangedCallback!.call( - instance, - attr, - null, - instance.getAttribute(attr) - ); - }); -}; + 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 { - '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; - } - return shadowRoot; -}; - -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, - 'createElement': { - value( - this: Document, - tagName: K, - options?: ElementCreationOptions - ): HTMLElementTagNameMap[K] { - const customElementRegistry = (options ?? {})['customElementRegistry']; - if (customElementRegistry === undefined) { - return createElement.call(this, tagName) as HTMLElementTagNameMap[K]; + 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 + ) { + // 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; + } + return shadowRoot; + }; + + 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(); - registryToSubtree( + setRegistryForSubtree( el, customElementRegistry as ShimmedCustomElementsRegistry ); return el; - } + }, + enumerable: true, + configurable: true, }, - enumerable: true, - configurable: true, - }, - 'createElementNS': { - value( - this: Document, - namespace: string | null, - tagName: K, - options?: ElementCreationOptions - ): HTMLElementTagNameMap[K] { - const customElementRegistry = (options ?? {})['customElementRegistry']; - if (customElementRegistry === undefined) { - return createElementNS.call( - this, - namespace, - tagName - ) as HTMLElementTagNameMap[K]; - } else { + '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, @@ -805,257 +895,307 @@ Object.defineProperties(Document.prototype, { tagName ) as HTMLElementTagNameMap[K]; creationContext.pop(); - registryToSubtree( + setRegistryForSubtree( el, customElementRegistry as ShimmedCustomElementsRegistry ); return el; - } + }, + enumerable: true, + configurable: true, }, - enumerable: true, - configurable: true, - }, - 'importNode': { - value( - this: Document, - node: T, - options?: boolean | ImportNodeOptions - ): T { - const deep = typeof options === 'boolean' ? options : !options?.selfOnly; - const customElementRegistry = ((options ?? {}) as ImportNodeOptions)[ - 'customElementRegistry' - ]; - if (customElementRegistry === undefined) { - return importNode.call(this, node, deep) as T; - } - creationContext.push(customElementRegistry); - const imported = importNode.call(this, node, deep) as T; - creationContext.pop(); - registryToSubtree( - imported, - customElementRegistry as ShimmedCustomElementsRegistry - ); - return imported; + // 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, }, - enumerable: true, - configurable: true, - }, -}); -Object.defineProperty( - ShadowRoot.prototype, - 'customElementRegistry', - customElementRegistryDescriptor -); - -// Install scoped creation API on Element & ShadowRoot -const creationContext: Array< - Document | CustomElementRegistry | Element | ShadowRoot -> = [document]; -const installScopedMethod = ( - ctor: Function, - method: string, - coda = function (this: Element, result: Node) { - registryToSubtree( - 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); - const ret = native.apply(this, args); - creationContext.pop(); - coda?.call(this as Element, ret); - return ret; - }; -}; - -const applyScopeFromParent = function (this: Element) { - const scope = (this.parentNode ?? this) as Element; - registryToSubtree( - scope, - scope['customElementRegistry'] as ShimmedCustomElementsRegistry + }); + Object.defineProperty( + ShadowRoot.prototype, + 'customElementRegistry', + customElementRegistryDescriptor ); -}; - -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 installScopedSetter = (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(); - registryToSubtree( - this, + + // 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 ); - }, - }); -}; -installScopedSetter(Element, 'innerHTML'); -installScopedSetter(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', - ]; + } + ) => { + 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; + }; + }; - HTMLElement.prototype['attachInternals'] = function (...args) { - const internals = attachInternals.call(this, ...args); - internalsToHostMap.set(internals, this); - return internals; + const applyScopeFromParent = function (this: Element) { + const scope = (this.parentNode ?? this) as Element; + setRegistryForSubtree( + scope, + scope['customElementRegistry'] as ShimmedCustomElementsRegistry + ); }; - const proto = window['ElementInternals'].prototype; + 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; + }; - 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 { - throw new DOMException( - `Failed to execute ${originalMethod} on 'ElementInternals': The target element is not a form-associated custom element.` - ); + 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'); - constructor(elements: Array) { - super(...elements); - this._elements = elements; - } - [index: number]: Node; + // Install global registry + Object.defineProperty(window, 'customElements', { + value: globalCustomElementRegistry, + configurable: true, + writable: true, + }); - item(index: number): Node | null { - return this[index]; - } + 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; + }; - 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]; + const proto = window['ElementInternals'].prototype; + + 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[] + ); + } + }); + } + + [index: number]: Element; + + ['item'](index: number): Element | null { + return this[index] ?? null; + } + + [Symbol.iterator](): IterableIterator { + throw new Error('Method not implemented.'); + } - // Override the built-in HTMLFormElements.prototype.elements getter - const formElementsDescriptor = Object.getOwnPropertyDescriptor( - HTMLFormElement.prototype, - 'elements' - )!; + ['namedItem'](key: string): RadioNodeList | Element | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this as any)[key] ?? null; + } + }; + + // Override the built-in HTMLFormElements.prototype.elements getter + const formElementsDescriptor = Object.getOwnPropertyDescriptor( + HTMLFormElement.prototype, + 'elements' + )!; - Object.defineProperty(HTMLFormElement.prototype, 'elements', { - get: function () { - const nativeElements = formElementsDescriptor.get!.call(this); + Object.defineProperty(HTMLFormElement.prototype, 'elements', { + get: function () { + const nativeElements = formElementsDescriptor.get!.call(this); - const include: Array = []; + const include: Array = []; - for (const element of nativeElements) { - const definition = definitionForElement.get(element); + 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); + // 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/test/DeclarativeShadowRoot.test.html b/packages/scoped-custom-element-registry/test/DeclarativeShadowRoot.test.html new file mode 100644 index 000000000..6fd0d961e --- /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 000000000..fccf7917d --- /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 5702a323e..3f6af8c33 100644 --- a/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js +++ b/packages/scoped-custom-element-registry/test/ShadowRoot.test.html.js @@ -23,6 +23,76 @@ describe('ShadowRoot', () => { }); 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(); @@ -37,7 +107,32 @@ describe('ShadowRoot', () => { 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); @@ -46,15 +141,21 @@ describe('ShadowRoot', () => { registry.define(tagName, AnotherCustomElementClass); const shadowRoot = getShadowRoot(registry); - const $el = getHTML(`<${tagName}>`); - - const $clone = document.importNode($el, { + const el1 = getHTML(`<${tagName}>`, shadowRoot); + const el2 = document.createElement(tagName); + el1.append(el2); + const clone1 = document.importNode(el1, { customElementRegistry: shadowRoot.customElementRegistry, }); - - expect($clone.outerHTML).to.be.equal(`<${tagName}>`); - expect($clone).not.to.be.instanceof(CustomElementClass); - expect($clone).to.be.instanceof(AnotherCustomElementClass); + 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', () => { @@ -72,10 +173,11 @@ describe('ShadowRoot', () => { const $clone = document.importNode($el, { customElementRegistry: secondShadowRoot.customElementRegistry, }); - - expect($clone.outerHTML).to.be.equal($el.outerHTML); - expect($clone).not.to.be.instanceof(CustomElementClass); - expect($clone).to.be.instanceof(AnotherCustomElementClass); + 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', () => { @@ -97,7 +199,7 @@ describe('ShadowRoot', () => { registry.define(tagName, CustomElementClass); const shadowRoot = getShadowRoot(registry); - const $el = getHTML(`<${tagName}>`); + const $el = getHTML(`<${tagName}>`, shadowRoot); const $clone = document.importNode($el, { customElementRegistry: shadowRoot.customElementRegistry, @@ -140,11 +242,12 @@ 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 shadowRoot = getShadowRoot(); const $el = document.createElement('div', { customElementRegistry: shadowRoot.customElementRegistry, @@ -154,25 +257,10 @@ describe('ShadowRoot', () => { 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 = 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 shadowRoot = getShadowRoot(); const $el = document.createElement(tagName, { customElementRegistry: shadowRoot.customElementRegistry, @@ -184,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}>`; @@ -197,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}>`; @@ -210,9 +297,7 @@ describe('ShadowRoot', () => { ); }); }); - }); - describe('without custom registry', () => { describe('importNode', () => { it('should import a basic node', () => { const shadowRoot = getShadowRoot(); @@ -301,58 +386,5 @@ describe('ShadowRoot', () => { expect($clone.firstElementChild).to.be.instanceof(CustomElementClass); }); }); - - describe('createElement', () => { - it('should create a regular element', () => { - const shadowRoot = getShadowRoot(); - - const $el = document.createElement('div', { - customElementRegistry: shadowRoot.customElementRegistry, - }); - - 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 = 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 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 79e09f265..5e90ff24a 100644 --- a/packages/scoped-custom-element-registry/test/common-registry-tests.js +++ b/packages/scoped-custom-element-registry/test/common-registry-tests.js @@ -2,7 +2,7 @@ import {expect, nextFrame} from '@open-wc/testing'; import { getTestElement, createTemplate, - getUnitializedShadowRoot, + getUninitializedShadowRoot, } from './utils.js'; export const commonRegistryTests = (registry) => { @@ -55,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); @@ -116,7 +118,7 @@ export const commonRegistryTests = (registry) => { describe('initialize', () => { it('can create uninitialized roots', async () => { - const shadowRoot = getUnitializedShadowRoot(); + const shadowRoot = getUninitializedShadowRoot(); expect(shadowRoot.customElementRegistry).to.be.null; shadowRoot.innerHTML = `
`; const el = shadowRoot.firstElementChild; @@ -124,7 +126,7 @@ export const commonRegistryTests = (registry) => { }); it('initialize sets customElements', async () => { - const shadowRoot = getUnitializedShadowRoot(); + const shadowRoot = getUninitializedShadowRoot(); shadowRoot.innerHTML = `
`; registry.initialize(shadowRoot); expect(shadowRoot.customElementRegistry).to.be.equal(registry); @@ -133,8 +135,34 @@ export const commonRegistryTests = (registry) => { 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 = getUnitializedShadowRoot(); + const shadowRoot = getUninitializedShadowRoot(); const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); shadowRoot.innerHTML = `<${tagName}>
`; @@ -148,8 +176,8 @@ export const commonRegistryTests = (registry) => { expect(el2).not.to.be.instanceOf(CustomElementClass); }); - it('should upgrade custom elements in initialized subtree', async () => { - const shadowRoot = getUnitializedShadowRoot(); + it('should not upgrade custom elements in initialized subtree', async () => { + const shadowRoot = getUninitializedShadowRoot(); const {tagName, CustomElementClass} = getTestElement(); registry.define(tagName, CustomElementClass); shadowRoot.innerHTML = `<${tagName}>
`; @@ -157,7 +185,10 @@ export const commonRegistryTests = (registry) => { const el = shadowRoot.firstElementChild; const container = shadowRoot.lastElementChild; expect(el.localName).to.be.equal(tagName); - expect(el).to.be.instanceOf(CustomElementClass); + 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); @@ -167,8 +198,12 @@ export const commonRegistryTests = (registry) => { describe('null customElements', () => { describe('do not customize when created', () => { - it('with innerHTML', () => { - const shadowRoot = getUnitializedShadowRoot(); + 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); @@ -184,8 +219,12 @@ export const commonRegistryTests = (registry) => { ); shadowRoot.host.remove(); }); - it('with insertAdjacentHTML', () => { - const shadowRoot = getUnitializedShadowRoot(); + 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); @@ -206,10 +245,14 @@ export const commonRegistryTests = (registry) => { 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 = getUnitializedShadowRoot(); + const shadowRoot = getUninitializedShadowRoot(); const {tagName, CustomElementClass} = getTestElement(); // globally define this customElements.define(tagName, CustomElementClass); @@ -228,8 +271,12 @@ export const commonRegistryTests = (registry) => { }); }); describe('customize when connected', () => { - it('append from unitialized shadowRoot', async () => { - const shadowRoot = getUnitializedShadowRoot(); + 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', { @@ -469,4 +516,179 @@ export const commonRegistryTests = (registry) => { }); }); }); + + 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 c0a73518e..651ec7f00 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 12686048a..432d566ba 100644 --- a/packages/scoped-custom-element-registry/test/utils.js +++ b/packages/scoped-custom-element-registry/test/utils.js @@ -91,9 +91,13 @@ export const getShadowRoot = (customElementRegistry) => { * * @return {ShadowRoot} */ -export const getUnitializedShadowRoot = () => { +export const getUninitializedShadowRoot = () => { const el = document.createElement('div'); - return el.attachShadow({mode: 'open', customElementRegistry: null}); + // note: using polyfill-specific host attribute + el.setHTMLUnsafe( + `
` + ); + return /** @type {ShadowRoot} */ el.firstElementChild.shadowRoot; }; /**