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.
diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts
index acf82cf0..05639c1a 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 name.
+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..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,5 +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';
@@ -25,6 +27,73 @@ 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('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');
@@ -35,6 +104,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..e500d186 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,9 @@ describe('Matcher types', () => {
expectTypeOf(missingElementHTMLName).toEqualTypeOf();
expectTypeOf(missingAttributeHTMLName).toEqualTypeOf();
+ expectTypeOf(
+ missingQualifiedHTMLName,
+ ).toEqualTypeOf();
expectTypeOf(pseudoWithValue).toEqualTypeOf();
});
});