Repository: capricorn86/happy-dom
Pull request #638 (2022-10-25 23:35:35)

#637@patch: Fixes problem with HTMLSelectElement.selectedIndex not re…
…flecting the select attribute set on options.


Patch:
diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts
index 0e90932d..a7f3140d 100644
--- a/packages/happy-dom/src/nodes/element/Element.ts
+++ b/packages/happy-dom/src/nodes/element/Element.ts
@@ -919,8 +919,17 @@ export default class Element extends Node implements IElement {
 	 * Removes an Attr node.
 	 *
 	 * @param attribute Attribute.
+	 * @returns Removed attribute.
 	 */
-	public removeAttributeNode(attribute: IAttr): void {
+	public removeAttributeNode(attribute: IAttr): IAttr {
+		const removedAttribute = this._attributes[attribute.name];
+
+		if (removedAttribute !== attribute) {
+			throw new DOMException(
+				`Failed to execute 'removeAttributeNode' on 'Element': The node provided is owned by another element.`
+			);
+		}
+
 		delete this._attributes[attribute.name];
 
 		if (this.isConnected) {
@@ -954,15 +963,18 @@ export default class Element extends Node implements IElement {
 				}
 			}
 		}
+
+		return attribute;
 	}
 
 	/**
 	 * Removes an Attr node.
 	 *
 	 * @param attribute Attribute.
+	 * @returns Removed attribute.
 	 */
-	public removeAttributeNodeNS(attribute: IAttr): void {
-		this.removeAttributeNode(attribute);
+	public removeAttributeNodeNS(attribute: IAttr): IAttr {
+		return this.removeAttributeNode(attribute);
 	}
 
 	/**
diff --git a/packages/happy-dom/src/nodes/element/IElement.ts b/packages/happy-dom/src/nodes/element/IElement.ts
index 7649cd0b..b3fc6ff5 100644
--- a/packages/happy-dom/src/nodes/element/IElement.ts
+++ b/packages/happy-dom/src/nodes/element/IElement.ts
@@ -260,15 +260,17 @@ export default interface IElement extends IChildNode, INonDocumentTypeChildNode,
 	 * Removes an Attr node.
 	 *
 	 * @param attribute Attribute.
+	 * @returns Removed attribute.
 	 */
-	removeAttributeNode(attribute: IAttr): void;
+	removeAttributeNode(attribute: IAttr): IAttr;
 
 	/**
 	 * Removes an Attr node.
 	 *
 	 * @param attribute Attribute.
+	 * @returns Removed attribute.
 	 */
-	removeAttributeNodeNS(attribute: IAttr): void;
+	removeAttributeNodeNS(attribute: IAttr): IAttr;
 
 	/**
 	 * Clones a node.
diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts
index bb1a9387..262eb88a 100644
--- a/packages/happy-dom/src/nodes/html-element/HTMLElement.ts
+++ b/packages/happy-dom/src/nodes/html-element/HTMLElement.ts
@@ -421,11 +421,7 @@ export default class HTMLElement extends Element implements IHTMLElement {
 	}
 
 	/**
-	 * The setAttributeNode() method adds a new Attr node to the specified element.
-	 *
 	 * @override
-	 * @param attribute Attribute.
-	 * @returns Replaced attribute.
 	 */
 	public setAttributeNode(attribute: IAttr): IAttr {
 		const replacedAttribute = super.setAttributeNode(attribute);
@@ -438,25 +434,20 @@ export default class HTMLElement extends Element implements IHTMLElement {
 	}
 
 	/**
-	 * Removes an Attr node.
-	 *
 	 * @override
-	 * @param attribute Attribute.
 	 */
-	public removeAttributeNode(attribute: IAttr): void {
+	public removeAttributeNode(attribute: IAttr): IAttr {
 		super.removeAttributeNode(attribute);
 
 		if (attribute.name === 'style' && this._style) {
 			this._style.cssText = '';
 		}
+
+		return attribute;
 	}
 
 	/**
-	 * Clones a node.
-	 *
 	 * @override
-	 * @param [deep=false] "true" to clone deep.
-	 * @returns Cloned node.
 	 */
 	public cloneNode(deep = false): IHTMLElement {
 		const clone = <HTMLElement>super.cloneNode(deep);
diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts
index 0f1dbdae..a692e5e0 100644
--- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts
+++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts
@@ -1,7 +1,8 @@
+import IAttr from '../attr/IAttr';
 import HTMLElement from '../html-element/HTMLElement';
 import IHTMLElement from '../html-element/IHTMLElement';
 import IHTMLFormElement from '../html-form-element/IHTMLFormElement';
-import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement';
+import HTMLSelectElement from '../html-select-element/HTMLSelectElement';
 import IHTMLOptionElement from './IHTMLOptionElement';
 
 /**
@@ -12,6 +13,8 @@ import IHTMLOptionElement from './IHTMLOptionElement';
  */
 export default class HTMLOptionElement extends HTMLElement implements IHTMLOptionElement {
 	public _index: number;
+	public _selectedness = false;
+	public _dirtyness = false;
 
 	/**
 	 * Returns inner text, which is the rendered appearance of text.
@@ -59,20 +62,7 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio
 	 * @returns Selected.
 	 */
 	public get selected(): boolean {
-		const parentNode = <IHTMLSelectElement>this.parentNode;
-
-		if (parentNode?.tagName === 'SELECT') {
-			let index = -1;
-			for (let i = 0; i < parentNode.options.length; i++) {
-				if (parentNode.options[i] === this) {
-					index = i;
-					break;
-				}
-			}
-			return index !== -1 && parentNode.options.selectedIndex === index;
-		}
-
-		return false;
+		return this._selectedness;
 	}
 
 	/**
@@ -81,26 +71,13 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio
 	 * @param selected Selected.
 	 */
 	public set selected(selected: boolean) {
-		const parentNode = <IHTMLSelectElement>this.parentNode;
-		if (parentNode?.tagName === 'SELECT') {
-			if (selected) {
-				let index = -1;
-
-				for (let i = 0; i < parentNode.options.length; i++) {
-					if (parentNode.options[i] === this) {
-						index = i;
-						break;
-					}
-				}
-
-				if (index !== -1) {
-					parentNode.options.selectedIndex = index;
-				}
-			} else if (parentNode.options.length) {
-				parentNode.options.selectedIndex = 0;
-			} else {
-				parentNode.options.selectedIndex = -1;
-			}
+		const selectElement = this._getSelectElement();
+
+		this._dirtyness = true;
+		this._selectedness = Boolean(selected);
+
+		if (selectElement) {
+			selectElement._resetOptionSelectednes(this._selectedness ? this : null);
 		}
 	}
 
@@ -143,4 +120,61 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio
 	public set value(value: string) {
 		this.setAttributeNS(null, 'value', value);
 	}
+
+	/**
+	 * @override
+	 */
+	public setAttributeNode(attribute: IAttr): IAttr {
+		const replacedAttribute = super.setAttributeNode(attribute);
+
+		if (
+			!this._dirtyness &&
+			attribute.name === 'selected' &&
+			replacedAttribute?.value !== attribute.value
+		) {
+			const selectElement = this._getSelectElement();
+
+			this._selectedness = true;
+
+			if (selectElement) {
+				selectElement._resetOptionSelectednes(this);
+			}
+		}
+
+		return replacedAttribute;
+	}
+
+	/**
+	 * @override
+	 */
+	public removeAttributeNode(attribute: IAttr): IAttr {
+		super.removeAttributeNode(attribute);
+
+		if (!this._dirtyness && attribute.name === 'selected') {
+			const selectElement = this._getSelectElement();
+
+			this._selectedness = false;
+
+			if (selectElement) {
+				selectElement._resetOptionSelectednes();
+			}
+		}
+
+		return attribute;
+	}
+
+	/**
+	 * Returns select element.
+	 *
+	 * @returns Select element.
+	 */
+	private _getSelectElement(): HTMLSelectElement {
+		const parentNode = <HTMLSelectElement>this.parentNode;
+		if (parentNode?.tagName === 'SELECT') {
+			return <HTMLSelectElement>parentNode;
+		}
+		if ((<HTMLSelectElement>parentNode?.parentNode)?.tagName === 'SELECT') {
+			return <HTMLSelectElement>parentNode.parentNode;
+		}
+	}
 }
diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts
index 2d81159c..8226dde2 100644
--- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts
+++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts
@@ -16,7 +16,6 @@ export default class HTMLOptionsCollection
 	implements IHTMLOptionsCollection
 {
 	private _selectElement: IHTMLSelectElement;
-	private _selectedIndex = -1;
 
 	/**
 	 *
@@ -34,7 +33,7 @@ export default class HTMLOptionsCollection
 	 * @returns SelectedIndex.
 	 */
 	public get selectedIndex(): number {
-		return this._selectedIndex;
+		return this._selectElement.selectedIndex;
 	}
 
 	/**
@@ -43,13 +42,7 @@ export default class HTMLOptionsCollection
 	 * @param selectedIndex SelectedIndex.
 	 */
 	public set selectedIndex(selectedIndex: number) {
-		if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) {
-			if (selectedIndex >= 0 && selectedIndex < this.length) {
-				this._selectedIndex = selectedIndex;
-			} else {
-				this._selectedIndex = -1;
-			}
-		}
+		this._selectElement.selectedIndex = selectedIndex;
 	}
 
 	/**
diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts
index b4c0e13a..b7437746 100644
--- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts
+++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts
@@ -167,13 +167,14 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 	 * @returns Value.
 	 */
 	public get value(): string {
-		if (this.options.selectedIndex === -1) {
-			return '';
+		for (let i = 0, max = this.options.length; i < max; i++) {
+			const option = <HTMLOptionElement>this.options[i];
+			if (option._selectedness) {
+				return option.value;
+			}
 		}
 
-		const option = this.options[this.options.selectedIndex];
-
-		return option instanceof HTMLOptionElement ? option.value : '';
+		return '';
 	}
 
 	/**
@@ -182,9 +183,15 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 	 * @param value Value.
 	 */
 	public set value(value: string) {
-		this.options.selectedIndex = this.options.findIndex(
-			(o) => o instanceof HTMLOptionElement && o.value === value
-		);
+		for (let i = 0, max = this.options.length; i < max; i++) {
+			const option = <HTMLOptionElement>this.options[i];
+			if (option.value === value) {
+				option._selectedness = true;
+				option._dirtyness = true;
+			} else {
+				option._selectedness = false;
+			}
+		}
 	}
 
 	/**
@@ -193,16 +200,31 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 	 * @returns Value.
 	 */
 	public get selectedIndex(): number {
-		return this.options.selectedIndex;
+		for (let i = 0, max = this.options.length; i < max; i++) {
+			if ((<HTMLOptionElement>this.options[i])._selectedness) {
+				return i;
+			}
+		}
+		return -1;
 	}
 
 	/**
 	 * Sets value.
 	 *
-	 * @param value Value.
+	 * @param selectedIndex Selected index.
 	 */
-	public set selectedIndex(value: number) {
-		this.options.selectedIndex = value;
+	public set selectedIndex(selectedIndex: number) {
+		if (typeof selectedIndex === 'number' && !isNaN(selectedIndex)) {
+			for (let i = 0, max = this.options.length; i < max; i++) {
+				(<HTMLOptionElement>this.options[i])._selectedness = false;
+			}
+
+			const selectedOption = <HTMLOptionElement>this.options[selectedIndex];
+			if (selectedOption) {
+				selectedOption._selectedness = true;
+				selectedOption._dirtyness = true;
+			}
+		}
 	}
 
 	/**
@@ -287,13 +309,10 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 
 			if (element.tagName === 'OPTION' || element.tagName === 'OPTGROUP') {
 				this.options.push(<IHTMLOptionElement | IHTMLOptGroupElement>element);
-
-				if (this.options.length === 1) {
-					this.options.selectedIndex = 0;
-				}
 			}
 
 			this._updateIndexProperties(previousLength, this.options.length);
+			this._resetOptionSelectednes();
 		}
 
 		return super.appendChild(node);
@@ -332,15 +351,15 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 				} else {
 					this.options.push(<IHTMLOptionElement | IHTMLOptGroupElement>newElement);
 				}
-
-				if (this.options.length === 1) {
-					this.options.selectedIndex = 0;
-				}
 			}
 
 			this._updateIndexProperties(previousLength, this.options.length);
 		}
 
+		if (newNode.nodeType === NodeTypeEnum.elementNode) {
+			this._resetOptionSelectednes();
+		}
+
 		return returnValue;
 	}
 
@@ -358,20 +377,83 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 				if (index !== -1) {
 					this.options.splice(index, 1);
 				}
+			}
 
-				if (this.options.selectedIndex >= this.options.length) {
-					this.options.selectedIndex = this.options.length - 1;
+			this._updateIndexProperties(previousLength, this.options.length);
+			this._resetOptionSelectednes();
+		}
+
+		return super.removeChild(node);
+	}
+
+	/**
+	 * Resets the option selectedness.
+	 *
+	 * Based on:
+	 * https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/nodes/HTMLSelectElement-impl.js
+	 *
+	 * @param [newOption] Optional new option element to be selected.
+	 * @see https://html.spec.whatwg.org/multipage/form-elements.html#selectedness-setting-algorithm
+	 */
+	public _resetOptionSelectednes(newOption?: IHTMLOptionElement): void {
+		if (this.hasAttributeNS(null, 'multiple')) {
+			return;
+		}
+
+		const selected: HTMLOptionElement[] = [];
+
+		for (let i = 0, max = this.options.length; i < max; i++) {
+			if (newOption) {
+				(<HTMLOptionElement>this.options[i])._selectedness = this.options[i] === newOption;
+			}
+
+			if ((<HTMLOptionElement>this.options[i])._selectedness) {
+				selected.push(<HTMLOptionElement>this.options[i]);
+			}
+		}
+
+		const size = this._getDisplaySize();
+
+		if (size === 1 && !selected.length) {
+			for (let i = 0, max = this.options.length; i < max; i++) {
+				const option = <HTMLOptionElement>this.options[i];
+
+				let disabled = option.hasAttributeNS(null, 'disabled');
+				const parentNode = <IHTMLElement>option.parentNode;
+				if (
+					parentNode &&
+					parentNode.nodeType === NodeTypeEnum.elementNode &&
+					parentNode.tagName === 'OPTGROUP' &&
+					parentNode.hasAttributeNS(null, 'disabled')
+				) {
+					disabled = true;
 				}
 
-				if (!this.options.length) {
-					this.options.selectedIndex = -1;
+				if (!disabled) {
+					option._selectedness = true;
+					break;
 				}
 			}
-
-			this._updateIndexProperties(previousLength, this.options.length);
+		} else if (selected.length >= 2) {
+			for (let i = 0, max = this.options.length; i < max; i++) {
+				(<HTMLOptionElement>this.options[i])._selectedness = i === selected.length - 1;
+			}
 		}
+	}
 
-		return super.removeChild(node);
+	/**
+	 * Returns display size.
+	 *
+	 * @returns Display size.
+	 */
+	protected _getDisplaySize(): number {
+		if (this.hasAttributeNS(null, 'size')) {
+			const size = parseInt(this.getAttributeNS(null, 'size'));
+			if (!isNaN(size) && size >= 0) {
+				return size;
+			}
+		}
+		return this.hasAttributeNS(null, 'multiple') ? 4 : 1;
 	}
 
 	/**
diff --git a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts
index 12136ecb..664e0c10 100644
--- a/packages/happy-dom/src/nodes/svg-element/SVGElement.ts
+++ b/packages/happy-dom/src/nodes/svg-element/SVGElement.ts
@@ -74,11 +74,7 @@ export default class SVGElement extends Element implements ISVGElement {
 	}
 
 	/**
-	 * The setAttributeNode() method adds a new Attr node to the specified element.
-	 *
 	 * @override
-	 * @param attribute Attribute.
-	 * @returns Replaced attribute.
 	 */
 	public setAttributeNode(attribute: IAttr): IAttr {
 		const replacedAttribute = super.setAttributeNode(attribute);
@@ -91,16 +87,15 @@ export default class SVGElement extends Element implements ISVGElement {
 	}
 
 	/**
-	 * Removes an Attr node.
-	 *
 	 * @override
-	 * @param attribute Attribute.
 	 */
-	public removeAttributeNode(attribute: IAttr): void {
+	public removeAttributeNode(attribute: IAttr): IAttr {
 		super.removeAttributeNode(attribute);
 
 		if (attribute.name === 'style' && this._style) {
 			this._style.cssText = '';
 		}
+
+		return attribute;
 	}
 }


Test patch:
diff --git a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts
index f7705efd..d86ebc6b 100644
--- a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts
+++ b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts
@@ -137,6 +137,26 @@ describe('HTMLSelectElement', () => {
 			element.options.selectedIndex = 1;
 			expect(element.selectedIndex).toBe(1);
 		});
+
+		it('Returns option with "selected" attribute is defined.', () => {
+			const option1 = document.createElement('option');
+			const option2 = document.createElement('option');
+
+			option2.setAttribute('selected', '');
+
+			element.appendChild(option1);
+			element.appendChild(option2);
+
+			expect(element.selectedIndex).toBe(1);
+
+			option1.setAttribute('selected', '');
+
+			expect(element.selectedIndex).toBe(0);
+
+			option2.removeAttribute('selected');
+
+			expect(element.selectedIndex).toBe(0);
+		});
 	});
 
 	describe(`set selectedIndex()`, () => {