From 806e9602c676d8505947a0f8079af174ba40dacf Mon Sep 17 00:00:00 2001 From: Martin Schitter Date: Sat, 9 Dec 2023 01:18:52 +0100 Subject: [PATCH 1/9] #1176@patch: Implement customElements.getName(). --- .../src/custom-element/CustomElementRegistry.ts | 13 +++++++++++++ .../custom-element/CustomElementRegistry.test.ts | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index bf1251732..53382a7c0 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -89,4 +89,17 @@ export default class CustomElementRegistry { this._callbacks[upperTagName].push(resolve); }); } + + /** + * Reverse lookup searching for tagName by given element class. + * + * @param elementClass Class constructor. + * @returns First found Tag name or `null`. + */ + public getName(elementClass: typeof HTMLElement): string | null { + const tagName = Object.keys(this._registry).find( + (k) => this._registry[k].elementClass === elementClass + ); + return !!tagName ? tagName : null; + } } diff --git a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts index ca2874b98..51637a923 100644 --- a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts +++ b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts @@ -68,4 +68,15 @@ describe('CustomElementRegistry', () => { }); }); }); + + describe('getName()', () => { + it('Returns null if no tagName is found in the registry for element class', () => { + expect(customElements.getName(CustomElement)).toBe(null); + }); + + it('Returns Tag name if element class is found in registry', () => { + customElements.define('custom-element', CustomElement); + expect(customElements.getName(CustomElement)).toMatch(/custom-element/i); + }); + }); }); From fb72997a2ebba8a0b849a581556a47add028ceb4 Mon Sep 17 00:00:00 2001 From: Martin Schitter Date: Sat, 9 Dec 2023 12:28:47 +0100 Subject: [PATCH 2/9] #1176@patch: Implement custom element name validation. --- .../custom-element/CustomElementRegistry.ts | 31 +++++++++++++++++++ .../CustomElementRegistry.test.ts | 16 +++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index 53382a7c0..496e93c51 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -9,6 +9,37 @@ export default class CustomElementRegistry { public _registry: { [k: string]: { elementClass: typeof HTMLElement; extends: string } } = {}; public _callbacks: { [k: string]: (() => void)[] } = {}; + /** + * Validates the correctness of custom element tag names. + * + * @param tagName custom element tag name. + * @returns boolean True, if tag name is standard compliant. + */ + private isValidCustomElementName(tagName: string): boolean { + // Validation criteria based on: + // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name + const PCENChar = + '[-_.]|[0-9]|[a-z]|\u{B7}|[\u{C0}-\u{D6}]|[\u{D8}-\u{F6}]' + + '|[\u{F8}-\u{37D}]|[\u{37F}-\u{1FFF}]' + + '|[\u{200C}-\u{200D}]|[\u{203F}-\u{2040}]|[\u{2070}-\u{218F}]' + + '|[\u{2C00}-\u{2FEF}]|[\u{3001}-\u{D7FF}]' + + '|[\u{F900}-\u{FDCF}]|[\u{FDF0}-\u{FFFD}]|[\u{10000}-\u{EFFFF}]'; + + const PCEN = new RegExp(`^[a-z](${PCENChar})*-(${PCENChar})*$`, 'u'); + + const forbiddenNames = [ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph' + ]; + return PCEN.test(tagName) && !forbiddenNames.includes(tagName); + } + /** * Defines a custom element class. * diff --git a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts index 51637a923..50e96fda0 100644 --- a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts +++ b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts @@ -10,6 +10,20 @@ describe('CustomElementRegistry', () => { CustomElement.observedAttributesCallCount = 0; }); + describe('isValidCustomElementName()', () => { + it('Validate custom elements tag name.', () => { + expect(customElements.isValidCustomElementName('a-b')).toBe(true); + expect(customElements.isValidCustomElementName('2a-b')).toBe(false); + expect(customElements.isValidCustomElementName('a2-b')).toBe(true); + expect(customElements.isValidCustomElementName('A-B')).toBe(false); + expect(customElements.isValidCustomElementName('aB-c')).toBe(false); + expect(customElements.isValidCustomElementName('ab')).toBe(false); + expect(customElements.isValidCustomElementName('a-\u00d9')).toBe(true); + expect(customElements.isValidCustomElementName('a_b.c-d')).toBe(true); + expect(customElements.isValidCustomElementName('font-face')).toBe(false); + }); + }); + describe('define()', () => { it('Defines an HTML element and returns it with get().', () => { customElements.define('custom-element', CustomElement); @@ -43,7 +57,7 @@ describe('CustomElementRegistry', () => { }); describe('get()', () => { - it('Returns element class if the tag name has been defined..', () => { + it('Returns element class if the tag name has been defined.', () => { customElements.define('custom-element', CustomElement); expect(customElements.get('custom-element')).toBe(CustomElement); }); From 8f3bbe16e1c5d18d6738734f7c68cd2979fbc654 Mon Sep 17 00:00:00 2001 From: Martin Schitter Date: Sun, 10 Dec 2023 11:39:24 +0100 Subject: [PATCH 3/9] #1176@patch: Custom Elements letter case handling. --- .../custom-element/CustomElementRegistry.ts | 51 +++++++++---------- .../happy-dom/src/nodes/document/Document.ts | 2 +- .../HTMLUnknownElement.ts | 20 ++++---- .../happy-dom/src/xml-parser/XMLParser.ts | 3 +- .../CustomElementRegistry.test.ts | 40 ++++++++++++++- .../HTMLUnknownElement.test.ts | 2 +- 6 files changed, 77 insertions(+), 41 deletions(-) diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index 496e93c51..e9c05fb5d 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -12,10 +12,10 @@ export default class CustomElementRegistry { /** * Validates the correctness of custom element tag names. * - * @param tagName custom element tag name. + * @param localName custom element tag name. * @returns boolean True, if tag name is standard compliant. */ - private isValidCustomElementName(tagName: string): boolean { + private isValidCustomElementName(localName: string): boolean { // Validation criteria based on: // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name const PCENChar = @@ -27,7 +27,7 @@ export default class CustomElementRegistry { const PCEN = new RegExp(`^[a-z](${PCENChar})*-(${PCENChar})*$`, 'u'); - const forbiddenNames = [ + const reservedNames = [ 'annotation-xml', 'color-profile', 'font-face', @@ -37,33 +37,31 @@ export default class CustomElementRegistry { 'font-face-name', 'missing-glyph' ]; - return PCEN.test(tagName) && !forbiddenNames.includes(tagName); + return PCEN.test(localName) && !reservedNames.includes(localName); } /** * Defines a custom element class. * - * @param tagName Tag name of element. + * @param localName Tag name of element. * @param elementClass Element class. * @param [options] Options. * @param options.extends */ public define( - tagName: string, + localName: string, elementClass: typeof HTMLElement, options?: { extends: string } ): void { - const upperTagName = tagName.toUpperCase(); - - if (!upperTagName.includes('-')) { + if (!this.isValidCustomElementName(localName)) { throw new DOMException( "Failed to execute 'define' on 'CustomElementRegistry': \"" + - tagName + + localName + '" is not a valid custom element name.' ); } - this._registry[upperTagName] = { + this._registry[localName] = { elementClass, extends: options && options.extends ? options.extends.toLowerCase() : null }; @@ -73,9 +71,9 @@ export default class CustomElementRegistry { elementClass._observedAttributes = elementClass.observedAttributes; } - if (this._callbacks[upperTagName]) { - const callbacks = this._callbacks[upperTagName]; - delete this._callbacks[upperTagName]; + if (this._callbacks[localName]) { + const callbacks = this._callbacks[localName]; + delete this._callbacks[localName]; for (const callback of callbacks) { callback(); } @@ -85,12 +83,11 @@ export default class CustomElementRegistry { /** * Returns a defined element class. * - * @param tagName Tag name of element. + * @param localName Tag name of element. * @param HTMLElement Class defined. */ - public get(tagName: string): typeof HTMLElement { - const upperTagName = tagName.toUpperCase(); - return this._registry[upperTagName] ? this._registry[upperTagName].elementClass : undefined; + public get(localName: string): typeof HTMLElement { + return this._registry[localName] ? this._registry[localName].elementClass : undefined; } /** @@ -107,17 +104,19 @@ export default class CustomElementRegistry { /** * When defined. * - * @param tagName Tag name of element. + * @param localName Tag name of element. * @returns Promise. */ - public whenDefined(tagName: string): Promise { - const upperTagName = tagName.toUpperCase(); - if (this.get(upperTagName)) { + public whenDefined(localName: string): Promise { + if (!this.isValidCustomElementName(localName)) { + return Promise.reject(new DOMException(`Invalid custom element name: "${localName}"`)); + } + if (this.get(localName)) { return Promise.resolve(); } return new Promise((resolve) => { - this._callbacks[upperTagName] = this._callbacks[upperTagName] || []; - this._callbacks[upperTagName].push(resolve); + this._callbacks[localName] = this._callbacks[localName] || []; + this._callbacks[localName].push(resolve); }); } @@ -128,9 +127,9 @@ export default class CustomElementRegistry { * @returns First found Tag name or `null`. */ public getName(elementClass: typeof HTMLElement): string | null { - const tagName = Object.keys(this._registry).find( + const localName = Object.keys(this._registry).find( (k) => this._registry[k].elementClass === elementClass ); - return !!tagName ? tagName : null; + return !!localName ? localName : null; } } diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index 1000dccff..ad4609870 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -823,7 +823,7 @@ export default class Document extends Node implements IDocument { if (this.defaultView && options && options.is) { customElementClass = this.defaultView.customElements.get(String(options.is)); } else if (this.defaultView) { - customElementClass = this.defaultView.customElements.get(tagName); + customElementClass = this.defaultView.customElements.get(String(qualifiedName)); } const elementClass: typeof Element = diff --git a/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts b/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts index 02cb7a737..7704856d9 100644 --- a/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts +++ b/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts @@ -23,17 +23,17 @@ export default class HTMLUnknownElement extends HTMLElement implements IHTMLElem * @param parentNode Parent node. */ public _connectToNode(parentNode: INode = null): void { - const tagName = this.tagName; + const localName = this.localName; // This element can potentially be a custom element that has not been defined yet // Therefore we need to register a callback for when it is defined in CustomElementRegistry and replace it with the registered element (see #404) - if (tagName.includes('-') && this.ownerDocument.defaultView.customElements._callbacks) { + if (localName.includes('-') && this.ownerDocument.defaultView.customElements._callbacks) { const callbacks = this.ownerDocument.defaultView.customElements._callbacks; if (parentNode && !this._customElementDefineCallback) { const callback = (): void => { if (this.parentNode) { - const newElement = this.ownerDocument.createElement(tagName); + const newElement = this.ownerDocument.createElement(localName); (>newElement._childNodes) = this._childNodes; (>newElement._children) = this._children; (newElement.isConnected) = this.isConnected; @@ -82,16 +82,16 @@ export default class HTMLUnknownElement extends HTMLElement implements IHTMLElem this._connectToNode(null); } }; - callbacks[tagName] = callbacks[tagName] || []; - callbacks[tagName].push(callback); + callbacks[localName] = callbacks[localName] || []; + callbacks[localName].push(callback); this._customElementDefineCallback = callback; - } else if (!parentNode && callbacks[tagName] && this._customElementDefineCallback) { - const index = callbacks[tagName].indexOf(this._customElementDefineCallback); + } else if (!parentNode && callbacks[localName] && this._customElementDefineCallback) { + const index = callbacks[localName].indexOf(this._customElementDefineCallback); if (index !== -1) { - callbacks[tagName].splice(index, 1); + callbacks[localName].splice(index, 1); } - if (!callbacks[tagName].length) { - delete callbacks[tagName]; + if (!callbacks[localName].length) { + delete callbacks[localName]; } this._customElementDefineCallback = null; } diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts index 2d6fe68c2..d4d399589 100755 --- a/packages/happy-dom/src/xml-parser/XMLParser.ts +++ b/packages/happy-dom/src/xml-parser/XMLParser.ts @@ -107,6 +107,7 @@ export default class XMLParser { // Start tag. const tagName = match[1].toUpperCase(); + const localName = match[1]; // Some elements are not allowed to be nested (e.g. "" is not allowed.). // Therefore we need to auto-close the tag, so that it become valid (e.g. ""). @@ -130,7 +131,7 @@ export default class XMLParser { tagName === 'SVG' ? NamespaceURI.svg : (currentNode).namespaceURI || NamespaceURI.html; - const newElement = document.createElementNS(namespaceURI, tagName); + const newElement = document.createElementNS(namespaceURI, localName); currentNode.appendChild(newElement); currentNode = newElement; diff --git a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts index 50e96fda0..0d9ba8de5 100644 --- a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts +++ b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts @@ -1,11 +1,19 @@ import CustomElement from '../CustomElement.js'; import CustomElementRegistry from '../../src/custom-element/CustomElementRegistry.js'; +import IWindow from '../../src/window/IWindow.js'; +import IDocument from '../../src/nodes/document/IDocument.js'; +import Window from '../../src/window/Window.js'; import { beforeEach, describe, it, expect } from 'vitest'; +import { rejects } from 'assert'; describe('CustomElementRegistry', () => { let customElements; + let window: IWindow; + let document: IDocument; beforeEach(() => { + window = new Window(); + document = window.document; customElements = new CustomElementRegistry(); CustomElement.observedAttributesCallCount = 0; }); @@ -21,6 +29,7 @@ describe('CustomElementRegistry', () => { expect(customElements.isValidCustomElementName('a-\u00d9')).toBe(true); expect(customElements.isValidCustomElementName('a_b.c-d')).toBe(true); expect(customElements.isValidCustomElementName('font-face')).toBe(false); + expect(customElements.isValidCustomElementName('a-Öa')).toBe(true); }); }); @@ -35,7 +44,7 @@ describe('CustomElementRegistry', () => { extends: 'ul' }); expect(customElements.get('custom-element')).toBe(CustomElement); - expect(customElements._registry['CUSTOM-ELEMENT'].extends).toBe('ul'); + expect(customElements._registry['custom-element'].extends).toBe('ul'); }); it('Throws an error if tag name does not contain "-".', () => { @@ -54,6 +63,11 @@ describe('CustomElementRegistry', () => { expect(CustomElement.observedAttributesCallCount).toBe(1); expect(CustomElement._observedAttributes).toEqual(['key1', 'key2']); }); + + it('Non-ASCII capital letter in localName.', () => { + customElements.define('a-Öa', CustomElement); + expect(customElements.get('a-Öa')).toBe(CustomElement); + }); }); describe('get()', () => { @@ -65,9 +79,19 @@ describe('CustomElementRegistry', () => { it('Returns undefined if the tag name has not been defined.', () => { expect(customElements.get('custom-element')).toBe(undefined); }); + + it('Case sensitivity of get().', () => { + customElements.define('custom-element', CustomElement); + expect(customElements.get('CUSTOM-ELEMENT')).toBe(undefined); + }); }); describe('whenDefined()', () => { + it('Throws an error if tag name looks invalide', async () => { + const tagName = 'element'; + expect(async() => await customElements.whenDefined(tagName)).rejects.toThrow(); + }); + it('Returns a promise which is fulfilled when an element is defined.', async () => { await new Promise((resolve) => { customElements.whenDefined('custom-element').then(resolve); @@ -90,7 +114,19 @@ describe('CustomElementRegistry', () => { it('Returns Tag name if element class is found in registry', () => { customElements.define('custom-element', CustomElement); - expect(customElements.getName(CustomElement)).toMatch(/custom-element/i); + expect(customElements.getName(CustomElement)).toMatch('custom-element'); + }); + }); + + describe('createElement()', () => { + it('Case insensitive access via document.createElement()', () => { + customElements.define('custom-element', CustomElement); + expect(document.createElement('CUSTOM-ELEMENT').localName).toBe('custom-element'); + }); + + it('Non-ASCII capital letters in document.createElement()', () => { + customElements.define('a-Öa', CustomElement); + expect(document.createElement('a-Öa').localName).toMatch(/a-Öa/i); }); }); }); diff --git a/packages/happy-dom/test/nodes/html-unknown-element/HTMLUnknownElement.test.ts b/packages/happy-dom/test/nodes/html-unknown-element/HTMLUnknownElement.test.ts index a5f002db2..43b4d1d80 100644 --- a/packages/happy-dom/test/nodes/html-unknown-element/HTMLUnknownElement.test.ts +++ b/packages/happy-dom/test/nodes/html-unknown-element/HTMLUnknownElement.test.ts @@ -22,7 +22,7 @@ describe('HTMLUnknownElement', () => { parent.appendChild(element); - expect(window.customElements._callbacks['CUSTOM-ELEMENT'].length).toBe(1); + expect(window.customElements._callbacks['custom-element'].length).toBe(1); parent.removeChild(element); From 1997a865186917e8376c36abca2aeb9e6a21e511 Mon Sep 17 00:00:00 2001 From: Martin Schitter Date: Sun, 10 Dec 2023 12:01:39 +0100 Subject: [PATCH 4/9] #1176@patch: Prevent custom element redefinition. --- .../src/custom-element/CustomElementRegistry.ts | 6 +++++- .../custom-element/CustomElementRegistry.test.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index e9c05fb5d..4b37b481e 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -61,6 +61,10 @@ export default class CustomElementRegistry { ); } + if (this._registry[localName]) { + throw new DOMException(`Custom Element: "${localName}" already defined.`); + } + this._registry[localName] = { elementClass, extends: options && options.extends ? options.extends.toLowerCase() : null @@ -124,7 +128,7 @@ export default class CustomElementRegistry { * Reverse lookup searching for tagName by given element class. * * @param elementClass Class constructor. - * @returns First found Tag name or `null`. + * @returns Found Tag name or `null`. */ public getName(elementClass: typeof HTMLElement): string | null { const localName = Object.keys(this._registry).find( diff --git a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts index 0d9ba8de5..d6baf08fe 100644 --- a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts +++ b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts @@ -58,6 +58,11 @@ describe('CustomElementRegistry', () => { ); }); + it('Throws an error if already defined.', () => { + customElements.define('custom-element', CustomElement); + expect(() => customElements.define('custom-element', CustomElement)).toThrow(); + }); + it('Calls observed attributes and set _observedAttributes as a property on the element class.', () => { customElements.define('custom-element', CustomElement); expect(CustomElement.observedAttributesCallCount).toBe(1); @@ -87,9 +92,9 @@ describe('CustomElementRegistry', () => { }); describe('whenDefined()', () => { - it('Throws an error if tag name looks invalide', async () => { + it('Throws an error if tag name looks invalid.', async () => { const tagName = 'element'; - expect(async() => await customElements.whenDefined(tagName)).rejects.toThrow(); + expect(async () => await customElements.whenDefined(tagName)).rejects.toThrow(); }); it('Returns a promise which is fulfilled when an element is defined.', async () => { @@ -119,12 +124,12 @@ describe('CustomElementRegistry', () => { }); describe('createElement()', () => { - it('Case insensitive access via document.createElement()', () => { + it('Case insensitive access via document.createElement().', () => { customElements.define('custom-element', CustomElement); expect(document.createElement('CUSTOM-ELEMENT').localName).toBe('custom-element'); }); - it('Non-ASCII capital letters in document.createElement()', () => { + it('Non-ASCII capital letters in document.createElement().', () => { customElements.define('a-Öa', CustomElement); expect(document.createElement('a-Öa').localName).toMatch(/a-Öa/i); }); From 36b2c8e1beb989e064ddb1f2668afa277ae5931e Mon Sep 17 00:00:00 2001 From: Martin Schitter Date: Mon, 11 Dec 2023 17:46:03 +0100 Subject: [PATCH 5/9] #1176@patch: Prevent custom element registration under a different name. --- .../happy-dom/src/custom-element/CustomElementRegistry.ts | 5 +++++ .../test/custom-element/CustomElementRegistry.test.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index 4b37b481e..b7af253ae 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -65,6 +65,11 @@ export default class CustomElementRegistry { throw new DOMException(`Custom Element: "${localName}" already defined.`); } + const otherName = this.getName(elementClass); + if (otherName) { + throw new DOMException(`Custom Element already defined as "${otherName}".`); + } + this._registry[localName] = { elementClass, extends: options && options.extends ? options.extends.toLowerCase() : null diff --git a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts index d6baf08fe..fc2b7020e 100644 --- a/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts +++ b/packages/happy-dom/test/custom-element/CustomElementRegistry.test.ts @@ -63,6 +63,11 @@ describe('CustomElementRegistry', () => { expect(() => customElements.define('custom-element', CustomElement)).toThrow(); }); + it('Throws an error if already registered under a different tag name.', () => { + customElements.define('custom-element', CustomElement); + expect(() => customElements.define('custom-element2', CustomElement)).toThrow(); + }); + it('Calls observed attributes and set _observedAttributes as a property on the element class.', () => { customElements.define('custom-element', CustomElement); expect(CustomElement.observedAttributesCallCount).toBe(1); From cee03ab6cf6e7243812c920134f8631c71b21114 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 21 Feb 2024 00:43:56 +0100 Subject: [PATCH 6/9] fix: [#1176] Use localName correctly in Document.createElement() and Document.createElementNS() --- packages/happy-dom/src/PropertySymbol.ts | 2 + packages/happy-dom/src/config/ElementTag.ts | 130 ------------------ .../src/config/HTMLElementLocalNameToClass.ts | 119 ++++++++++++++++ ...extElements.ts => HTMLElementPlainText.ts} | 0 ...leElements.ts => HTMLElementUnnestable.ts} | 0 .../{VoidElements.ts => HTMLElementVoid.ts} | 0 packages/happy-dom/src/config/NamespaceURI.ts | 3 +- .../custom-element/CustomElementRegistry.ts | 26 ++-- .../happy-dom/src/nodes/document/Document.ts | 78 ++++++++--- .../happy-dom/src/nodes/element/Element.ts | 4 +- .../src/nodes/html-element/HTMLElement.ts | 127 +++++++++++++++++ .../HTMLUnknownElement.ts | 130 +----------------- .../nodes/parent-node/ParentNodeUtility.ts | 8 +- .../src/nodes/svg-element/SVGElement.ts | 2 +- .../happy-dom/src/xml-parser/XMLParser.ts | 34 +++-- .../src/xml-serializer/XMLSerializer.ts | 12 +- .../CustomElementRegistry.test.ts | 64 +++++---- .../test/nodes/document/Document.test.ts | 124 +++++++++++------ .../test/nodes/element/Element.test.ts | 1 - .../nodes/html-element/HTMLElement.test.ts | 89 ++++++++++++ .../HTMLUnknownElement.test.ts | 104 -------------- .../test/xml-parser/XMLParser.test.ts | 4 +- 22 files changed, 563 insertions(+), 498 deletions(-) delete mode 100644 packages/happy-dom/src/config/ElementTag.ts create mode 100644 packages/happy-dom/src/config/HTMLElementLocalNameToClass.ts rename packages/happy-dom/src/config/{PlainTextElements.ts => HTMLElementPlainText.ts} (100%) rename packages/happy-dom/src/config/{UnnestableElements.ts => HTMLElementUnnestable.ts} (100%) rename packages/happy-dom/src/config/{VoidElements.ts => HTMLElementVoid.ts} (100%) delete mode 100644 packages/happy-dom/test/nodes/html-unknown-element/HTMLUnknownElement.test.ts diff --git a/packages/happy-dom/src/PropertySymbol.ts b/packages/happy-dom/src/PropertySymbol.ts index 97d69c481..74f9963c5 100644 --- a/packages/happy-dom/src/PropertySymbol.ts +++ b/packages/happy-dom/src/PropertySymbol.ts @@ -149,3 +149,5 @@ export const content = Symbol('content'); export const mode = Symbol('mode'); export const host = Symbol('host'); export const setURL = Symbol('setURL'); +export const localName = Symbol('localName'); +export const registedClass = Symbol('registedClass'); diff --git a/packages/happy-dom/src/config/ElementTag.ts b/packages/happy-dom/src/config/ElementTag.ts deleted file mode 100644 index dbcb19d98..000000000 --- a/packages/happy-dom/src/config/ElementTag.ts +++ /dev/null @@ -1,130 +0,0 @@ -export default <{ [key: string]: string }>{ - A: 'HTMLAnchorElement', - ABBR: 'HTMLElement', - ADDRESS: 'HTMLElement', - AREA: 'HTMLElement', - ARTICLE: 'HTMLElement', - ASIDE: 'HTMLElement', - AUDIO: 'HTMLAudioElement', - B: 'HTMLElement', - BASE: 'HTMLBaseElement', - BDI: 'HTMLElement', - BDO: 'HTMLElement', - BLOCKQUAOTE: 'HTMLElement', - BODY: 'HTMLElement', - TEMPLATE: 'HTMLTemplateElement', - FORM: 'HTMLFormElement', - INPUT: 'HTMLInputElement', - TEXTAREA: 'HTMLTextAreaElement', - SCRIPT: 'HTMLScriptElement', - IMG: 'HTMLImageElement', - LINK: 'HTMLLinkElement', - STYLE: 'HTMLStyleElement', - LABEL: 'HTMLLabelElement', - SLOT: 'HTMLSlotElement', - SVG: 'SVGSVGElement', - G: 'SVGElement', - CIRCLE: 'SVGElement', - ELLIPSE: 'SVGElement', - LINE: 'SVGElement', - PATH: 'SVGElement', - POLYGON: 'SVGElement', - POLYLINE: 'SVGElement', - RECT: 'SVGElement', - STOP: 'SVGElement', - USE: 'SVGElement', - META: 'HTMLMetaElement', - BLOCKQUOTE: 'HTMLElement', - BR: 'HTMLElement', - BUTTON: 'HTMLButtonElement', - CANVAS: 'HTMLElement', - CAPTION: 'HTMLElement', - CITE: 'HTMLElement', - CODE: 'HTMLElement', - COL: 'HTMLElement', - COLGROUP: 'HTMLElement', - DATA: 'HTMLElement', - DATALIST: 'HTMLElement', - DD: 'HTMLElement', - DEL: 'HTMLElement', - DETAILS: 'HTMLElement', - DFN: 'HTMLElement', - DIALOG: 'HTMLDialogElement', - DIV: 'HTMLElement', - DL: 'HTMLElement', - DT: 'HTMLElement', - EM: 'HTMLElement', - EMBED: 'HTMLElement', - FIELDSET: 'HTMLElement', - FIGCAPTION: 'HTMLElement', - FIGURE: 'HTMLElement', - FOOTER: 'HTMLElement', - H1: 'HTMLElement', - H2: 'HTMLElement', - H3: 'HTMLElement', - H4: 'HTMLElement', - H5: 'HTMLElement', - H6: 'HTMLElement', - HEAD: 'HTMLElement', - HEADER: 'HTMLElement', - HGROUP: 'HTMLElement', - HR: 'HTMLElement', - HTML: 'HTMLElement', - I: 'HTMLElement', - IFRAME: 'HTMLIFrameElement', - INS: 'HTMLElement', - KBD: 'HTMLElement', - LEGEND: 'HTMLElement', - LI: 'HTMLElement', - MAIN: 'HTMLElement', - MAP: 'HTMLElement', - MARK: 'HTMLElement', - MATH: 'HTMLElement', - MENU: 'HTMLElement', - MENUITEM: 'HTMLElement', - METER: 'HTMLElement', - NAV: 'HTMLElement', - NOSCRIPT: 'HTMLElement', - OBJECT: 'HTMLElement', - OL: 'HTMLElement', - OPTGROUP: 'HTMLOptGroupElement', - OPTION: 'HTMLOptionElement', - OUTPUT: 'HTMLElement', - P: 'HTMLElement', - PARAM: 'HTMLElement', - PICTURE: 'HTMLElement', - PRE: 'HTMLElement', - PROGRESS: 'HTMLElement', - Q: 'HTMLElement', - RB: 'HTMLElement', - RP: 'HTMLElement', - RT: 'HTMLElement', - RTC: 'HTMLElement', - RUBY: 'HTMLElement', - S: 'HTMLElement', - SAMP: 'HTMLElement', - SECTION: 'HTMLElement', - SELECT: 'HTMLSelectElement', - SMALL: 'HTMLElement', - SOURCE: 'HTMLElement', - SPAN: 'HTMLElement', - STRONG: 'HTMLElement', - SUB: 'HTMLElement', - SUMMARY: 'HTMLElement', - SUP: 'HTMLElement', - TABLE: 'HTMLElement', - TBODY: 'HTMLElement', - TD: 'HTMLElement', - TFOOT: 'HTMLElement', - TH: 'HTMLElement', - THEAD: 'HTMLElement', - TIME: 'HTMLElement', - TITLE: 'HTMLElement', - TR: 'HTMLElement', - TRACK: 'HTMLElement', - U: 'HTMLElement', - UL: 'HTMLElement', - VAR: 'HTMLElement', - VIDEO: 'HTMLVideoElement', - WBR: 'HTMLElement' -}; diff --git a/packages/happy-dom/src/config/HTMLElementLocalNameToClass.ts b/packages/happy-dom/src/config/HTMLElementLocalNameToClass.ts new file mode 100644 index 000000000..fe5ab0b10 --- /dev/null +++ b/packages/happy-dom/src/config/HTMLElementLocalNameToClass.ts @@ -0,0 +1,119 @@ +export default <{ [key: string]: string }>{ + a: 'HTMLAnchorElement', + abbr: 'HTMLElement', + address: 'HTMLElement', + area: 'HTMLElement', + article: 'HTMLElement', + aside: 'HTMLElement', + audio: 'HTMLAudioElement', + b: 'HTMLElement', + base: 'HTMLBaseElement', + bdi: 'HTMLElement', + bdo: 'HTMLElement', + blockquaote: 'HTMLElement', + body: 'HTMLElement', + template: 'HTMLTemplateElement', + form: 'HTMLFormElement', + input: 'HTMLInputElement', + textarea: 'HTMLTextAreaElement', + script: 'HTMLScriptElement', + img: 'HTMLImageElement', + link: 'HTMLLinkElement', + style: 'HTMLStyleElement', + label: 'HTMLLabelElement', + slot: 'HTMLSlotElement', + meta: 'HTMLMetaElement', + blockquote: 'HTMLElement', + br: 'HTMLElement', + button: 'HTMLButtonElement', + canvas: 'HTMLElement', + caption: 'HTMLElement', + cite: 'HTMLElement', + code: 'HTMLElement', + col: 'HTMLElement', + colgroup: 'HTMLElement', + data: 'HTMLElement', + datalist: 'HTMLElement', + dd: 'HTMLElement', + del: 'HTMLElement', + details: 'HTMLElement', + dfn: 'HTMLElement', + dialog: 'HTMLDialogElement', + div: 'HTMLElement', + dl: 'HTMLElement', + dt: 'HTMLElement', + em: 'HTMLElement', + embed: 'HTMLElement', + fieldset: 'HTMLElement', + figcaption: 'HTMLElement', + figure: 'HTMLElement', + footer: 'HTMLElement', + h1: 'HTMLElement', + h2: 'HTMLElement', + h3: 'HTMLElement', + h4: 'HTMLElement', + h5: 'HTMLElement', + h6: 'HTMLElement', + head: 'HTMLElement', + header: 'HTMLElement', + hgroup: 'HTMLElement', + hr: 'HTMLElement', + html: 'HTMLElement', + i: 'HTMLElement', + iframe: 'HTMLIFrameElement', + ins: 'HTMLElement', + kbd: 'HTMLElement', + legend: 'HTMLElement', + li: 'HTMLElement', + main: 'HTMLElement', + map: 'HTMLElement', + mark: 'HTMLElement', + math: 'HTMLElement', + menu: 'HTMLElement', + menuitem: 'HTMLElement', + meter: 'HTMLElement', + nav: 'HTMLElement', + noscript: 'HTMLElement', + object: 'HTMLElement', + ol: 'HTMLElement', + optgroup: 'HTMLOptGroupElement', + option: 'HTMLOptionElement', + output: 'HTMLElement', + p: 'HTMLElement', + param: 'HTMLElement', + picture: 'HTMLElement', + pre: 'HTMLElement', + progress: 'HTMLElement', + q: 'HTMLElement', + rb: 'HTMLElement', + rp: 'HTMLElement', + rt: 'HTMLElement', + rtc: 'HTMLElement', + ruby: 'HTMLElement', + s: 'HTMLElement', + samp: 'HTMLElement', + section: 'HTMLElement', + select: 'HTMLSelectElement', + small: 'HTMLElement', + source: 'HTMLElement', + span: 'HTMLElement', + strong: 'HTMLElement', + sub: 'HTMLElement', + summary: 'HTMLElement', + sup: 'HTMLElement', + table: 'HTMLElement', + tbody: 'HTMLElement', + td: 'HTMLElement', + tfoot: 'HTMLElement', + th: 'HTMLElement', + thead: 'HTMLElement', + time: 'HTMLElement', + title: 'HTMLElement', + tr: 'HTMLElement', + track: 'HTMLElement', + u: 'HTMLElement', + ul: 'HTMLElement', + var: 'HTMLElement', + video: 'HTMLVideoElement', + wbr: 'HTMLElement' +}; diff --git a/packages/happy-dom/src/config/PlainTextElements.ts b/packages/happy-dom/src/config/HTMLElementPlainText.ts similarity index 100% rename from packages/happy-dom/src/config/PlainTextElements.ts rename to packages/happy-dom/src/config/HTMLElementPlainText.ts diff --git a/packages/happy-dom/src/config/UnnestableElements.ts b/packages/happy-dom/src/config/HTMLElementUnnestable.ts similarity index 100% rename from packages/happy-dom/src/config/UnnestableElements.ts rename to packages/happy-dom/src/config/HTMLElementUnnestable.ts diff --git a/packages/happy-dom/src/config/VoidElements.ts b/packages/happy-dom/src/config/HTMLElementVoid.ts similarity index 100% rename from packages/happy-dom/src/config/VoidElements.ts rename to packages/happy-dom/src/config/HTMLElementVoid.ts diff --git a/packages/happy-dom/src/config/NamespaceURI.ts b/packages/happy-dom/src/config/NamespaceURI.ts index 051bc5b19..8e57b1559 100644 --- a/packages/happy-dom/src/config/NamespaceURI.ts +++ b/packages/happy-dom/src/config/NamespaceURI.ts @@ -1,5 +1,6 @@ export default { html: 'http://www.w3.org/1999/xhtml', svg: 'http://www.w3.org/2000/svg', - mathML: 'http://www.w3.org/1998/Math/MathML' + mathML: 'http://www.w3.org/1998/Math/MathML', + xmlns: 'http://www.w3.org/2000/xmlns/' }; diff --git a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts index f6d720798..bc859c672 100644 --- a/packages/happy-dom/src/custom-element/CustomElementRegistry.ts +++ b/packages/happy-dom/src/custom-element/CustomElementRegistry.ts @@ -10,6 +10,7 @@ export default class CustomElementRegistry { public [PropertySymbol.registry]: { [k: string]: { elementClass: typeof HTMLElement; extends: string }; } = {}; + public [PropertySymbol.registedClass]: Map = new Map(); public [PropertySymbol.callbacks]: { [k: string]: (() => void)[] } = {}; /** @@ -27,9 +28,19 @@ export default class CustomElementRegistry { ): void { if (!this.#isValidCustomElementName(name)) { throw new DOMException( - "Failed to execute 'define' on 'CustomElementRegistry': \"" + - name + - '" is not a valid custom element name.' + `Failed to execute 'define' on 'CustomElementRegistry': "${name}" is not a valid custom element name` + ); + } + + if (this[PropertySymbol.registry][name]) { + throw new DOMException( + `Failed to execute 'define' on 'CustomElementRegistry': the name "${name}" has already been used with this registry` + ); + } + + if (this[PropertySymbol.registedClass].has(elementClass)) { + throw new DOMException( + "Failed to execute 'define' on 'CustomElementRegistry': this constructor has already been used with this registry" ); } @@ -37,6 +48,7 @@ export default class CustomElementRegistry { elementClass, extends: options && options.extends ? options.extends.toLowerCase() : null }; + this[PropertySymbol.registedClass].set(elementClass, name); // ObservedAttributes should only be called once by CustomElementRegistry (see #117) if (elementClass.prototype.attributeChangedCallback) { @@ -98,13 +110,7 @@ export default class CustomElementRegistry { * @returns Found tag name or `null`. */ public getName(elementClass: typeof HTMLElement): string | null { - // For loops are faster than find() - for (const name of Object.keys(this[PropertySymbol.registry])) { - if (this[PropertySymbol.registry][name].elementClass === elementClass) { - return name; - } - } - return null; + return this[PropertySymbol.registedClass].get(elementClass) || null; } /** diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index f3645aaa7..96311b9c5 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -1,6 +1,5 @@ import Element from '../element/Element.js'; import * as PropertySymbol from '../../PropertySymbol.js'; -import HTMLUnknownElement from '../html-unknown-element/HTMLUnknownElement.js'; import IBrowserWindow from '../../window/IBrowserWindow.js'; import Node from '../node/Node.js'; import NodeIterator from '../../tree-walker/NodeIterator.js'; @@ -9,7 +8,7 @@ import DocumentFragment from '../document-fragment/DocumentFragment.js'; import XMLParser from '../../xml-parser/XMLParser.js'; import Event from '../../event/Event.js'; import DOMImplementation from '../../dom-implementation/DOMImplementation.js'; -import ElementTag from '../../config/ElementTag.js'; +import HTMLElementLocalNameToClass from '../../config/HTMLElementLocalNameToClass.js'; import INodeFilter from '../../tree-walker/INodeFilter.js'; import NamespaceURI from '../../config/NamespaceURI.js'; import DocumentType from '../document-type/DocumentType.js'; @@ -879,30 +878,73 @@ export default class Document extends Node implements IDocument { qualifiedName: string, options?: { is?: string } ): IElement { - const tagName = String(qualifiedName).toUpperCase(); + qualifiedName = String(qualifiedName); - let customElementClass; - if (options && options.is) { - customElementClass = this[PropertySymbol.ownerWindow].customElements.get(String(options.is)); - } else { - customElementClass = this[PropertySymbol.ownerWindow].customElements.get( - String(qualifiedName) + if (!qualifiedName) { + throw new DOMException( + "Failed to execute 'createElementNS' on 'Document': The qualified name provided is empty." ); } - const elementClass: typeof Element = - customElementClass || - this[PropertySymbol.ownerWindow][ElementTag[tagName]] || - HTMLUnknownElement; + // SVG element + if (namespaceURI === NamespaceURI.svg) { + const element = NodeFactory.createNode( + this, + qualifiedName === 'svg' + ? this[PropertySymbol.ownerWindow].SVGSVGElement + : this[PropertySymbol.ownerWindow].SVGElement + ); + element[PropertySymbol.tagName] = qualifiedName; + element[PropertySymbol.localName] = qualifiedName; + element[PropertySymbol.namespaceURI] = namespaceURI; + element[PropertySymbol.isValue] = options && options.is ? String(options.is) : null; + return element; + } - const element = NodeFactory.createNode(this, elementClass); - element[PropertySymbol.tagName] = tagName; + // Custom HTML element + const customElement = + this[PropertySymbol.ownerWindow].customElements[PropertySymbol.registry]?.[ + options && options.is ? String(options.is) : qualifiedName + ]; + + if (customElement) { + const element = NodeFactory.createNode(this, customElement.elementClass); + element[PropertySymbol.tagName] = qualifiedName.toUpperCase(); + element[PropertySymbol.localName] = qualifiedName; + element[PropertySymbol.namespaceURI] = namespaceURI; + element[PropertySymbol.isValue] = options && options.is ? String(options.is) : null; + return element; + } - element[PropertySymbol.namespaceURI] = namespaceURI; - if (element instanceof Element && options && options.is) { - element[PropertySymbol.isValue] = String(options.is); + const localName = qualifiedName.toLowerCase(); + const elementClass = this[PropertySymbol.ownerWindow][HTMLElementLocalNameToClass[localName]]; + + // Known HTML element + if (elementClass) { + const element = NodeFactory.createNode(this, elementClass); + + element[PropertySymbol.tagName] = qualifiedName.toUpperCase(); + element[PropertySymbol.localName] = localName; + element[PropertySymbol.namespaceURI] = namespaceURI; + element[PropertySymbol.isValue] = options && options.is ? String(options.is) : null; + + return element; } + // Unknown HTML element + const element = NodeFactory.createNode( + this, + // If the tag name contains a hyphen, it is an unknown custom element and we should use HTMLElement. + localName.includes('-') + ? this[PropertySymbol.ownerWindow].HTMLElement + : this[PropertySymbol.ownerWindow].HTMLUnknownElement + ); + + element[PropertySymbol.tagName] = qualifiedName.toUpperCase(); + element[PropertySymbol.localName] = localName; + element[PropertySymbol.namespaceURI] = namespaceURI; + element[PropertySymbol.isValue] = options && options.is ? String(options.is) : null; + return element; } diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index b77be0f80..1e9c745e9 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -87,6 +87,7 @@ export default class Element extends Node implements IElement { public [PropertySymbol.computedStyle]: CSSStyleDeclaration | null = null; public [PropertySymbol.nodeType] = NodeTypeEnum.elementNode; public [PropertySymbol.tagName]: string | null = null; + public [PropertySymbol.localName]: string | null = null; public [PropertySymbol.prefix]: string | null = null; public [PropertySymbol.shadowRoot]: IShadowRoot | null = null; public [PropertySymbol.scrollHeight] = 0; @@ -266,7 +267,7 @@ export default class Element extends Node implements IElement { * @returns Local name. */ public get localName(): string { - return this[PropertySymbol.tagName] ? this[PropertySymbol.tagName].toLowerCase() : 'unknown'; + return this[PropertySymbol.localName]; } /** @@ -486,6 +487,7 @@ export default class Element extends Node implements IElement { } clone[PropertySymbol.tagName] = this[PropertySymbol.tagName]; + clone[PropertySymbol.localName] = this[PropertySymbol.localName]; clone[PropertySymbol.namespaceURI] = this[PropertySymbol.namespaceURI]; return clone; diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts index e8802cf8d..c21646380 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts @@ -10,6 +10,12 @@ import Event from '../../event/Event.js'; import HTMLElementUtility from './HTMLElementUtility.js'; import INamedNodeMap from '../../named-node-map/INamedNodeMap.js'; import HTMLElementNamedNodeMap from './HTMLElementNamedNodeMap.js'; +import INodeList from '../node/INodeList.js'; +import INode from '../node/INode.js'; +import IHTMLCollection from '../element/IHTMLCollection.js'; +import IElement from '../element/IElement.js'; +import NodeList from '../node/NodeList.js'; +import HTMLCollection from '../element/HTMLCollection.js'; /** * HTML Element. @@ -62,6 +68,7 @@ export default class HTMLElement extends Element implements IHTMLElement { // Private properties #dataset: Dataset = null; + #customElementDefineCallback: () => void = null; /** * Returns access key. @@ -473,4 +480,124 @@ export default class HTMLElement extends Element implements IHTMLElement { return clone; } + + /** + * Connects this element to another element. + * + * @see https://html.spec.whatwg.org/multipage/dom.html#htmlelement + * @param parentNode Parent node. + */ + public [PropertySymbol.connectToNode](parentNode: INode = null): void { + const localName = this[PropertySymbol.localName]; + + // This element can potentially be a custom element that has not been defined yet + // Therefore we need to register a callback for when it is defined in CustomElementRegistry and replace it with the registered element (see #404) + if ( + this.constructor === HTMLElement && + localName.includes('-') && + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ + PropertySymbol.callbacks + ] + ) { + const callbacks = + this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ + PropertySymbol.callbacks + ]; + + if (parentNode && !this.#customElementDefineCallback) { + const callback = (): void => { + if (this[PropertySymbol.parentNode]) { + const newElement = ( + this[PropertySymbol.ownerDocument].createElement(localName) + ); + (>newElement[PropertySymbol.childNodes]) = + this[PropertySymbol.childNodes]; + (>newElement[PropertySymbol.children]) = + this[PropertySymbol.children]; + (newElement[PropertySymbol.isConnected]) = this[PropertySymbol.isConnected]; + + newElement[PropertySymbol.rootNode] = this[PropertySymbol.rootNode]; + newElement[PropertySymbol.formNode] = this[PropertySymbol.formNode]; + newElement[PropertySymbol.selectNode] = this[PropertySymbol.selectNode]; + newElement[PropertySymbol.textAreaNode] = this[PropertySymbol.textAreaNode]; + newElement[PropertySymbol.observers] = this[PropertySymbol.observers]; + newElement[PropertySymbol.isValue] = this[PropertySymbol.isValue]; + + for (let i = 0, max = this[PropertySymbol.attributes].length; i < max; i++) { + newElement[PropertySymbol.attributes].setNamedItem( + this[PropertySymbol.attributes][i] + ); + } + + (>this[PropertySymbol.childNodes]) = new NodeList(); + (>this[PropertySymbol.children]) = new HTMLCollection(); + this[PropertySymbol.rootNode] = null; + this[PropertySymbol.formNode] = null; + this[PropertySymbol.selectNode] = null; + this[PropertySymbol.textAreaNode] = null; + this[PropertySymbol.observers] = []; + this[PropertySymbol.isValue] = null; + (this[PropertySymbol.attributes]) = + new HTMLElementNamedNodeMap(this); + + for ( + let i = 0, + max = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes] + .length; + i < max; + i++ + ) { + if ( + (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] === + this + ) { + (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] = + newElement; + break; + } + } + + if ((this[PropertySymbol.parentNode])[PropertySymbol.children]) { + for ( + let i = 0, + max = (this[PropertySymbol.parentNode])[PropertySymbol.children] + .length; + i < max; + i++ + ) { + if ( + (this[PropertySymbol.parentNode])[PropertySymbol.children][i] === + this + ) { + (this[PropertySymbol.parentNode])[PropertySymbol.children][i] = + newElement; + break; + } + } + } + + if (newElement[PropertySymbol.isConnected] && newElement.connectedCallback) { + newElement.connectedCallback(); + } + + this[PropertySymbol.connectToNode](null); + } + }; + callbacks[localName] = callbacks[localName] || []; + callbacks[localName].push(callback); + this.#customElementDefineCallback = callback; + } else if (!parentNode && callbacks[localName] && this.#customElementDefineCallback) { + const index = callbacks[localName].indexOf(this.#customElementDefineCallback); + if (index !== -1) { + callbacks[localName].splice(index, 1); + } + if (!callbacks[localName].length) { + delete callbacks[localName]; + } + this.#customElementDefineCallback = null; + } + } + + super[PropertySymbol.connectToNode](parentNode); + } } diff --git a/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts b/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts index f2a4b0a22..0d46acebd 100644 --- a/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts +++ b/packages/happy-dom/src/nodes/html-unknown-element/HTMLUnknownElement.ts @@ -1,13 +1,5 @@ import HTMLElement from '../html-element/HTMLElement.js'; -import * as PropertySymbol from '../../PropertySymbol.js'; -import INode from '../node/INode.js'; import IHTMLElement from '../html-element/IHTMLElement.js'; -import INodeList from '../node/INodeList.js'; -import IHTMLCollection from '../element/IHTMLCollection.js'; -import IElement from '../element/IElement.js'; -import NodeList from '../node/NodeList.js'; -import HTMLCollection from '../element/HTMLCollection.js'; -import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js'; /** * HTML Unknown Element. @@ -15,124 +7,4 @@ import HTMLElementNamedNodeMap from '../html-element/HTMLElementNamedNodeMap.js' * Reference: * https://developer.mozilla.org/en-US/docs/Web/API/HTMLUnknownElement. */ -export default class HTMLUnknownElement extends HTMLElement implements IHTMLElement { - #customElementDefineCallback: () => void = null; - - /** - * Connects this element to another element. - * - * @param parentNode Parent node. - */ - public [PropertySymbol.connectToNode](parentNode: INode = null): void { - const localName = this.localName; - - // This element can potentially be a custom element that has not been defined yet - // Therefore we need to register a callback for when it is defined in CustomElementRegistry and replace it with the registered element (see #404) - if ( - localName.includes('-') && - this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ - PropertySymbol.callbacks - ] - ) { - const callbacks = - this[PropertySymbol.ownerDocument][PropertySymbol.ownerWindow].customElements[ - PropertySymbol.callbacks - ]; - - if (parentNode && !this.#customElementDefineCallback) { - const callback = (): void => { - if (this[PropertySymbol.parentNode]) { - const newElement = ( - this[PropertySymbol.ownerDocument].createElement(this[PropertySymbol.tagName]) - ); - (>newElement[PropertySymbol.childNodes]) = - this[PropertySymbol.childNodes]; - (>newElement[PropertySymbol.children]) = - this[PropertySymbol.children]; - (newElement[PropertySymbol.isConnected]) = this[PropertySymbol.isConnected]; - - newElement[PropertySymbol.rootNode] = this[PropertySymbol.rootNode]; - newElement[PropertySymbol.formNode] = this[PropertySymbol.formNode]; - newElement[PropertySymbol.selectNode] = this[PropertySymbol.selectNode]; - newElement[PropertySymbol.textAreaNode] = this[PropertySymbol.textAreaNode]; - newElement[PropertySymbol.observers] = this[PropertySymbol.observers]; - newElement[PropertySymbol.isValue] = this[PropertySymbol.isValue]; - - for (let i = 0, max = this[PropertySymbol.attributes].length; i < max; i++) { - newElement[PropertySymbol.attributes].setNamedItem( - this[PropertySymbol.attributes][i] - ); - } - - (>this[PropertySymbol.childNodes]) = new NodeList(); - (>this[PropertySymbol.children]) = new HTMLCollection(); - this[PropertySymbol.rootNode] = null; - this[PropertySymbol.formNode] = null; - this[PropertySymbol.selectNode] = null; - this[PropertySymbol.textAreaNode] = null; - this[PropertySymbol.observers] = []; - this[PropertySymbol.isValue] = null; - (this[PropertySymbol.attributes]) = - new HTMLElementNamedNodeMap(this); - - for ( - let i = 0, - max = (this[PropertySymbol.parentNode])[PropertySymbol.childNodes] - .length; - i < max; - i++ - ) { - if ( - (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] === - this - ) { - (this[PropertySymbol.parentNode])[PropertySymbol.childNodes][i] = - newElement; - break; - } - } - - if ((this[PropertySymbol.parentNode])[PropertySymbol.children]) { - for ( - let i = 0, - max = (this[PropertySymbol.parentNode])[PropertySymbol.children] - .length; - i < max; - i++ - ) { - if ( - (this[PropertySymbol.parentNode])[PropertySymbol.children][i] === - this - ) { - (this[PropertySymbol.parentNode])[PropertySymbol.children][i] = - newElement; - break; - } - } - } - - if (newElement[PropertySymbol.isConnected] && newElement.connectedCallback) { - newElement.connectedCallback(); - } - - this[PropertySymbol.connectToNode](null); - } - }; - callbacks[localName] = callbacks[localName] || []; - callbacks[localName].push(callback); - this.#customElementDefineCallback = callback; - } else if (!parentNode && callbacks[localName] && this.#customElementDefineCallback) { - const index = callbacks[localName].indexOf(this.#customElementDefineCallback); - if (index !== -1) { - callbacks[localName].splice(index, 1); - } - if (!callbacks[localName].length) { - delete callbacks[localName]; - } - this.#customElementDefineCallback = null; - } - } - - super[PropertySymbol.connectToNode](parentNode); - } -} +export default class HTMLUnknownElement extends HTMLElement implements IHTMLElement {} diff --git a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts index dfae298c6..6de575423 100644 --- a/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts +++ b/packages/happy-dom/src/nodes/parent-node/ParentNodeUtility.ts @@ -7,6 +7,7 @@ import IHTMLCollection from '../element/IHTMLCollection.js'; import INode from '../node/INode.js'; import HTMLCollection from '../element/HTMLCollection.js'; import DocumentFragment from '../document-fragment/DocumentFragment.js'; +import NamespaceURI from '../../config/NamespaceURI.js'; /** * Parent node utility. @@ -115,7 +116,7 @@ export default class ParentNodeUtility { let matches = new HTMLCollection(); for (const child of (parentNode)[PropertySymbol.children]) { - if (includeAll || child[PropertySymbol.tagName] === upperTagName) { + if (includeAll || child[PropertySymbol.tagName].toUpperCase() === upperTagName) { matches.push(child); } matches = >( @@ -139,13 +140,14 @@ export default class ParentNodeUtility { namespaceURI: string, tagName: string ): IHTMLCollection { - const upperTagName = tagName.toUpperCase(); + // When the namespace is HTML, the tag name is case-insensitive. + const formattedTagName = namespaceURI === NamespaceURI.html ? tagName.toUpperCase() : tagName; const includeAll = tagName === '*'; let matches = new HTMLCollection(); for (const child of (parentNode)[PropertySymbol.children]) { if ( - (includeAll || child[PropertySymbol.tagName] === upperTagName) && + (includeAll || child[PropertySymbol.tagName] === formattedTagName) && child[PropertySymbol.namespaceURI] === namespaceURI ) { matches.push(child); diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts index 12d1f33e8..71ebfdc93 100644 --- a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts +++ b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts @@ -48,7 +48,7 @@ export default class SVGElement extends Element implements ISVGElement { public get ownerSVGElement(): ISVGSVGElement { let parent = this[PropertySymbol.parentNode]; while (parent) { - if (parent['tagName'] === 'SVG') { + if (parent[PropertySymbol.localName] === 'svg') { return parent; } diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts index d1e4cca56..5a5d7168b 100755 --- a/packages/happy-dom/src/xml-parser/XMLParser.ts +++ b/packages/happy-dom/src/xml-parser/XMLParser.ts @@ -1,12 +1,12 @@ import IDocument from '../nodes/document/IDocument.js'; import * as PropertySymbol from '../PropertySymbol.js'; -import VoidElements from '../config/VoidElements.js'; -import UnnestableElements from '../config/UnnestableElements.js'; +import HTMLElementVoid from '../config/HTMLElementVoid.js'; +import HTMLElementUnnestable from '../config/HTMLElementUnnestable.js'; import NamespaceURI from '../config/NamespaceURI.js'; import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement.js'; import IElement from '../nodes/element/IElement.js'; import HTMLLinkElement from '../nodes/html-link-element/HTMLLinkElement.js'; -import PlainTextElements from '../config/PlainTextElements.js'; +import HTMLElementPlainText from '../config/HTMLElementPlainText.js'; import IDocumentType from '../nodes/document-type/IDocumentType.js'; import INode from '../nodes/node/INode.js'; import IDocumentFragment from '../nodes/document-fragment/IDocumentFragment.js'; @@ -106,8 +106,8 @@ export default class XMLParser { if (match[1]) { // Start tag. - const tagName = match[1].toUpperCase(); + const localName = tagName === 'SVG' ? 'svg' : match[1]; // Some elements are not allowed to be nested (e.g. "" is not allowed.). // Therefore we need to auto-close the tag, so that it become valid (e.g. ""). @@ -115,7 +115,7 @@ export default class XMLParser { if (unnestableTagNameIndex !== -1) { unnestableTagNames.splice(unnestableTagNameIndex, 1); while (currentNode !== root) { - if ((currentNode)[PropertySymbol.tagName] === tagName) { + if ((currentNode)[PropertySymbol.tagName].toUpperCase() === tagName) { stack.pop(); currentNode = stack[stack.length - 1] || root; break; @@ -126,12 +126,12 @@ export default class XMLParser { } // NamespaceURI is inherited from the parent element. - // It should default to SVG for SVG elements. + // NamespaceURI should be SVG for SVGSVGElement. const namespaceURI = tagName === 'SVG' ? NamespaceURI.svg : (currentNode)[PropertySymbol.namespaceURI] || NamespaceURI.html; - const newElement = document.createElementNS(namespaceURI, tagName); + const newElement = document.createElementNS(namespaceURI, localName); currentNode.appendChild(newElement); currentNode = newElement; @@ -141,11 +141,14 @@ export default class XMLParser { } else if (match[2]) { // End tag. - if (match[2].toUpperCase() === (currentNode)[PropertySymbol.tagName]) { + if ( + match[2].toUpperCase() === + (currentNode)[PropertySymbol.tagName].toUpperCase() + ) { // Some elements are not allowed to be nested (e.g. "" is not allowed.). // Therefore we need to auto-close the tag, so that it become valid (e.g. ""). const unnestableTagNameIndex = unnestableTagNames.indexOf( - (currentNode)[PropertySymbol.tagName] + (currentNode)[PropertySymbol.tagName].toUpperCase() ); if (unnestableTagNameIndex !== -1) { unnestableTagNames.splice(unnestableTagNameIndex, 1); @@ -227,9 +230,12 @@ export default class XMLParser { const rawValue = attributeMatch[2] || attributeMatch[4] || attributeMatch[7] || ''; const value = rawValue ? Entities.decodeHTMLAttribute(rawValue) : ''; + + // In XML and SVG namespaces, the attribute "xmlns" should be set to the "http://www.w3.org/2000/xmlns/" namespace. const namespaceURI = - (currentNode)[PropertySymbol.tagName] === 'SVG' && name === 'xmlns' - ? value + (currentNode)[PropertySymbol.namespaceURI] === NamespaceURI.svg && + name === 'xmlns' + ? NamespaceURI.xmlns : null; (currentNode).setAttributeNS(namespaceURI, name, value); @@ -256,7 +262,7 @@ export default class XMLParser { // Self closing tags are not allowed in the HTML namespace, but the parser should still allow it for void elements. // Self closing tags is supported in the SVG namespace. if ( - VoidElements[(currentNode)[PropertySymbol.tagName]] || + HTMLElementVoid[(currentNode)[PropertySymbol.tagName]] || (match[7] && (currentNode)[PropertySymbol.namespaceURI] === NamespaceURI.svg) ) { @@ -265,7 +271,7 @@ export default class XMLParser { readState = MarkupReadStateEnum.startOrEndTag; } else { // Plain text elements such as