From 2c9da0e71b6d2db43c0655e87095411d2be9d6fc Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Thu, 17 Sep 2026 09:37:14 -0700 Subject: [PATCH 1/3] Reuse querySelectorAll for getElementsByTagName --- packages/polyfill/source/selectors.ts | 20 +++++-- packages/polyfill/source/shared.ts | 25 ++++----- .../tests/get-elements-by-tag-name.test.ts | 52 +++++++++++++++++++ .../source/tests/selectors.types.test.ts | 11 ++++ 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index acf82cf0..46acffc3 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -3,6 +3,7 @@ import { NEXT, PARENT, PREV, + NAME, HTML_NAMESPACE, asciiLowercase, splitOnASCIIWhitespace, @@ -35,6 +36,9 @@ export const MATCHER_ATTRIBUTE = 4; export const MATCHER_PSEUDO = 5; export const MATCHER_FUNCTION = 6; export const MATCHER_SCOPE = 7; +// Internal matcher for qualified-name queries. CSS type selectors use localName +// instead, while both kinds precompute their HTML comparison value. +export const MATCHER_QUALIFIED_NAME = 8; /** Common fields available on every selector matcher. */ export interface MatcherBase { @@ -95,11 +99,14 @@ export interface ScopeMatcher extends MatcherBase { value?: undefined; } -/** A local-name matcher with a precomputed HTML comparison name. */ +/** A local- or qualified-name matcher with a precomputed HTML comparison name. */ export interface NormalizedNameMatcher extends MatcherBase { - /** Selects local-name matching for CSS. */ - type: typeof MATCHER_ELEMENT; - /** The original local-name query, preserving case for non-HTML elements. */ + /** Selects local-name matching for CSS or qualified-name matching for DOM APIs. */ + type: typeof MATCHER_ELEMENT | typeof MATCHER_QUALIFIED_NAME; + /** + * The original local-name query for MATCHER_ELEMENT or qualified-name query + * for MATCHER_QUALIFIED_NAME, preserving case for non-HTML elements. + */ name: string; /** The precomputed ASCII-lowercased name used for HTML elements. */ htmlName: string; @@ -421,6 +428,11 @@ function matchesSelectorMatcher( element.localName === (element.namespaceURI === HTML_NAMESPACE ? htmlName : name) ); + case MATCHER_QUALIFIED_NAME: + return ( + element[NAME] === + (element.namespaceURI === HTML_NAMESPACE ? htmlName : name) + ); case MATCHER_ID: return element.getAttributeNS(null, 'id') === name; case MATCHER_CLASS: diff --git a/packages/polyfill/source/shared.ts b/packages/polyfill/source/shared.ts index 826022f5..ffb72ccb 100644 --- a/packages/polyfill/source/shared.ts +++ b/packages/polyfill/source/shared.ts @@ -31,6 +31,8 @@ import type {Text} from './Text.ts'; import { MATCHER_CLASS, MATCHER_ID, + MATCHER_QUALIFIED_NAME, + MATCHER_UNKNOWN, querySelector, querySelectorAll, } from './selectors.ts'; @@ -295,23 +297,14 @@ export function getElementsByTagName( qualifiedName: string, ) { const name = String(qualifiedName); - const normalizedHtmlName = asciiLowercase(name); - const elements: Element[] = []; - - for (const node of descendants(within)) { - if (!isElementNode(node)) continue; - - if ( - name === '*' || - (node.namespaceURI === HTML_NAMESPACE - ? node[NAME] === normalizedHtmlName - : node[NAME] === name) - ) { - elements.push(node); - } - } - return elements; + return querySelectorAll(within, [ + { + type: name === '*' ? MATCHER_UNKNOWN : MATCHER_QUALIFIED_NAME, + name, + htmlName: asciiLowercase(name), + }, + ]); } export function descendants(node: Node) { diff --git a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts index e66cd8d0..20cd47b4 100644 --- a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts +++ b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts @@ -1,5 +1,6 @@ import {SVG_NAMESPACE} from '../constants.ts'; import {Window} from '../index.ts'; +import {NodeList} from '../NodeList.ts'; import {beforeEach, describe, expect, it} from 'vitest'; @@ -25,6 +26,42 @@ describe('getElementsByTagName', () => { expect(document.body.getElementsByTagName('*')).toHaveLength(3); }); + it('returns the existing NodeList collection with item() access', () => { + document.body.innerHTML = '
'; + + const matches = document.getElementsByTagName('div'); + + expect(matches).toBeInstanceOf(NodeList); + expect(matches.item(0)).toBe(matches[0]); + expect(matches.item(matches.length)).toBeNull(); + }); + + it('converts the qualified name argument to a string', () => { + const element = document.createElement('div'); + document.body.appendChild(element); + + expect( + document.getElementsByTagName({toString: () => 'DIV'} as any), + ).toEqual([element]); + }); + + it('matches qualified names while CSS type selectors match local names', () => { + const prefixedHtml = document.createElementNS( + 'http://www.w3.org/1999/xhtml', + 'test:aÇ', + ); + const prefixedForeign = document.createElementNS('test', 'te:ST'); + document.body.append(prefixedHtml, prefixedForeign); + + expect(document.getElementsByTagName('test:aÇ')).toEqual([prefixedHtml]); + expect(document.getElementsByTagName('aÇ')).toHaveLength(0); + expect(document.querySelectorAll('aÇ')).toEqual([prefixedHtml]); + + expect(document.getElementsByTagName('te:ST')).toEqual([prefixedForeign]); + expect(document.getElementsByTagName('ST')).toHaveLength(0); + expect(document.querySelectorAll('ST')).toEqual([prefixedForeign]); + }); + it('matches non-HTML tag names case-sensitively', () => { const svg = document.createElementNS(SVG_NAMESPACE, 'svg'); const gradient = document.createElementNS(SVG_NAMESPACE, 'linearGradient'); @@ -35,6 +72,21 @@ describe('getElementsByTagName', () => { expect(document.getElementsByTagName('lineargradient')).toHaveLength(0); }); + it.each(['Document', 'Element'])( + 'preserves the original name for foreign elements in %s lookups', + (contextType) => { + const parent = document.body; + const context = contextType === 'Document' ? document : parent; + const html = document.createElement('div'); + const uppercaseSvg = document.createElementNS(SVG_NAMESPACE, 'DIV'); + const lowercaseSvg = document.createElementNS(SVG_NAMESPACE, 'div'); + parent.append(html, uppercaseSvg, lowercaseSvg); + + expect(context.getElementsByTagName('DIV')).toEqual([html, uppercaseSvg]); + expect(context.getElementsByTagName('div')).toEqual([html, lowercaseSvg]); + }, + ); + it.each(['Document', 'Element'])( 'uses WPT-derived ASCII matching for %s.getElementsByTagName()', (contextType) => { diff --git a/packages/polyfill/source/tests/selectors.types.test.ts b/packages/polyfill/source/tests/selectors.types.test.ts index 54bf8d8e..4b4681d2 100644 --- a/packages/polyfill/source/tests/selectors.types.test.ts +++ b/packages/polyfill/source/tests/selectors.types.test.ts @@ -7,6 +7,7 @@ import { MATCHER_FUNCTION, MATCHER_ID, MATCHER_PSEUDO, + MATCHER_QUALIFIED_NAME, MATCHER_SCOPE, MATCHER_UNKNOWN, } from '../selectors.ts'; @@ -43,6 +44,9 @@ function assertDiscriminantNarrowing(matcher: Matcher) { case MATCHER_SCOPE: expectTypeOf(matcher).toEqualTypeOf(); break; + case MATCHER_QUALIFIED_NAME: + expectTypeOf(matcher).toEqualTypeOf(); + break; default: expectTypeOf(matcher).toEqualTypeOf(); } @@ -60,6 +64,7 @@ describe('Matcher types', () => { {type: MATCHER_FUNCTION, name: 'not', value: '.hidden'}, {type: MATCHER_SCOPE, name: ':scope'}, {type: MATCHER_ID, name: 'target', htmlName: 'ignored'}, + {type: MATCHER_QUALIFIED_NAME, name: 'DIV', htmlName: 'div'}, ]; for (const matcher of matchers) assertDiscriminantNarrowing(matcher); @@ -86,6 +91,11 @@ describe('Matcher types', () => { type: MATCHER_ATTRIBUTE, name: 'DATA-STATE', }; + // @ts-expect-error Qualified-name matching requires the normalized HTML name. + const missingQualifiedHTMLName: NormalizedNameMatcher = { + type: MATCHER_QUALIFIED_NAME, + name: 'DIV', + }; const pseudoWithValue: PseudoMatcher = { type: MATCHER_PSEUDO, name: 'hover', @@ -95,6 +105,7 @@ describe('Matcher types', () => { expectTypeOf(missingElementHTMLName).toEqualTypeOf(); expectTypeOf(missingAttributeHTMLName).toEqualTypeOf(); + expectTypeOf(missingQualifiedHTMLName).toEqualTypeOf(); expectTypeOf(pseudoWithValue).toEqualTypeOf(); }); }); From 0eda06ba27ca027c4bbb4e23c1b36ef218b45cba Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Thu, 17 Sep 2026 09:53:28 -0700 Subject: [PATCH 2/3] Add changeset for tag-name selector reuse --- .changeset/reuse-tag-name-selector-traversal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/reuse-tag-name-selector-traversal.md diff --git a/.changeset/reuse-tag-name-selector-traversal.md b/.changeset/reuse-tag-name-selector-traversal.md new file mode 100644 index 00000000..530aa836 --- /dev/null +++ b/.changeset/reuse-tag-name-selector-traversal.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Return a static `NodeList` with `item()` support from `getElementsByTagName()`, using shared selector traversal while preserving qualified-name and namespace-sensitive matching. From d506f2ecee4f715cb411d9377883d7e05d7d6fbd Mon Sep 17 00:00:00 2001 From: Andre Wiggins Date: Thu, 17 Sep 2026 11:04:59 -0700 Subject: [PATCH 3/3] Test structured qualified-name matchers --- packages/polyfill/source/selectors.ts | 2 +- .../tests/get-elements-by-tag-name.test.ts | 32 +++++++++++++++++++ .../source/tests/selectors.types.test.ts | 4 ++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index 46acffc3..05639c1a 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -37,7 +37,7 @@ export const MATCHER_PSEUDO = 5; export const MATCHER_FUNCTION = 6; export const MATCHER_SCOPE = 7; // Internal matcher for qualified-name queries. CSS type selectors use localName -// instead, while both kinds precompute their HTML comparison value. +// instead, while both kinds precompute their HTML comparison name. export const MATCHER_QUALIFIED_NAME = 8; /** Common fields available on every selector matcher. */ diff --git a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts index 20cd47b4..f7d67586 100644 --- a/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts +++ b/packages/polyfill/source/tests/get-elements-by-tag-name.test.ts @@ -1,6 +1,7 @@ import {SVG_NAMESPACE} from '../constants.ts'; import {Window} from '../index.ts'; import {NodeList} from '../NodeList.ts'; +import {MATCHER_QUALIFIED_NAME, querySelectorAll} from '../selectors.ts'; import {beforeEach, describe, expect, it} from 'vitest'; @@ -62,6 +63,37 @@ describe('getElementsByTagName', () => { expect(document.querySelectorAll('ST')).toEqual([prefixedForeign]); }); + it('uses structured qualified matcher names for each namespace', () => { + const root = new Window().document; + const prefixedHtml = root.createElementNS( + 'http://www.w3.org/1999/xhtml', + 'test:aÇ', + ); + const prefixedForeign = root.createElementNS('test', 'te:ST'); + root.body.append(prefixedHtml, prefixedForeign); + + expect( + querySelectorAll(root, [ + { + type: MATCHER_QUALIFIED_NAME, + name: 'unused-for-html', + htmlName: 'test:aÇ', + value: 'not-the-html-name', + }, + ]), + ).toEqual([prefixedHtml]); + expect( + querySelectorAll(root, [ + { + type: MATCHER_QUALIFIED_NAME, + name: 'te:ST', + htmlName: 'unused-for-foreign', + value: 'not-the-foreign-name', + }, + ]), + ).toEqual([prefixedForeign]); + }); + it('matches non-HTML tag names case-sensitively', () => { const svg = document.createElementNS(SVG_NAMESPACE, 'svg'); const gradient = document.createElementNS(SVG_NAMESPACE, 'linearGradient'); diff --git a/packages/polyfill/source/tests/selectors.types.test.ts b/packages/polyfill/source/tests/selectors.types.test.ts index 4b4681d2..e500d186 100644 --- a/packages/polyfill/source/tests/selectors.types.test.ts +++ b/packages/polyfill/source/tests/selectors.types.test.ts @@ -105,7 +105,9 @@ describe('Matcher types', () => { expectTypeOf(missingElementHTMLName).toEqualTypeOf(); expectTypeOf(missingAttributeHTMLName).toEqualTypeOf(); - expectTypeOf(missingQualifiedHTMLName).toEqualTypeOf(); + expectTypeOf( + missingQualifiedHTMLName, + ).toEqualTypeOf(); expectTypeOf(pseudoWithValue).toEqualTypeOf(); }); });