Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reuse-tag-name-selector-traversal.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 16 additions & 4 deletions packages/polyfill/source/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
NEXT,
PARENT,
PREV,
NAME,
HTML_NAMESPACE,
asciiLowercase,
splitOnASCIIWhitespace,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 9 additions & 16 deletions packages/polyfill/source/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
84 changes: 84 additions & 0 deletions packages/polyfill/source/tests/get-elements-by-tag-name.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -25,6 +27,73 @@ describe('getElementsByTagName', () => {
expect(document.body.getElementsByTagName('*')).toHaveLength(3);
});

it('returns the existing NodeList collection with item() access', () => {
document.body.innerHTML = '<main><div></div></main>';

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');
Expand All @@ -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) => {
Expand Down
13 changes: 13 additions & 0 deletions packages/polyfill/source/tests/selectors.types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MATCHER_FUNCTION,
MATCHER_ID,
MATCHER_PSEUDO,
MATCHER_QUALIFIED_NAME,
MATCHER_SCOPE,
MATCHER_UNKNOWN,
} from '../selectors.ts';
Expand Down Expand Up @@ -43,6 +44,9 @@ function assertDiscriminantNarrowing(matcher: Matcher) {
case MATCHER_SCOPE:
expectTypeOf(matcher).toEqualTypeOf<ScopeMatcher>();
break;
case MATCHER_QUALIFIED_NAME:
expectTypeOf(matcher).toEqualTypeOf<NormalizedNameMatcher>();
break;
default:
expectTypeOf(matcher).toEqualTypeOf<never>();
}
Expand All @@ -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);
Expand All @@ -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',
Expand All @@ -95,6 +105,9 @@ describe('Matcher types', () => {

expectTypeOf(missingElementHTMLName).toEqualTypeOf<NormalizedNameMatcher>();
expectTypeOf(missingAttributeHTMLName).toEqualTypeOf<AttributeMatcher>();
expectTypeOf(
missingQualifiedHTMLName,
).toEqualTypeOf<NormalizedNameMatcher>();
expectTypeOf(pseudoWithValue).toEqualTypeOf<PseudoMatcher>();
});
});
Loading