Skip to content

Commit

Permalink
fix: [#1728] Fixes incorrect handling of attribute prefixes when iter…
Browse files Browse the repository at this point in the history
…ating NamedNodeMap (#1736)
  • Loading branch information
capricorn86 authored Feb 22, 2025
1 parent 82efdbc commit c929243
Show file tree
Hide file tree
Showing 16 changed files with 275 additions and 97 deletions.
4 changes: 2 additions & 2 deletions packages/happy-dom/src/PropertySymbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const isInPassiveEventListener = Symbol('isInPassiveEventListener');
export const isValue = Symbol('isValue');
export const listenerOptions = Symbol('listenerOptions');
export const listeners = Symbol('listeners');
export const namedItems = Symbol('namedItems');
export const itemsByName = Symbol('itemsByName');
export const nextActiveElement = Symbol('nextActiveElement');
export const observeMutations = Symbol('observeMutations');
export const mutationListeners = Symbol('mutationListeners');
Expand Down Expand Up @@ -180,7 +180,7 @@ export const getFormControlNamedItem = Symbol('getFormControlNamedItem');
export const dataset = Symbol('dataset');
export const getNamespaceItemKey = Symbol('getNamespaceItemKey');
export const getNamedItemKey = Symbol('getNamedItemKey');
export const namespaceItems = Symbol('namespaceItems');
export const itemsByNamespaceURI = Symbol('itemsByNamespaceURI');
export const proxy = Symbol('proxy');
export const setNamedItem = Symbol('setNamedItem');
export const getTokenList = Symbol('getTokenList');
Expand Down
2 changes: 1 addition & 1 deletion packages/happy-dom/src/dom/DOMStringMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default class DOMStringMap {
// "The result List must contain the keys of all non-configurable own properties of the target object."
const keys = [];
for (const items of element[PropertySymbol.attributes][
PropertySymbol.namedItems
PropertySymbol.itemsByName
].values()) {
if (items[0][PropertySymbol.name].startsWith('data-')) {
keys.push(
Expand Down
3 changes: 2 additions & 1 deletion packages/happy-dom/src/exception/DOMExceptionNameEnum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum DOMExceptionNameEnum {
timeoutError = 'TimeoutError',
encodingError = 'EncodingError',
uriMismatchError = 'URIMismatchError',
inUseAttributeError = 'InUseAttributeError'
inUseAttributeError = 'InUseAttributeError',
namespaceError = 'NamespaceError'
}
export default DOMExceptionNameEnum;
10 changes: 5 additions & 5 deletions packages/happy-dom/src/html-serializer/HTMLSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,18 @@ export default class HTMLSerializer {
private getAttributes(element: Element): string {
let attributeString = '';

const namedItems = (<Element>element)[PropertySymbol.attributes][PropertySymbol.namedItems];
const attributes = (<Element>element)[PropertySymbol.attributes][PropertySymbol.items];

if (!namedItems.has('is') && element[PropertySymbol.isValue]) {
if (!attributes.has(':is') && element[PropertySymbol.isValue]) {
attributeString +=
' is="' + XMLEncodeUtility.encodeHTMLAttributeValue(element[PropertySymbol.isValue]) + '"';
}

for (const attributes of namedItems.values()) {
for (const attribute of attributes.values()) {
const escapedValue = XMLEncodeUtility.encodeHTMLAttributeValue(
attributes[0][PropertySymbol.value]
attribute[PropertySymbol.value]
);
attributeString += ' ' + attributes[0][PropertySymbol.name] + '="' + escapedValue + '"';
attributeString += ' ' + attribute[PropertySymbol.name] + '="' + escapedValue + '"';
}

return attributeString;
Expand Down
23 changes: 22 additions & 1 deletion packages/happy-dom/src/nodes/document/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import SVGElementConfig from '../../config/SVGElementConfig.js';
import StringUtility from '../../utilities/StringUtility.js';
import HTMLParser from '../../html-parser/HTMLParser.js';
import PreloadEntry from '../../fetch/preload/PreloadEntry.js';
import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js';

const PROCESSING_INSTRUCTION_TARGET_REGEXP = /^[a-z][a-z0-9-]+$/;

Expand Down Expand Up @@ -1222,7 +1223,17 @@ export default class Document extends Node {
* @returns Attribute.
*/
public createAttribute(qualifiedName: string): Attr {
return this.createAttributeNS(null, StringUtility.asciiLowerCase(qualifiedName));
// We should use the NodeFactory and not the class constructor, so that owner document will be this document
const attribute = NodeFactory.createNode(this, this[PropertySymbol.window].Attr);

const name = StringUtility.asciiLowerCase(qualifiedName);
const parts = name.split(':');

attribute[PropertySymbol.name] = name;
attribute[PropertySymbol.localName] = parts[1] ?? name;
attribute[PropertySymbol.prefix] = parts[1] ? parts[0] : null;

return attribute;
}

/**
Expand All @@ -1237,11 +1248,21 @@ export default class Document extends Node {
const attribute = NodeFactory.createNode(this, this[PropertySymbol.window].Attr);

const parts = qualifiedName.split(':');

attribute[PropertySymbol.namespaceURI] = namespaceURI;
attribute[PropertySymbol.name] = qualifiedName;
attribute[PropertySymbol.localName] = parts[1] ?? qualifiedName;
attribute[PropertySymbol.prefix] = parts[1] ? parts[0] : null;

if (!namespaceURI && attribute[PropertySymbol.prefix]) {
throw new this[PropertySymbol.window].DOMException(
`Failed to execute 'createAttributeNS' on 'Document': The namespace URI provided ('${
namespaceURI || ''
}') is not valid for the qualified name provided ('${qualifiedName}').`,
DOMExceptionNameEnum.namespaceError
);
}

return attribute;
}

Expand Down
25 changes: 20 additions & 5 deletions packages/happy-dom/src/nodes/element/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import HTMLSerializer from '../../html-serializer/HTMLSerializer.js';
import HTMLParser from '../../html-parser/HTMLParser.js';
import IScrollToOptions from '../../window/IScrollToOptions.js';
import { AttributeUtility } from '../../utilities/AttributeUtility.js';
import DOMExceptionNameEnum from '../../exception/DOMExceptionNameEnum.js';

type InsertAdjacentPosition = 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend';

Expand Down Expand Up @@ -545,9 +546,17 @@ export default class Element
clone[PropertySymbol.shadowRoot][PropertySymbol.host] = clone;
}

for (const item of this[PropertySymbol.attributes][PropertySymbol.namespaceItems].values()) {
clone[PropertySymbol.attributes].setNamedItem(item.cloneNode());
}
clone[PropertySymbol.attributes][PropertySymbol.itemsByNamespaceURI] = new Map(
this[PropertySymbol.attributes][PropertySymbol.itemsByNamespaceURI]
);

clone[PropertySymbol.attributes][PropertySymbol.itemsByName] = new Map(
this[PropertySymbol.attributes][PropertySymbol.itemsByName]
);

clone[PropertySymbol.attributes][PropertySymbol.items] = new Map(
this[PropertySymbol.attributes][PropertySymbol.items]
);

return <Element>clone;
}
Expand Down Expand Up @@ -704,6 +713,12 @@ export default class Element
*/
public setAttributeNS(namespaceURI: string, name: string, value: string): void {
const attribute = this[PropertySymbol.ownerDocument].createAttributeNS(namespaceURI, name);
if (!namespaceURI && attribute[PropertySymbol.prefix]) {
throw new this[PropertySymbol.window].DOMException(
`Failed to execute 'setAttributeNS' on 'Element': '' is an invalid namespace for attributes.`,
DOMExceptionNameEnum.namespaceError
);
}
attribute[PropertySymbol.value] = String(value);
this[PropertySymbol.attributes].setNamedItemNS(attribute);
}
Expand All @@ -715,7 +730,7 @@ export default class Element
*/
public getAttributeNames(): string[] {
const names = [];
for (const item of this[PropertySymbol.attributes][PropertySymbol.namespaceItems].values()) {
for (const item of this[PropertySymbol.attributes][PropertySymbol.items].values()) {
names.push(item[PropertySymbol.name]);
}
return names;
Expand Down Expand Up @@ -799,7 +814,7 @@ export default class Element
* @returns "true" if the element has attributes.
*/
public hasAttributes(): boolean {
return this[PropertySymbol.attributes][PropertySymbol.namespaceItems].size > 0;
return this[PropertySymbol.attributes][PropertySymbol.items].size > 0;
}

/**
Expand Down
69 changes: 42 additions & 27 deletions packages/happy-dom/src/nodes/element/NamedNodeMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import StringUtility from '../../utilities/StringUtility.js';
export default class NamedNodeMap {
[index: number]: Attr;

// All items with the namespaceURI as prefix
public [PropertySymbol.namespaceItems]: Map<string, Attr> = new Map();
// Items by attribute namespaceURI
public [PropertySymbol.itemsByNamespaceURI]: Map<string, Attr> = new Map();

// Items without namespaceURI as prefix, where the HTML namespace is the default namespace
public [PropertySymbol.namedItems]: Map<string, Attr[]> = new Map();
// Items by attribute name
public [PropertySymbol.itemsByName]: Map<string, Attr[]> = new Map();

// All items
public [PropertySymbol.items]: Map<string, Attr> = new Map();

public declare [PropertySymbol.ownerElement]: Element;

Expand All @@ -37,7 +40,7 @@ export default class NamedNodeMap {
* @returns Length.
*/
public get length(): number {
return this[PropertySymbol.namespaceItems].size;
return this[PropertySymbol.items].size;
}

/**
Expand All @@ -64,7 +67,7 @@ export default class NamedNodeMap {
* @returns Iterator.
*/
public [Symbol.iterator](): IterableIterator<Attr> {
return this[PropertySymbol.namespaceItems].values();
return this[PropertySymbol.items].values();
}

/**
Expand All @@ -73,7 +76,7 @@ export default class NamedNodeMap {
* @param index Index.
*/
public item(index: number): Attr | null {
const items = Array.from(this[PropertySymbol.namespaceItems].values());
const items = Array.from(this[PropertySymbol.items].values());
return index >= 0 && items[index] ? items[index] : null;
}

Expand All @@ -91,9 +94,9 @@ export default class NamedNodeMap {
PropertySymbol.contentType
] === 'text/html'
) {
return this[PropertySymbol.namedItems].get(StringUtility.asciiLowerCase(name))?.[0] || null;
return this[PropertySymbol.itemsByName].get(StringUtility.asciiLowerCase(name))?.[0] || null;
}
return this[PropertySymbol.namedItems].get(name)?.[0] || null;
return this[PropertySymbol.itemsByName].get(name)?.[0] || null;
}

/**
Expand All @@ -104,11 +107,17 @@ export default class NamedNodeMap {
* @returns Item.
*/
public getNamedItemNS(namespace: string, localName: string): Attr | null {
if (namespace === '') {
namespace = null;
const item = this[PropertySymbol.itemsByNamespaceURI].get(`${namespace || ''}:${localName}`);

// It seems like an item cant have a prefix without a namespaceURI
// E.g. element.setAttribute('ns1:key', 'value1');
// expect(element.attributes.getNamedItemNS(null, 'key')).toBeNull();

if (item && (!item[PropertySymbol.prefix] || item[PropertySymbol.namespaceURI])) {
return item;
}

return this[PropertySymbol.namespaceItems].get(`${namespace || ''}:${localName}`) || null;
return null;
}

/**
Expand Down Expand Up @@ -199,26 +208,29 @@ export default class NamedNodeMap {
const replacedItem =
this.getNamedItemNS(item[PropertySymbol.namespaceURI], item[PropertySymbol.localName]) ||
null;

const namedItems = this[PropertySymbol.namedItems].get(item[PropertySymbol.name]);
const itemsByName = this[PropertySymbol.itemsByName].get(item[PropertySymbol.name]);

if (replacedItem === item) {
return item;
}

this[PropertySymbol.namespaceItems].set(
this[PropertySymbol.itemsByNamespaceURI].set(
`${item[PropertySymbol.namespaceURI] || ''}:${item[PropertySymbol.localName]}`,
item
);
this[PropertySymbol.items].set(
`${item[PropertySymbol.namespaceURI] || ''}:${item[PropertySymbol.name]}`,
item
);

if (!namedItems?.length) {
this[PropertySymbol.namedItems].set(item[PropertySymbol.name], [item]);
if (!itemsByName?.length) {
this[PropertySymbol.itemsByName].set(item[PropertySymbol.name], [item]);
} else {
const index = namedItems.indexOf(replacedItem);
const index = itemsByName.indexOf(replacedItem);
if (index !== -1) {
namedItems.splice(index, 1);
itemsByName.splice(index, 1);
}
namedItems.push(item);
itemsByName.push(item);
}

if (!ignoreListeners) {
Expand All @@ -237,19 +249,22 @@ export default class NamedNodeMap {
public [PropertySymbol.removeNamedItem](item: Attr, ignoreListeners = false): void {
item[PropertySymbol.ownerElement] = null;

this[PropertySymbol.namespaceItems].delete(
this[PropertySymbol.itemsByNamespaceURI].delete(
`${item[PropertySymbol.namespaceURI] || ''}:${item[PropertySymbol.localName]}`
);
this[PropertySymbol.items].delete(
`${item[PropertySymbol.namespaceURI] || ''}:${item[PropertySymbol.name]}`
);

const namedItems = this[PropertySymbol.namedItems].get(item[PropertySymbol.name]);
const itemsByName = this[PropertySymbol.itemsByName].get(item[PropertySymbol.name]);

if (namedItems?.length) {
const index = namedItems.indexOf(item);
if (itemsByName?.length) {
const index = itemsByName.indexOf(item);
if (index !== -1) {
namedItems.splice(index, 1);
itemsByName.splice(index, 1);
}
if (!namedItems.length) {
this[PropertySymbol.namedItems].delete(item[PropertySymbol.name]);
if (!itemsByName.length) {
this[PropertySymbol.itemsByName].delete(item[PropertySymbol.name]);
}
}

Expand Down
39 changes: 22 additions & 17 deletions packages/happy-dom/src/nodes/element/NamedNodeMapProxyFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ export default class NamedNodeMapProxyFactory {
return new Proxy<NamedNodeMap>(namedNodeMap, {
get: (target, property) => {
if (property === 'length') {
return namedNodeMap[PropertySymbol.namedItems].size;
return namedNodeMap[PropertySymbol.items].size;
}
if (property in target || typeof property === 'symbol') {
methodBinder.bind(property);
return target[property];
}
const index = Number(property);
if (!isNaN(index)) {
return Array.from(namedNodeMap[PropertySymbol.namedItems].values())[index]?.[0];
return target.item(index);
}
return target.getNamedItem(<string>property) || undefined;
},
Expand Down Expand Up @@ -57,8 +57,8 @@ export default class NamedNodeMapProxyFactory {
return true;
},
ownKeys(): string[] {
const keys = Array.from(namedNodeMap[PropertySymbol.namedItems].keys());
for (let i = 0, max = namedNodeMap[PropertySymbol.namedItems].size; i < max; i++) {
const keys = Array.from(namedNodeMap[PropertySymbol.items].keys());
for (let i = 0, max = namedNodeMap[PropertySymbol.items].size; i < max; i++) {
keys.push(String(i));
}
return keys;
Expand All @@ -68,13 +68,13 @@ export default class NamedNodeMapProxyFactory {
return false;
}

if (property in target || namedNodeMap[PropertySymbol.namedItems].has(property)) {
if (property in target || namedNodeMap[PropertySymbol.items].has(property)) {
return true;
}

const index = Number(property);

if (!isNaN(index) && index >= 0 && index < namedNodeMap[PropertySymbol.namedItems].size) {
if (!isNaN(index) && index >= 0 && index < namedNodeMap[PropertySymbol.items].size) {
return true;
}

Expand All @@ -96,21 +96,26 @@ export default class NamedNodeMapProxyFactory {
}

const index = Number(property);

if (!isNaN(index) && index >= 0 && index < namedNodeMap[PropertySymbol.namedItems].size) {
return {
value: Array.from(namedNodeMap[PropertySymbol.namedItems].values())[index][0],
writable: false,
enumerable: true,
configurable: true
};
if (!isNaN(index)) {
if (index >= 0) {
const itemByIndex = target.item(index);
if (itemByIndex) {
return {
value: itemByIndex,
writable: false,
enumerable: true,
configurable: true
};
}
}
return;
}

const namedItems = namedNodeMap[PropertySymbol.namedItems].get(<string>property);
const items = namedNodeMap[PropertySymbol.items].get(<string>property);

if (namedItems) {
if (items) {
return {
value: namedItems[0],
value: items,
writable: false,
enumerable: true,
configurable: true
Expand Down
Loading

0 comments on commit c929243

Please sign in to comment.