From 54664151820316d9f655e9964de232854fdec5c4 Mon Sep 17 00:00:00 2001 From: Olavo Santos Date: Wed, 2 Sep 2026 12:29:59 -0500 Subject: [PATCH] Return NodeList from querySelectorAll --- .../return-query-selector-all-node-list.md | 5 ++ packages/polyfill/source/Element.ts | 4 +- packages/polyfill/source/NodeList.ts | 6 ++- packages/polyfill/source/ParentNode.ts | 13 +++--- .../polyfill/source/getElementsByClassName.ts | 8 +++- packages/polyfill/source/selectors.ts | 5 +- .../polyfill/source/tests/selectors.test.ts | 46 ++++++++++++++++++- 7 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 .changeset/return-query-selector-all-node-list.md diff --git a/.changeset/return-query-selector-all-node-list.md b/.changeset/return-query-selector-all-node-list.md new file mode 100644 index 00000000..506b7f54 --- /dev/null +++ b/.changeset/return-query-selector-all-node-list.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Return a NodeList-compatible collection from `querySelectorAll()`. diff --git a/packages/polyfill/source/Element.ts b/packages/polyfill/source/Element.ts index 7b7cc956..8667868d 100644 --- a/packages/polyfill/source/Element.ts +++ b/packages/polyfill/source/Element.ts @@ -94,7 +94,7 @@ export class Element extends ParentNode { return findElementsByTagName(this, qualifiedName); } - get firstElementChild() { + get firstElementChild(): Element | null { return this.children[0] ?? null; } @@ -102,7 +102,7 @@ export class Element extends ParentNode { return findElementsByClassName(this, classNames); } - get lastElementChild() { + get lastElementChild(): Element | null { return this.children[this.children.length - 1] ?? null; } diff --git a/packages/polyfill/source/NodeList.ts b/packages/polyfill/source/NodeList.ts index 7fcbf0fc..7ae15403 100644 --- a/packages/polyfill/source/NodeList.ts +++ b/packages/polyfill/source/NodeList.ts @@ -1,5 +1,7 @@ -export class NodeList extends Array { - item(index: number) { +import type {Node} from './Node.ts'; + +export class NodeList extends Array { + item(index: number): Item | null { return this[index] ?? null; } } diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index 6438f894..18787c38 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -11,10 +11,11 @@ import { IS_CONNECTED, } from './constants.ts'; import type {Node} from './Node.ts'; +import type {Element} from './Element.ts'; import {ChildNode, toNode} from './ChildNode.ts'; import {NodeList} from './NodeList.ts'; import {querySelectorAll, querySelector} from './selectors.ts'; -import {selfAndDescendants} from './shared.ts'; +import {isElementNode, selfAndDescendants} from './shared.ts'; import { childListObserversActive, mutationNodeList, @@ -46,8 +47,8 @@ interface PreparedInsertionRoot { } export class ParentNode extends ChildNode { - readonly childNodes = new NodeList(); - readonly children = new NodeList(); + readonly childNodes = new NodeList(); + readonly children = new NodeList(); appendChild(child: T) { return performWithCustomElementReactions(() => { @@ -286,7 +287,7 @@ export class ParentNode extends ChildNode { const childNodesIndex = this.childNodes.indexOf(child); this.childNodes.splice(childNodesIndex, 1); - if (child.nodeType === NODE_TYPE_ELEMENT) { + if (isElementNode(child)) { this.children.splice(this.children.indexOf(child), 1); } @@ -319,7 +320,7 @@ export class ParentNode extends ChildNode { } } - const isElement = child.nodeType === NODE_TYPE_ELEMENT; + const isElement = isElementNode(child); child[PARENT] = this; child[OWNER_DOCUMENT] = this[OWNER_DOCUMENT]; @@ -330,7 +331,7 @@ export class ParentNode extends ChildNode { if (isElement) { let reference: Node | null = before; - while (reference && reference.nodeType !== NODE_TYPE_ELEMENT) { + while (reference && !isElementNode(reference)) { reference = reference[NEXT]; } if (reference) { diff --git a/packages/polyfill/source/getElementsByClassName.ts b/packages/polyfill/source/getElementsByClassName.ts index 23d16bd2..5dab6d1f 100644 --- a/packages/polyfill/source/getElementsByClassName.ts +++ b/packages/polyfill/source/getElementsByClassName.ts @@ -1,12 +1,16 @@ import type {ParentNode} from './ParentNode.ts'; +import type {Element} from './Element.ts'; import {NodeList} from './NodeList.ts'; import {descendants, isElementNode} from './shared.ts'; -export function getElementsByClassName(node: ParentNode, classNames: string) { +export function getElementsByClassName( + node: ParentNode, + classNames: string, +): NodeList { const names = [...new Set(String(classNames).split(/[\t\n\f\r ]+/))].filter( Boolean, ); - const matches = new NodeList(); + const matches = new NodeList(); if (names.length === 0) return matches; diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index 287332f6..22e0bb4c 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -7,6 +7,7 @@ import { asciiLowercase, } from './constants.ts'; import {isElementNode} from './shared.ts'; +import {NodeList} from './NodeList.ts'; import type {Node} from './Node.ts'; import type {Element} from './Element.ts'; @@ -111,12 +112,12 @@ export function querySelector( export function querySelectorAll( within: ParentNode, selector: string | Matcher[], -): Element[] { +): NodeList { const parts: Part[] = typeof selector === 'string' ? parseSelector(selector) : [{combinator: COMBINATOR_INNER, matchers: selector}]; - const results: Element[] = []; + const results = new NodeList(); const child = within[CHILD]; if (child && parts[0]!.matchers.length) { diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts index 3b25a3d3..2f1d9e61 100644 --- a/packages/polyfill/source/tests/selectors.test.ts +++ b/packages/polyfill/source/tests/selectors.test.ts @@ -1,4 +1,6 @@ import {Window} from '../index.ts'; +import {NodeList} from '../NodeList.ts'; +import type {Element as PolyfillElement} from '../Element.ts'; import { MATCHER_CLASS, MATCHER_ELEMENT, @@ -17,7 +19,7 @@ const MatcherType = { Class: MATCHER_CLASS, } as const; -import {describe, it, expect, beforeEach} from 'vitest'; +import {describe, it, expect, expectTypeOf, beforeEach} from 'vitest'; describe('selector parsing and matching', () => { beforeEach(() => { @@ -226,6 +228,16 @@ describe('selector parsing and matching', () => { container.querySelector('.highlight')!.setAttribute('data-label', 'a)b'); }); + it('types instance query results as elements', () => { + const matches = new Window().document + .createElement('div') + .querySelectorAll('p'); + + expectTypeOf(matches).toEqualTypeOf>(); + expectTypeOf(matches[0]!).toEqualTypeOf(); + expectTypeOf(matches.item(0)).toEqualTypeOf(); + }); + it('selects HTML element names case-insensitively', () => { const articles = container.querySelectorAll('ARTICLE'); expect(articles).toHaveLength(1); @@ -235,6 +247,28 @@ describe('selector parsing and matching', () => { expect(paragraphs).toHaveLength(3); }); + it('returns a static NodeList-compatible collection', () => { + const matches = container.querySelectorAll('.text'); + const text = [...matches].map((element) => element.textContent?.trim()); + const visited: string[] = []; + + matches.forEach((element) => visited.push(element.textContent?.trim()!)); + container + .appendChild(document.createElement('p')) + .setAttribute('class', 'text'); + + expect(container.querySelectorAll('.text')).toHaveLength(4); + expect(matches).toBeInstanceOf(NodeList); + expect(matches).toHaveLength(3); + expect(matches.item(0)).toBe(matches[0]); + expect(matches.item(-1)).toBeNull(); + expect(matches.item(matches.length)).toBeNull(); + expect( + [...matches].map((element) => element.textContent?.trim()), + ).toEqual(text); + expect(visited).toEqual(text); + }); + it('does not fold stored createElementNS HTML names', () => { const uppercase = document.createElementNS( 'http://www.w3.org/1999/xhtml', @@ -550,6 +584,16 @@ describe('selector parsing and matching', () => { expect(main?.tagName.toLowerCase()).toBe('article'); }); + it('types standalone query results as elements', () => { + const matches = querySelectorAll(asPolyfill(container), [ + {type: MatcherType.Element, name: 'p'}, + ]); + + expectTypeOf(matches).toEqualTypeOf>(); + expectTypeOf(matches[0]!).toEqualTypeOf(); + expectTypeOf(matches.item(0)).toEqualTypeOf(); + }); + it('selects by element matcher without parsing', () => { const paragraphs = querySelectorAll(asPolyfill(container), [ {type: MatcherType.Element, name: 'p'},