Skip to content

Commit eba7bd4

Browse files
committed
refactor: align component check with ember-cli#2724 canonical (lists + scope)
Replace the inline PascalCase-regex isComponentInvocation heuristic with the lists + scope pattern established by @NullVoxPopuli in ember-cli#2689 and canonicalised by ember-cli#2724. The new lib/utils/is-native-element.js mirrors ember-cli#2724's util byte-for-byte so the two PRs can land in either order without conflict — whichever lands first introduces the file; the other rebases to a no-op on it. Heuristic approaches (PascalCase regex, `tag.includes('.')`, etc.) were explicitly rejected in ember-cli#2689 because lowercase tags CAN be components in GJS/GTS when the name is shadowed by a scope binding (`const div = MyComponent; <div />`). Scope analysis catches this; regex heuristics don't. Call site now uses `!isNativeElement(node, sourceCode)` directly to match the canonical usage pattern in ember-cli#2724's migrated rules.
1 parent 784ca12 commit eba7bd4

5 files changed

Lines changed: 159 additions & 97 deletions

File tree

lib/rules/template-no-noninteractive-tabindex.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const { dom, roles } = require('aria-query');
2-
const { isComponentInvocation } = require('../utils/is-component-invocation');
2+
const { isNativeElement } = require('../utils/is-native-element');
33
const { isNativeInteractive } = require('../utils/native-interactive-elements');
44

55
// Interactive ARIA roles (widget/command/composite/input/range subtypes) —
@@ -100,18 +100,20 @@ module.exports = {
100100
},
101101

102102
create(context) {
103+
const sourceCode = context.sourceCode || context.getSourceCode();
103104
return {
104105
GlimmerElementNode(node) {
105106
const tabindex = findAttr(node, 'tabindex');
106107
if (!tabindex) {
107108
return;
108109
}
109110

110-
// Skip component invocations (PascalCase, named-arg, this-path,
111-
// dot-path, named-block). Fixes the <Article tabindex={{0}}> FP where
112-
// a PascalCase component name collided with a native tag after
111+
// Only native HTML / SVG / MathML elements are in scope. Authoritative
112+
// tag lists (html-tags + svg-tags + mathml-tag-names) plus scope-
113+
// shadowing detection. Fixes the <Article tabindex={{0}}> FP where a
114+
// PascalCase component name collided with a native tag after
113115
// lowercasing.
114-
if (isComponentInvocation(node)) {
116+
if (!isNativeElement(node, sourceCode)) {
115117
return;
116118
}
117119

lib/utils/is-component-invocation.js

Lines changed: 0 additions & 24 deletions
This file was deleted.

lib/utils/is-native-element.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use strict';
2+
3+
const htmlTags = require('html-tags');
4+
const svgTags = require('svg-tags');
5+
const { mathmlTagNames } = require('mathml-tag-names');
6+
7+
// Authoritative set of native element tag names. Mirrors the approach
8+
// established by #2689 (template-no-block-params-for-html-elements), which
9+
// the maintainer requires for component-vs-element discrimination in this
10+
// plugin. Heuristic approaches (PascalCase detection, etc.) were explicitly
11+
// rejected there because a lowercase tag CAN be a component in GJS/GTS when
12+
// the name is bound in scope (e.g. `const div = MyComponent; <div />`).
13+
const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
14+
15+
/**
16+
* Returns true if the Glimmer element node is a native HTML / SVG / MathML
17+
* element — i.e. the tag name is in the authoritative list AND is not
18+
* shadowed by an in-scope binding.
19+
*
20+
* Returns false for:
21+
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
22+
* none of these tag names appear in the HTML/SVG/MathML lists)
23+
* - custom elements (`<my-widget>`) — accepted false negative; the web-
24+
* components namespace is open and can't be enumerated
25+
* - scope-bound identifiers (`<div>` when `div` is a local `let` / `const` /
26+
* import / block-param in the enclosing scope)
27+
*
28+
* @param {object} node - GlimmerElementNode
29+
* @param {object} [sourceCode] - ESLint SourceCode, for scope lookup. When
30+
* omitted, the scope check is skipped (the result is then list-based only —
31+
* suitable for unit tests).
32+
*/
33+
function isNativeElement(node, sourceCode) {
34+
if (!node || typeof node.tag !== 'string') {
35+
return false;
36+
}
37+
if (!ELEMENT_TAGS.has(node.tag)) {
38+
return false;
39+
}
40+
if (!sourceCode || !node.parent) {
41+
return true;
42+
}
43+
const scope = sourceCode.getScope(node.parent);
44+
const firstPart = node.parts && node.parts[0];
45+
if (firstPart && scope.references.some((ref) => ref.identifier === firstPart)) {
46+
return false;
47+
}
48+
return true;
49+
}
50+
51+
/**
52+
* Inverse of {@link isNativeElement}. Returns true when the node should NOT
53+
* be treated as a native HTML element — either because it's a component
54+
* invocation (PascalCase, dotted, @-prefixed, this.-prefixed, custom element)
55+
* OR a tag name that's shadowed by a scope binding.
56+
*/
57+
function isComponentInvocation(node, sourceCode) {
58+
return !isNativeElement(node, sourceCode);
59+
}
60+
61+
module.exports = { isNativeElement, isComponentInvocation, ELEMENT_TAGS };

tests/lib/utils/is-component-invocation-test.js

Lines changed: 0 additions & 68 deletions
This file was deleted.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use strict';
2+
3+
const { isNativeElement, ELEMENT_TAGS } = require('../../../lib/utils/is-native-element');
4+
5+
// Tests exercise the list-lookup path only. Scope-based shadowing is covered
6+
// by the rule-level test suites (see tests/lib/rules/template-no-block-params-
7+
// for-html-elements.js and siblings) because it requires a real ESLint
8+
// SourceCode / scope manager that's only built up by the rule tester.
9+
10+
describe('isNativeElement — list-only behavior (no sourceCode)', () => {
11+
it('returns true for lowercase HTML tag names', () => {
12+
expect(isNativeElement({ tag: 'div' })).toBe(true);
13+
expect(isNativeElement({ tag: 'article' })).toBe(true);
14+
expect(isNativeElement({ tag: 'h1' })).toBe(true);
15+
expect(isNativeElement({ tag: 'button' })).toBe(true);
16+
expect(isNativeElement({ tag: 'form' })).toBe(true);
17+
expect(isNativeElement({ tag: 'section' })).toBe(true);
18+
});
19+
20+
it('returns true for SVG tag names', () => {
21+
expect(isNativeElement({ tag: 'svg' })).toBe(true);
22+
expect(isNativeElement({ tag: 'circle' })).toBe(true);
23+
expect(isNativeElement({ tag: 'path' })).toBe(true);
24+
});
25+
26+
it('returns true for MathML tag names', () => {
27+
expect(isNativeElement({ tag: 'mfrac' })).toBe(true);
28+
expect(isNativeElement({ tag: 'math' })).toBe(true);
29+
});
30+
31+
it('returns false for PascalCase component tags', () => {
32+
expect(isNativeElement({ tag: 'Button' })).toBe(false);
33+
expect(isNativeElement({ tag: 'MyWidget' })).toBe(false);
34+
// Native-tag names in PascalCase — the core bug case.
35+
expect(isNativeElement({ tag: 'Article' })).toBe(false);
36+
expect(isNativeElement({ tag: 'Form' })).toBe(false);
37+
expect(isNativeElement({ tag: 'Main' })).toBe(false);
38+
expect(isNativeElement({ tag: 'Nav' })).toBe(false);
39+
expect(isNativeElement({ tag: 'Section' })).toBe(false);
40+
expect(isNativeElement({ tag: 'Table' })).toBe(false);
41+
});
42+
43+
it('returns false for named-arg invocations', () => {
44+
expect(isNativeElement({ tag: '@heading' })).toBe(false);
45+
expect(isNativeElement({ tag: '@tag.foo' })).toBe(false);
46+
});
47+
48+
it('returns false for this-path invocations', () => {
49+
expect(isNativeElement({ tag: 'this.myComponent' })).toBe(false);
50+
expect(isNativeElement({ tag: 'this.comp.sub' })).toBe(false);
51+
});
52+
53+
it('returns false for dot-path invocations', () => {
54+
expect(isNativeElement({ tag: 'foo.bar' })).toBe(false);
55+
expect(isNativeElement({ tag: 'ns.widget' })).toBe(false);
56+
});
57+
58+
it('returns false for named-block / namespaced invocations', () => {
59+
expect(isNativeElement({ tag: 'foo::bar' })).toBe(false);
60+
expect(isNativeElement({ tag: 'Foo::Bar' })).toBe(false);
61+
});
62+
63+
it('returns false for custom elements (accepted false negative)', () => {
64+
// Custom elements aren't in the html-tags/svg-tags/mathml-tag-names
65+
// allowlists. They're treated as "not a native element" so downstream
66+
// rules skip them — matches the convention established in PR #2689.
67+
expect(isNativeElement({ tag: 'my-element' })).toBe(false);
68+
expect(isNativeElement({ tag: 'x-foo' })).toBe(false);
69+
});
70+
71+
it('returns false for empty / missing / non-string tag', () => {
72+
expect(isNativeElement({ tag: '' })).toBe(false);
73+
expect(isNativeElement({ tag: undefined })).toBe(false);
74+
expect(isNativeElement({ tag: null })).toBe(false);
75+
expect(isNativeElement({ tag: 123 })).toBe(false);
76+
expect(isNativeElement({})).toBe(false);
77+
expect(isNativeElement()).toBe(false);
78+
expect(isNativeElement(null)).toBe(false);
79+
});
80+
});
81+
82+
describe('ELEMENT_TAGS', () => {
83+
it('includes all HTML, SVG, and MathML tag names', () => {
84+
// Sanity check — if this ever drops below a reasonable size, one of the
85+
// underlying packages has changed contract.
86+
expect(ELEMENT_TAGS.size).toBeGreaterThan(200);
87+
expect(ELEMENT_TAGS.has('div')).toBe(true);
88+
expect(ELEMENT_TAGS.has('circle')).toBe(true);
89+
expect(ELEMENT_TAGS.has('mfrac')).toBe(true);
90+
});
91+
});

0 commit comments

Comments
 (0)