From ee48d5290023a22de7632d7e652438514a1c1cfb Mon Sep 17 00:00:00 2001 From: Olavo Santos Date: Wed, 2 Sep 2026 12:48:37 -0500 Subject: [PATCH] Distinguish DOM mutation and selector errors --- .changeset/distinguish-dom-errors.md | 5 + packages/polyfill/source/ChildNode.ts | 16 +- .../polyfill/source/CustomElementRegistry.ts | 8 +- packages/polyfill/source/Document.ts | 7 +- packages/polyfill/source/EventTarget.ts | 15 +- packages/polyfill/source/NamedNodeMap.ts | 6 +- packages/polyfill/source/ParentNode.ts | 55 +- packages/polyfill/source/dom-exception.ts | 14 + packages/polyfill/source/names.ts | 11 +- packages/polyfill/source/selectors.ts | 117 +++-- packages/polyfill/source/shared.ts | 16 +- .../dom-mutation-selector-errors.test.ts | 484 ++++++++++++++++++ .../source/tests/named-node-map.test.ts | 1 + .../polyfill/source/tests/selectors.test.ts | 37 +- 14 files changed, 678 insertions(+), 114 deletions(-) create mode 100644 .changeset/distinguish-dom-errors.md create mode 100644 packages/polyfill/source/dom-exception.ts create mode 100644 packages/polyfill/source/tests/dom-mutation-selector-errors.test.ts diff --git a/.changeset/distinguish-dom-errors.md b/.changeset/distinguish-dom-errors.md new file mode 100644 index 00000000..92723c65 --- /dev/null +++ b/.changeset/distinguish-dom-errors.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Throw named DOM errors for invalid tree mutations and selector syntax. diff --git a/packages/polyfill/source/ChildNode.ts b/packages/polyfill/source/ChildNode.ts index 2095a1fd..309b256d 100644 --- a/packages/polyfill/source/ChildNode.ts +++ b/packages/polyfill/source/ChildNode.ts @@ -1,6 +1,7 @@ import {HOST, NEXT, PARENT, PREV} from './constants.ts'; import {performWithCustomElementReactions} from './custom-element-reactions.ts'; import {performHookEffects, type HookEffect} from './hook-effects.ts'; +import {createDOMException} from './dom-exception.ts'; import type {ParentNode} from './ParentNode.ts'; import {Node} from './Node.ts'; @@ -21,7 +22,7 @@ export class ChildNode extends Node { if (!parent) return; return performChildNodeMutation((hookEffects) => { - validateNodesForInsertion(parent, staged); + validateInsertionNodes(parent, staged); let next = this[NEXT]; while (next && staged.includes(next)) next = next[NEXT]; @@ -41,7 +42,7 @@ export class ChildNode extends Node { if (!parent) return; return performChildNodeMutation((hookEffects) => { - validateNodesForInsertion(parent, staged); + validateInsertionNodes(parent, staged); let previous = this[PREV]; while (previous && staged.includes(previous)) previous = previous[PREV]; @@ -61,7 +62,7 @@ export class ChildNode extends Node { if (!parent) return; return performChildNodeMutation((hookEffects) => { - validateNodesForInsertion(parent, staged); + validateInsertionNodes(parent, staged); let next = this[NEXT]; while (next && staged.includes(next)) next = next[NEXT]; @@ -89,7 +90,7 @@ function performChildNodeMutation( }); } -function stageNodes(nodes: (Node | string)[]) { +export function stageNodes(nodes: (Node | string)[]) { return nodes.map((node) => (node instanceof Node ? node : String(node))); } @@ -99,7 +100,7 @@ export function toNode(parent: ParentNode, node: Node | any) { return ownerDocument.createTextNode(String(node)); } -function validateNodesForInsertion( +export function validateInsertionNodes( parent: ParentNode, nodes: (Node | string)[], ) { @@ -109,8 +110,9 @@ function validateNodesForInsertion( let ancestor: Node | null = parent; while (ancestor) { if (ancestor === node) { - throw Error( - 'cannot insert a node into itself or one of its descendants', + throw createDOMException( + 'Cannot insert a node into itself or one of its descendants', + 'HierarchyRequestError', ); } ancestor = ancestor[PARENT] ?? ancestor[HOST]; diff --git a/packages/polyfill/source/CustomElementRegistry.ts b/packages/polyfill/source/CustomElementRegistry.ts index 6be31e93..d6075f54 100644 --- a/packages/polyfill/source/CustomElementRegistry.ts +++ b/packages/polyfill/source/CustomElementRegistry.ts @@ -1,3 +1,5 @@ +import {createDOMException} from './dom-exception.ts'; + const VALID_CUSTOM_ELEMENT_NAME = /^[a-z][^A-Z\u0000\t\n\f\r />]*-[^A-Z\u0000\t\n\f\r />]*$/u; @@ -20,7 +22,7 @@ function isValidCustomElementName(name: string) { } function createInvalidCustomElementNameError(name: string) { - return new DOMException( + return createDOMException( `Invalid custom element name: "${name}"`, 'SyntaxError', ); @@ -45,14 +47,14 @@ export class CustomElementRegistryImplementation } if (this.registry.has(name)) { - throw new DOMException( + throw createDOMException( `A custom element named "${name}" has already been defined`, 'NotSupportedError', ); } if (this.getName(Constructor) != null) { - throw new DOMException( + throw createDOMException( 'This constructor has already been registered in this custom element registry', 'NotSupportedError', ); diff --git a/packages/polyfill/source/Document.ts b/packages/polyfill/source/Document.ts index 5d318dbd..39034a56 100644 --- a/packages/polyfill/source/Document.ts +++ b/packages/polyfill/source/Document.ts @@ -31,7 +31,6 @@ import { adoptNodes, cloneNode, collectAdoptionSnapshot, - createNotSupportedError, getElementById as findElementById, getElementsByClassName as findElementsByClassName, getElementsByTagName as findElementsByTagName, @@ -40,6 +39,7 @@ import {HTMLBodyElement} from './HTMLBodyElement.ts'; import {HTMLHeadElement} from './HTMLHeadElement.ts'; import {HTMLHtmlElement} from './HTMLHtmlElement.ts'; import {performWithCustomElementReactions} from './custom-element-reactions.ts'; +import {createDOMException} from './dom-exception.ts'; export class Document extends ParentNode { nodeType: NodeType = NODE_TYPE_DOCUMENT; @@ -133,7 +133,10 @@ export class Document extends ParentNode { importNode(node: Node, deep?: boolean) { if (node.nodeType === NODE_TYPE_DOCUMENT) { - throw createNotSupportedError('Cannot import a document node'); + throw createDOMException( + 'Cannot import a document node', + 'NotSupportedError', + ); } return cloneNode(node, deep, this); diff --git a/packages/polyfill/source/EventTarget.ts b/packages/polyfill/source/EventTarget.ts index 2c5b6470..38d5ef12 100644 --- a/packages/polyfill/source/EventTarget.ts +++ b/packages/polyfill/source/EventTarget.ts @@ -6,6 +6,7 @@ import { OWNER_DOCUMENT, STOP_IMMEDIATE_PROPAGATION, } from './constants.ts'; +import {createDOMException} from './dom-exception.ts'; import { EVENT_PHASE_NONE, EVENT_PHASE_BUBBLING, @@ -165,8 +166,9 @@ export class EventTarget { dispatchEvent(event: Event) { if (event[DISPATCHING]) { - throw createInvalidStateError( + throw createDOMException( `Failed to execute 'dispatchEvent' on 'EventTarget': The event is already being dispatched.`, + 'InvalidStateError', ); } @@ -280,14 +282,3 @@ function removeListenerRegistration( registration.capture, ); } - -function createInvalidStateError(message: string) { - const DOMExceptionConstructor = globalThis.DOMException; - if (typeof DOMExceptionConstructor === 'function') { - return new DOMExceptionConstructor(message, 'InvalidStateError'); - } - - const error = new Error(message); - error.name = 'InvalidStateError'; - return error; -} diff --git a/packages/polyfill/source/NamedNodeMap.ts b/packages/polyfill/source/NamedNodeMap.ts index ab3cdeaf..3cba4bec 100644 --- a/packages/polyfill/source/NamedNodeMap.ts +++ b/packages/polyfill/source/NamedNodeMap.ts @@ -18,6 +18,7 @@ import { } from './MutationObserver.ts'; import {performWithCustomElementReactions} from './custom-element-reactions.ts'; import {enqueueAttributeReaction} from './attribute-reactions.ts'; +import {createDOMException} from './dom-exception.ts'; export class NamedNodeMap { [CHILD]: Attr | null = null; @@ -155,11 +156,10 @@ export class NamedNodeMap { const currentOwner = attr[OWNER_ELEMENT]; if (currentOwner != null && currentOwner !== ownerElement) { - const error = new Error( + throw createDOMException( 'The attribute is already in use by another element.', + 'InUseAttributeError', ); - error.name = 'InUseAttributeError'; - throw error; } let old = null; diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index e58304e7..ea78fd96 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -3,7 +3,6 @@ import { NEXT, PREV, PARENT, - HOST, OWNER_DOCUMENT, NODE_TYPE_DOCUMENT_FRAGMENT, NODE_TYPE_ELEMENT, @@ -17,7 +16,9 @@ import { INSERT_NODE, PREFLIGHT_INSERTIONS, REPLACE_NODE, + stageNodes, toNode, + validateInsertionNodes, } from './ChildNode.ts'; import {NodeList} from './NodeList.ts'; import {querySelectorAll, querySelector} from './selectors.ts'; @@ -41,6 +42,7 @@ import { performHookEffects, type HookEffect, } from './hook-effects.ts'; +import {createDOMException} from './dom-exception.ts'; interface PreparedInsertionRoot { node: Node; @@ -76,8 +78,9 @@ export class ParentNode extends ChildNode { append(...nodes: (Node | string)[]) { return performWithCustomElementReactions(() => { - for (const child of nodes) { - if (child == null) continue; + const staged = stageNodes(nodes.filter((node) => node != null)); + if (staged.length > 1) validateInsertionNodes(this, staged); + for (const child of staged) { this[INSERT_NODE](toNode(this, child), null); } }); @@ -85,9 +88,10 @@ export class ParentNode extends ChildNode { prepend(...nodes: (Node | string)[]) { return performWithCustomElementReactions(() => { + const staged = stageNodes(nodes.filter((node) => node != null)); + if (staged.length > 1) validateInsertionNodes(this, staged); const before = this.firstChild; - for (const child of nodes) { - if (child == null) continue; + for (const child of staged) { this[INSERT_NODE](toNode(this, child), before); } }); @@ -95,13 +99,15 @@ export class ParentNode extends ChildNode { replaceChildren(...nodes: (Node | string)[]) { return performWithCustomElementReactions(() => { + const staged = stageNodes(nodes.filter((node) => node != null)); + validateInsertionNodes(this, staged); + let child; while ((child = this.firstChild)) { this.removeChildImmediately(child); } - for (const child of nodes) { - if (child == null) continue; - this[INSERT_NODE](toNode(this, child), null); + for (const node of staged) { + this[INSERT_NODE](toNode(this, node), null); } }); } @@ -114,8 +120,12 @@ export class ParentNode extends ChildNode { } private removeChildImmediately(child: Node) { - if (child.parentNode !== this) throw Error(`not a child of this node`); - + if (child.parentNode !== this) { + throw createDOMException( + 'The node is not a child of this node', + 'NotFoundError', + ); + } const disconnectedNodes = this[IS_CONNECTED] ? selfAndDescendants(child) : undefined; @@ -142,14 +152,16 @@ export class ParentNode extends ChildNode { } [REPLACE_NODE](newChild: Node, oldChild: Node, hookEffects?: HookEffect[]) { + validateInsertionNodes(this, [newChild]); if (oldChild.parentNode !== this) { - throw Error('reference node is not a child of this parent'); + throw createDOMException( + 'The reference node is not a child of this parent', + 'NotFoundError', + ); } const previous = oldChild[PREV]; const next = oldChild[NEXT]; - this.validateInsertion(newChild, next); - const insertion = this.prepareInsertion(newChild); const removedNodes = this[IS_CONNECTED] ? selfAndDescendants(oldChild) @@ -217,18 +229,13 @@ export class ParentNode extends ChildNode { } private validateInsertion(child: Node, before: Node | null) { - if (before && before.parentNode !== this) { - throw Error('reference node is not a child of this parent'); - } + validateInsertionNodes(this, [child]); - let ancestor: Node | null = this; - while (ancestor) { - if (ancestor === child) { - throw Error( - 'cannot insert a node into itself or one of its descendants', - ); - } - ancestor = ancestor[PARENT] ?? ancestor[HOST]; + if (before && before.parentNode !== this) { + throw createDOMException( + 'The reference node is not a child of this parent', + 'NotFoundError', + ); } } diff --git a/packages/polyfill/source/dom-exception.ts b/packages/polyfill/source/dom-exception.ts new file mode 100644 index 00000000..38beb8a3 --- /dev/null +++ b/packages/polyfill/source/dom-exception.ts @@ -0,0 +1,14 @@ +export function createDOMException( + message: string, + name: string, +): DOMException { + const DOMExceptionConstructor = globalThis.DOMException; + + if (typeof DOMExceptionConstructor === 'function') { + return new DOMExceptionConstructor(message, name); + } + + const error = new Error(message); + error.name = name; + return error as DOMException; +} diff --git a/packages/polyfill/source/names.ts b/packages/polyfill/source/names.ts index 93269abe..3ec6f19e 100644 --- a/packages/polyfill/source/names.ts +++ b/packages/polyfill/source/names.ts @@ -3,6 +3,7 @@ import { XMLNS_NAMESPACE, type NamespaceURI, } from './constants.ts'; +import {createDOMException} from './dom-exception.ts'; const VALID_ELEMENT_LOCAL_NAME = /^(?:[A-Za-z][^\0\t\n\f\r\u0020/>]*|[:_\u0080-\u{10FFFF}][A-Za-z0-9-.:_\u0080-\u{10FFFF}]*)$/u; @@ -51,14 +52,14 @@ export function validateAndExtractQualifiedName( } if (prefix != null && namespace == null) { - throw new DOMException( + throw createDOMException( `A namespace is required for the prefix in "${qualifiedName}"`, 'NamespaceError', ); } if (prefix === 'xml' && namespace !== XML_NAMESPACE) { - throw new DOMException( + throw createDOMException( `The xml prefix requires the XML namespace`, 'NamespaceError', ); @@ -68,7 +69,7 @@ export function validateAndExtractQualifiedName( (qualifiedName === 'xmlns' || prefix === 'xmlns') && namespace !== XMLNS_NAMESPACE ) { - throw new DOMException( + throw createDOMException( `The xmlns name requires the XMLNS namespace`, 'NamespaceError', ); @@ -79,7 +80,7 @@ export function validateAndExtractQualifiedName( qualifiedName !== 'xmlns' && prefix !== 'xmlns' ) { - throw new DOMException( + throw createDOMException( `The XMLNS namespace requires the xmlns name or prefix`, 'NamespaceError', ); @@ -89,5 +90,5 @@ export function validateAndExtractQualifiedName( } function throwInvalidCharacterError(name: string): never { - throw new DOMException(`Invalid name: "${name}"`, 'InvalidCharacterError'); + throw createDOMException(`Invalid name: "${name}"`, 'InvalidCharacterError'); } diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index 1b0833ff..c0c746c7 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -9,6 +9,7 @@ import { } from './constants.ts'; import {isElementNode} from './shared.ts'; import {NodeList} from './NodeList.ts'; +import {createDOMException} from './dom-exception.ts'; import type {Node} from './Node.ts'; import type {Element} from './Element.ts'; @@ -57,7 +58,8 @@ export interface Matcher { value?: string; } -const ELEMENT_SELECTOR_TEST = /[a-zA-Z]/; +const SUPPORTED_IDENTIFIER_TEST = + /^(?:--|-?[A-Za-z_\u0080-\u{10FFFF}])[A-Za-z0-9_\u0080-\u{10FFFF}-]*$/u; function readFunctionArgument( selector: string, @@ -90,6 +92,13 @@ function readFunctionArgument( return [selector.slice(start), selector.length]; } +function throwSelectorSyntaxError(selector: string): never { + throw createDOMException( + `Invalid or unsupported selector: "${selector}"`, + 'SyntaxError', + ); +} + export function querySelector( within: ParentNode, selector: string | Matcher[], @@ -129,69 +138,114 @@ export function querySelectorAll( return results; } -export function parseSelector(selector: string, insideHas = false) { +export function parseSelector( + selector: string, + insideHas = false, + allowLeadingCombinator = false, +) { let part: Part = {combinator: COMBINATOR_INNER, matchers: []}; const parts = [part]; const tokenizer = - /[\t\n\f\r ]*?([>\t\n\f\r +~]?)[\t\n\f\r ]*?(?:(?:\[[\t\n\f\r ]*([^\]=\t\n\f\r ]+)[\t\n\f\r ]*(?:=[\t\n\f\r ]*(?:(['"])(.*?)\3|([^\]\t\n\f\r ]+)))?[\t\n\f\r ]*\])|([#.]?)([^\t\n\f\r #.[>:+~()]+)|:(\w+)(\()?)/gi; + /[\t\n\f\r ]*?([>\t\n\f\r +~]?)[\t\n\f\r ]*?(?:(?:\[[\t\n\f\r ]*([^\]=\t\n\f\r ]+)[\t\n\f\r ]*(?:=[\t\n\f\r ]*(?:(['"])(.*?)\3|([^\]\t\n\f\r ]+)))?[\t\n\f\r ]*(?:\]|$))|([#.]?)([^\t\n\f\r #.[>:+~()]+)|:(\w+)(\()?)/gi; const normalizedSelector = selector.replace( /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, '', ); + if (normalizedSelector === '') throwSelectorSyntaxError(selector); + + let consumed = 0; let token; while ((token = tokenizer.exec(normalizedSelector))) { - // [1]: ancestor/parent/sibling/adjacent + if (token.index !== consumed) throwSelectorSyntaxError(selector); + + // [1]: ancestor/ parent/ sibling/ adjacent // [2]: attribute name - // [4]/[5]: quoted/unquoted attribute value + // [3]/[4]: quoted attribute value + // [5]: unquoted attribute value // [6]: id/class sigil - // [7]: id/class name + // [7]: id/class/type name // [8]: :pseudo/:function() name // [9]: :function opening parenthesis if (token[1]) { - // Update the combinator on the (now parent) Part: + if ( + part.matchers.length === 0 && + !(allowLeadingCombinator && parts.length === 1) + ) { + throwSelectorSyntaxError(selector); + } + if (token[1] === '>') part.combinator = COMBINATOR_CHILD; else if (token[1] === '+') part.combinator = COMBINATOR_ADJACENT; else if (token[1] === '~') part.combinator = COMBINATOR_SIBLING; else part.combinator = COMBINATOR_DESCENDANT; - // Add a new Part for the next selector parts: part = {combinator: COMBINATOR_INNER, matchers: []}; parts.push(part); } - let type: MatcherType = MATCHER_UNKNOWN; + const name = token[8] ? asciiLowercase(token[8]) : (token[2] || token[7])!; + const isTypeSelector = token[7] != null && !token[6]; + if (isTypeSelector && part.matchers.length > 0) { + throwSelectorSyntaxError(selector); + } + + let type: MatcherType; + let value = token[4] ?? token[5] ?? token[7]; if (token[2]) { + if (!SUPPORTED_IDENTIFIER_TEST.test(name)) { + throwSelectorSyntaxError(selector); + } + + const unquotedValue = token[5]; + const openingQuote = unquotedValue?.[0]; + if (token[3] == null && (openingQuote === '"' || openingQuote === "'")) { + const valueOffset = token[0].lastIndexOf(unquotedValue!); + value = normalizedSelector.slice(token.index + valueOffset + 1); + tokenizer.lastIndex = normalizedSelector.length; + } else if ( + unquotedValue != null && + !SUPPORTED_IDENTIFIER_TEST.test(unquotedValue) + ) { + throwSelectorSyntaxError(selector); + } type = MATCHER_ATTRIBUTE; } else if (token[6]) { + if (!SUPPORTED_IDENTIFIER_TEST.test(name)) { + throwSelectorSyntaxError(selector); + } type = token[6] === '#' ? MATCHER_ID : MATCHER_CLASS; } else if (token[8]) { type = token[9] == null ? MATCHER_PSEUDO : MATCHER_FUNCTION; - } else if (token[7]) { - if (token[7] === '*') { - type = MATCHER_UNKNOWN; // Universal selector matches all - } else if (ELEMENT_SELECTOR_TEST.test(token[7])) { - type = MATCHER_ELEMENT; - } + } else if (token[7] === '*') { + type = MATCHER_UNKNOWN; + } else if (token[7] && SUPPORTED_IDENTIFIER_TEST.test(token[7])) { + type = MATCHER_ELEMENT; + } else { + throwSelectorSyntaxError(selector); } - let value = token[4] ?? token[5] ?? token[7]; + if (token[9]) { [value, tokenizer.lastIndex] = readFunctionArgument( normalizedSelector, tokenizer.lastIndex, ); - } - const name = token[8] ? asciiLowercase(token[8]) : (token[2] || token[7])!; - if (type === MATCHER_FUNCTION && (name === 'has' || name === 'not')) { - if (name === 'has' && insideHas) { - throw Error(':has() cannot be nested inside :has()'); + + if (name !== 'has' && name !== 'not') { + throwSelectorSyntaxError(selector); } - parseSelector(value!, insideHas || name === 'has'); + if (name === 'has' && insideHas) throwSelectorSyntaxError(selector); + parseSelector(value, insideHas || name === 'has', name === 'has'); + } else if (type === MATCHER_PSEUDO) { + throwSelectorSyntaxError(selector); } - part.matchers.push({ - type, - name, - value, - }); + + part.matchers.push({type, name, value}); + consumed = tokenizer.lastIndex; + } + + if (consumed !== normalizedSelector.length || part.matchers.length === 0) { + throwSelectorSyntaxError(selector); } + return parts; } @@ -203,7 +257,7 @@ function matchesSelector(element: Element, selector: string) { } function matchesRelativeSelector(scope: Element, selector: string) { - const parts = parseSelector(selector); + const parts = parseSelector(selector, true, true); const first = parts[0]!; if (parts.length === 1 && first.matchers.length === 0) return false; @@ -363,10 +417,7 @@ function matchesSelectorMatcher( case MATCHER_SCOPE: return element === scope; case MATCHER_PSEUDO: - switch (name) { - default: - throw Error(`Pseudo :${name} not implemented`); - } + throwSelectorSyntaxError(`:${name}`); case MATCHER_FUNCTION: switch (name) { case 'has': @@ -374,7 +425,7 @@ function matchesSelectorMatcher( case 'not': return !matchesSelector(element, value || ''); default: - throw Error(`Function :${name}(${value}) not implemented`); + throwSelectorSyntaxError(`:${name}(${value})`); } } return false; diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 826022f5..4e688b01 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -34,16 +34,7 @@ import { querySelector, querySelectorAll, } from './selectors.ts'; - -export function createNotSupportedError(message: string) { - if (typeof DOMException === 'function') { - return new DOMException(message, 'NotSupportedError'); - } - - const error = new Error(message); - error.name = 'NotSupportedError'; - return error; -} +import {createDOMException} from './dom-exception.ts'; export function isAttributeNode(node: Node): node is Attr { return node.nodeType === NODE_TYPE_ATTRIBUTE; @@ -185,7 +176,10 @@ export function cloneNode( document: Document = node.ownerDocument, ): Node { if (node.nodeType === NODE_TYPE_DOCUMENT) { - throw createNotSupportedError('Cannot clone a document node'); + throw createDOMException( + 'Cannot clone a document node', + 'NotSupportedError', + ); } const cloned = cloneNodeShallow(node, document); diff --git a/packages/polyfill/source/tests/dom-mutation-selector-errors.test.ts b/packages/polyfill/source/tests/dom-mutation-selector-errors.test.ts new file mode 100644 index 00000000..cd390362 --- /dev/null +++ b/packages/polyfill/source/tests/dom-mutation-selector-errors.test.ts @@ -0,0 +1,484 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import {HOOKS, Window} from '../index.ts'; +import {PARENT} from '../constants.ts'; +import {createDOMException} from '../dom-exception.ts'; +import {parseSelector} from '../selectors.ts'; + +let polyfillWindow: Window; + +beforeEach(() => { + polyfillWindow = new Window(); + Window.setGlobalThis(polyfillWindow); +}); + +function expectDOMError(operation: () => unknown, name: string) { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({name}); + return; + } + + throw new Error(`Expected ${name}`); +} + +describe('DOM mutation errors', () => { + it('reports invalid child and reference nodes as NotFoundError without mutations', () => { + const parent = document.createElement('div'); + const child = document.createElement('span'); + const foreignParent = document.createElement('section'); + const foreignChild = document.createElement('em'); + parent.appendChild(child); + foreignParent.appendChild(foreignChild); + const mutations: string[] = []; + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + + expectDOMError(() => parent.removeChild(foreignChild), 'NotFoundError'); + expectDOMError( + () => parent.insertBefore(foreignChild, foreignChild), + 'NotFoundError', + ); + expectDOMError( + () => parent.replaceChild(foreignChild, foreignChild), + 'NotFoundError', + ); + + expect([...parent.childNodes]).toEqual([child]); + expect([...foreignParent.childNodes]).toEqual([foreignChild]); + expect(child.parentNode).toBe(parent); + expect(foreignChild.parentNode).toBe(foreignParent); + expect(mutations).toEqual([]); + }); + + it('reports hierarchy violations as HierarchyRequestError without mutations', () => { + const ancestor = document.createElement('section'); + const parent = document.createElement('div'); + const child = document.createElement('span'); + ancestor.appendChild(parent); + parent.appendChild(child); + const mutations: string[] = []; + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + + expectDOMError( + () => parent.replaceChild(ancestor, child), + 'HierarchyRequestError', + ); + + expect([...ancestor.childNodes]).toEqual([parent]); + expect([...parent.childNodes]).toEqual([child]); + expect(parent.parentNode).toBe(ancestor); + expect(child.parentNode).toBe(parent); + expect(mutations).toEqual([]); + }); + + it('reports hierarchy errors before invalid reference errors', () => { + const parent = document.createElement('div'); + const child = document.createElement('span'); + const foreignParent = document.createElement('section'); + const foreignChild = document.createElement('em'); + parent.appendChild(child); + foreignParent.appendChild(foreignChild); + const mutations: string[] = []; + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + + expectDOMError( + () => parent.insertBefore(parent, foreignChild), + 'HierarchyRequestError', + ); + expectDOMError( + () => parent.replaceChild(parent, foreignChild), + 'HierarchyRequestError', + ); + + expect([...parent.childNodes]).toEqual([child]); + expect([...foreignParent.childNodes]).toEqual([foreignChild]); + expect(parent.parentNode).toBeNull(); + expect(child.parentNode).toBe(parent); + expect(foreignChild.parentNode).toBe(foreignParent); + expect(mutations).toEqual([]); + }); + + it('validates host-including hierarchy before references and variadic commits', () => { + const sourceWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = polyfillWindow.document; + const holder = destinationDocument.createElement('div'); + const template = destinationDocument.createElement('template'); + const content = template.content; + const foreignParent = destinationDocument.createElement('section'); + const foreignReference = destinationDocument.createElement('span'); + const sourceParent = sourceDocument.createElement('div'); + const movable = sourceDocument.createElement('atomic-host-node'); + let reactions = 0; + (movable as any).connectedCallback = () => reactions++; + (movable as any).disconnectedCallback = () => reactions++; + holder.appendChild(template); + foreignParent.appendChild(foreignReference); + sourceDocument.body.appendChild(sourceParent); + sourceParent.appendChild(movable); + reactions = 0; + + const sourceHooks: string[] = []; + const destinationHooks: string[] = []; + sourceWindow[HOOKS] = { + insertChild: () => sourceHooks.push('insert'), + removeChild: () => sourceHooks.push('remove'), + }; + polyfillWindow[HOOKS] = { + insertChild: () => destinationHooks.push('insert'), + removeChild: () => destinationHooks.push('remove'), + }; + + expectDOMError( + () => content.insertBefore(template, foreignReference), + 'HierarchyRequestError', + ); + expectDOMError( + () => content.append(movable, template), + 'HierarchyRequestError', + ); + + expect([...holder.childNodes]).toEqual([template]); + expect([...content.childNodes]).toEqual([]); + expect([...foreignParent.childNodes]).toEqual([foreignReference]); + expect([...sourceParent.childNodes]).toEqual([movable]); + expect(template.parentNode).toBe(holder); + expect(movable.parentNode).toBe(sourceParent); + expect(movable.ownerDocument).toBe(sourceDocument); + expect(sourceHooks).toEqual([]); + expect(destinationHooks).toEqual([]); + expect(reactions).toBe(0); + }); + + it.each([ + [ + 'append', + (parent: Element, child: Element) => parent.append(child, parent), + ], + [ + 'prepend', + (parent: Element, child: Element) => parent.prepend(child, parent), + ], + [ + 'replaceChildren', + (parent: Element, child: Element) => + parent.replaceChildren(child, parent), + ], + [ + 'before', + (parent: Element, child: Element) => + parent.firstElementChild!.before(child, parent), + ], + [ + 'after', + (parent: Element, child: Element) => + parent.firstElementChild!.after(child, parent), + ], + [ + 'replaceWith', + (parent: Element, child: Element) => + parent.firstElementChild!.replaceWith(child, parent), + ], + ])('validates every %s argument before changing state', (_name, mutate) => { + const parent = document.createElement('div'); + const existing = document.createElement('span'); + const source = document.createElement('section'); + const movable = document.createElement('em'); + parent.appendChild(existing); + source.appendChild(movable); + const mutations: string[] = []; + polyfillWindow[HOOKS].insertChild = () => mutations.push('insert'); + polyfillWindow[HOOKS].removeChild = () => mutations.push('remove'); + + expectDOMError(() => mutate(parent, movable), 'HierarchyRequestError'); + + expect([...parent.childNodes]).toEqual([existing]); + expect([...source.childNodes]).toEqual([movable]); + expect(existing.parentNode).toBe(parent); + expect(movable.parentNode).toBe(source); + expect(mutations).toEqual([]); + }); + + it.each([ + [ + 'append', + (parent: Element, child: Element) => parent.append(child), + (parent: Element, child: Element) => parent.appendChild(child), + ], + [ + 'prepend', + (parent: Element, child: Element) => parent.prepend(child), + (parent: Element, child: Element) => + parent.insertBefore(child, parent.firstChild), + ], + ])( + 'does not add a second hierarchy walk for single-node %s', + (_name, mutate, directMutation) => { + const countParentReads = ( + operation: (parent: Element, child: Element) => unknown, + ) => { + const parent = document.createElement('div'); + const child = document.createElement('span'); + let parentReads = 0; + Object.defineProperty(parent, PARENT, { + configurable: true, + get() { + parentReads += 1; + return null; + }, + }); + + operation(parent, child); + expect(parent.firstElementChild).toBe(child); + return parentReads; + }; + + expect(countParentReads(mutate)).toBe(countParentReads(directMutation)); + }, + ); + + it.each([ + [ + 'append', + (parent: any, child: any, invalid: any) => parent.append(child, invalid), + ], + [ + 'prepend', + (parent: any, child: any, invalid: any) => parent.prepend(child, invalid), + ], + [ + 'replaceChildren', + (parent: any, child: any, invalid: any) => + parent.replaceChildren(child, invalid), + ], + [ + 'before', + (parent: any, child: any, invalid: any) => + parent.firstElementChild!.before(child, invalid), + ], + [ + 'after', + (parent: any, child: any, invalid: any) => + parent.firstElementChild!.after(child, invalid), + ], + [ + 'replaceWith', + (parent: any, child: any, invalid: any) => + parent.firstElementChild!.replaceWith(child, invalid), + ], + ])( + 'converts every %s argument before moving cross-document nodes', + (_name, mutate) => { + const sourceWindow = new Window(); + const sourceDocument = sourceWindow.document; + const destinationDocument = polyfillWindow.document; + const sourceParent = sourceDocument.createElement('section'); + const movable = sourceDocument.createElement('atomic-node'); + const parent = destinationDocument.createElement('div'); + const existing = destinationDocument.createElement('span'); + let reactions = 0; + (movable as any).connectedCallback = () => reactions++; + (movable as any).disconnectedCallback = () => reactions++; + sourceDocument.body.appendChild(sourceParent); + sourceParent.appendChild(movable); + destinationDocument.body.appendChild(parent); + parent.appendChild(existing); + reactions = 0; + + const sourceHooks: string[] = []; + const destinationHooks: string[] = []; + sourceWindow[HOOKS] = { + createText: () => sourceHooks.push('createText'), + insertChild: () => sourceHooks.push('insert'), + removeChild: () => sourceHooks.push('remove'), + }; + polyfillWindow[HOOKS] = { + createText: () => destinationHooks.push('createText'), + insertChild: () => destinationHooks.push('insert'), + removeChild: () => destinationHooks.push('remove'), + }; + const conversionError = new Error('conversion failed'); + const invalid = { + toString() { + throw conversionError; + }, + }; + + expect(() => mutate(parent, movable, invalid)).toThrow(conversionError); + + expect([...parent.childNodes]).toEqual([existing]); + expect([...sourceParent.childNodes]).toEqual([movable]); + expect(existing.parentNode).toBe(parent); + expect(movable.parentNode).toBe(sourceParent); + expect(movable.ownerDocument).toBe(sourceDocument); + expect(sourceHooks).toEqual([]); + expect(destinationHooks).toEqual([]); + expect(reactions).toBe(0); + }, + ); +}); + +describe('selector syntax errors and EOF recovery', () => { + it.each(['div:has(span', 'div:not(.missing', 'div:has(> span'])( + 'recovers supported function selector %j at EOF', + (selector) => { + const root = document.createElement('section'); + root.innerHTML = '
'; + const expected = root.firstElementChild; + + expect(() => parseSelector(selector)).not.toThrow(); + expect(root.querySelector(selector)).toBe(expected); + expect(root.querySelectorAll(selector)).toEqual([expected]); + }, + ); + + it.each([ + ['[data-open', 'data-open', '', false], + ['[data-kind=item', 'data-kind', 'item', false], + ['[data-kind="item', 'data-kind', 'item', false], + ['[data-kind="item]', 'data-kind', 'item]', false], + ['[data-kind=\'item"]', 'data-kind', 'item"]', false], + [':has([data-kind="item])', 'data-kind', 'item])', true], + [':has([data-kind=\'item"])', 'data-kind', 'item"])', true], + ['article:has([data-kind="item]) span', 'data-kind', 'item]) span', true], + ])( + 'recovers attribute selector %j at EOF', + (selector, attribute, value, nested) => { + const root = document.createElement('section'); + const wrapper = document.createElement('article'); + const child = document.createElement('div'); + child.setAttribute(attribute as string, value); + wrapper.appendChild(child); + root.appendChild(wrapper); + const expected = nested ? wrapper : child; + + expect(() => parseSelector(selector as string)).not.toThrow(); + expect(root.querySelector(selector as string)).toBe(expected); + expect(root.querySelectorAll(selector as string)).toEqual([expected]); + }, + ); + + it('recovers an EOF attribute after a complete compound member', () => { + const root = document.createElement('section'); + const child = document.createElement('div'); + child.setAttribute('data-kind', 'a'); + child.setAttribute('other', 'b'); + root.appendChild(child); + const selector = '[data-kind="a"][other="b'; + + expect(() => parseSelector(selector)).not.toThrow(); + expect(root.querySelector(selector)).toBe(child); + expect(root.querySelectorAll(selector)).toEqual([child]); + }); + + it.each([ + '', + ' ', + 'div)', + 'div, span', + ':hover', + ':HOVER', + ':matches(div)', + ':has(:hover)', + 'div >', + 'div >> span', + 'div:has()', + 'div:has(span))', + '[data-kind^=item]', + '[data-kind=item"value]', + '[data-kind==item]', + '[data-kind="item" junk]', + '[data-kind=#item]', + '[data-kind]div', + ':not(.missing)div', + ])('reports malformed or unsupported %j as SyntaxError', (selector) => { + const parent = document.createElement('div'); + parent.appendChild(document.createElement('span')); + + expectDOMError(() => parseSelector(selector), 'SyntaxError'); + expectDOMError(() => parent.querySelector(selector), 'SyntaxError'); + expectDOMError(() => parent.querySelectorAll(selector), 'SyntaxError'); + }); +}); + +describe('DOMException fallback', () => { + it('creates a named Error when the DOMException global is unavailable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'DOMException', + ); + Object.defineProperty(globalThis, 'DOMException', { + configurable: true, + value: undefined, + writable: true, + }); + + try { + const error = createDOMException('Invalid tree', 'HierarchyRequestError'); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + message: 'Invalid tree', + name: 'HierarchyRequestError', + }); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'DOMException', descriptor); + } else { + delete (globalThis as {DOMException?: typeof DOMException}) + .DOMException; + } + } + }); + + it('preserves public error names without the DOMException global', () => { + const descriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'DOMException', + ); + Object.defineProperty(globalThis, 'DOMException', { + configurable: true, + value: undefined, + writable: true, + }); + + try { + const parent = document.createElement('div'); + const foreignChild = document.createElement('span'); + const firstElement = document.createElement('section'); + const secondElement = document.createElement('section'); + firstElement.setAttribute('shared', 'value'); + const sharedAttribute = firstElement.attributes.item(0)!; + class InvalidElement extends HTMLElement {} + + const operations: [() => unknown, string][] = [ + [() => parent.append(parent), 'HierarchyRequestError'], + [() => parent.removeChild(foreignChild), 'NotFoundError'], + [() => parent.querySelector(''), 'SyntaxError'], + [() => document.createElement('invalid name'), 'InvalidCharacterError'], + [() => document.createElementNS(null, 'p:name'), 'NamespaceError'], + [() => customElements.define('invalid', InvalidElement), 'SyntaxError'], + [() => document.importNode(document), 'NotSupportedError'], + [ + () => secondElement.attributes.setNamedItem(sharedAttribute), + 'InUseAttributeError', + ], + ]; + + for (const [operation, name] of operations) { + expectDOMError(operation, name); + } + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'DOMException', descriptor); + } else { + delete (globalThis as {DOMException?: typeof DOMException}) + .DOMException; + } + } + }); +}); diff --git a/packages/polyfill/source/tests/named-node-map.test.ts b/packages/polyfill/source/tests/named-node-map.test.ts index aab666d5..6036711e 100644 --- a/packages/polyfill/source/tests/named-node-map.test.ts +++ b/packages/polyfill/source/tests/named-node-map.test.ts @@ -107,6 +107,7 @@ describe('NamedNodeMap invariants', () => { thrown = error; } + expect(thrown).toBeInstanceOf(DOMException); expect(thrown).toMatchObject({name: 'InUseAttributeError'}); expect([...firstElement.attributes]).toEqual([attribute]); expect([...secondElement.attributes]).toEqual([secondAttribute]); diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts index cb794cae..46ea97cf 100644 --- a/packages/polyfill/source/tests/selectors.test.ts +++ b/packages/polyfill/source/tests/selectors.test.ts @@ -100,14 +100,10 @@ describe('selector parsing and matching', () => { }); }); - it('parses pseudo-class selectors', () => { - const parts = parseSelector(':hover'); - expect(parts).toHaveLength(1); - expect(parts[0]!.matchers[0]!).toMatchObject({ - type: 5, // MatcherType.Pseudo - name: 'hover', - value: undefined, - }); + it('rejects unsupported pseudo-class selectors', () => { + expect(() => parseSelector(':hover')).toThrowError( + expect.objectContaining({name: 'SyntaxError'}), + ); }); it('parses function selectors', () => { @@ -149,7 +145,6 @@ describe('selector parsing and matching', () => { it.each([ [':HAS(div)', 6, 'has', 'div'], [':Not(.Hidden)', 6, 'not', '.Hidden'], - [':HOVER', 5, 'hover', undefined], ])( 'ASCII-lowercases only the pseudo-class name in %s', (selector, type, name, value) => { @@ -369,6 +364,18 @@ describe('selector parsing and matching', () => { expect(container.querySelector('.left')).toBe(separated); }); + it('selects Unicode and double-hyphen identifiers', () => { + const article = container.querySelector('article')!; + const unicode = document.createElement('span'); + unicode.setAttribute('class', 'é'); + unicode.id = '--foo'; + article.appendChild(unicode); + + expect(container.querySelector('.é')).toBe(unicode); + expect(container.querySelector('#--foo')).toBe(unicode); + expect(container.querySelector('article:has(> .é)')).toBe(article); + }); + it('selects by attribute', () => { const links = container.querySelectorAll('[href]'); expect(links).toHaveLength(2); @@ -504,8 +511,12 @@ describe('selector parsing and matching', () => { (selector) => { const empty = document.createElement('div'); for (const root of [empty, container]) { - expect(() => root.querySelector(selector)).toThrow(); - expect(() => root.querySelectorAll(selector)).toThrow(); + expect(() => root.querySelector(selector)).toThrowError( + expect.objectContaining({name: 'SyntaxError'}), + ); + expect(() => root.querySelectorAll(selector)).toThrowError( + expect.objectContaining({name: 'SyntaxError'}), + ); } }, ); @@ -593,9 +604,7 @@ describe('selector parsing and matching', () => { expect(container.querySelector(selector) != null).toBe(matches); }); - it('handles edge cases', () => { - expect(container.querySelectorAll('')).toHaveLength(0); - + it('selects all elements with the universal selector', () => { const allElements = container.querySelectorAll('*'); expect(allElements.length).toBeGreaterThan(0); });