Skip to content

refactor: extract isComponentInvocation util (fix native-HTML-tag misclassification)#31

Closed
johanrd wants to merge 1 commit intomasterfrom
fix/is-component-invocation-util
Closed

refactor: extract isComponentInvocation util (fix native-HTML-tag misclassification)#31
johanrd wants to merge 1 commit intomasterfrom
fix/is-component-invocation-util

Conversation

@johanrd
Copy link
Copy Markdown
Owner

@johanrd johanrd commented Apr 21, 2026

  • Premise: Ember component invocations use PascalCase (<Article>, <Form>). Rules that do node.tag.toLowerCase() and compare to a set of native HTML tag names misclassify these.
  • Problem: <Article role="button"> flagged as non-interactive→interactive-role (audit B5); <Article tabindex={{0}}> flagged as non-interactive tabindex (audit B8).

Fix: extract the existing inline guard (from template-no-invalid-interactive and template-no-empty-headings) into a shared util. No behavior change in those two rules.

Follow-up: 5 rules on open PR branches (#19, #20, #21, #22, #24) also need to adopt this util. Tracked separately — those adoptions will land when each PR merges.

Ref: audit tracking PR #28 item F2.

…g discrimination

Several accessibility rules lowercase `node.tag` and compare it against
a set of native HTML tag names. That misclassifies PascalCase Ember
component invocations whose names happen to match a native HTML element
(e.g. `<Article>`, `<Form>`, `<Main>`, `<Nav>`, `<Ul>`, `<Li>`,
`<Aside>`, `<Section>`, `<Table>`), producing false positives.

Concrete false positives confirmed in the Phase 3 audit:
- B5: `<Article role="button">` flagged as non-interactive-to-interactive-role.
- B8: `<Article tabindex={{0}}>` flagged as non-interactive-tabindex.

Extract the existing inline guard from `template-no-invalid-interactive`
and `template-no-empty-headings` into a single shared util,
`lib/utils/is-component-invocation.js`. The util recognises PascalCase,
named-arg (`<@x>`), this-path (`<this.x>`), dot-path (`<a.b>`), and
named-block (`<Foo::Bar>`) invocations.

This refactor is behaviour-neutral for these two rules; their test
suites are unchanged and still pass.

Follow-up: five more rules also need to adopt this util —
- template-no-aria-hidden-on-focusable
- template-no-interactive-element-to-noninteractive-role
- template-no-noninteractive-element-to-interactive-role
- template-no-role-presentation-on-focusable
- template-no-noninteractive-tabindex

Those rules currently live on open PR branches (#19, #20, #21, #22, #24)
and will adopt the util in separate follow-up PRs once each feature PR
merges.

Ref: audit tracking PR #28 item F2.
@github-actions
Copy link
Copy Markdown

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
🟢 js small 14.01 ms 13.54 ms -3.4%
🟢 js medium 6.89 ms 6.61 ms -4.1%
🟢 js large 2.67 ms 2.60 ms -2.5%
🟢 gjs small 1.15 ms 1.11 ms -2.9%
gjs medium 555.42 µs 553.31 µs -0.4%
gjs large 218.73 µs 218.62 µs -0.1%
gts small 1.11 ms 1.10 ms -0.7%
gts medium 553.14 µs 552.28 µs -0.2%
gts large 220.01 µs 219.05 µs -0.4%

🟢 faster · 🔴 slower · 🟠 slightly slower · ⚪ within 2%

Full mitata output
clk: ~2.09 GHz
cpu: AMD EPYC 9V74 80-Core Processor
runtime: node 24.14.1 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
------------------------------------------- -------------------------------
js small (control)            16.90 ms/iter  17.80 ms █                    
                      (11.49 ms … 35.24 ms)  35.06 ms █▃█▆                 
                    (  5.23 mb …  10.65 mb)   7.27 mb ████▄▆▄▁▁▄▄▄▁▄▆▁▁▁▁▁▄

js small (experiment)         14.14 ms/iter  15.00 ms ▄ ██ ▄               
                      (11.99 ms … 21.14 ms)  19.68 ms █ ████  █            
                    (  6.17 mb …   8.31 mb)   6.83 mb ███████▅█▅▅█▁▅▁▁▅▁▁▁▅

                             ┌                                            ┐
                             ╷┌────────┬─┐                                ╷
          js small (control) ├┤        │ ├────────────────────────────────┤
                             ╵└────────┴─┘                                ╵
                              ╷┌──┬─┐        ╷
       js small (experiment)  ├┤  │ ├────────┤
                              ╵└──┴─┘        ╵
                             └                                            ┘
                             11.49 ms           23.28 ms           35.06 ms

summary
  js small (experiment)
   1.2x faster than js small (control)

------------------------------------------- -------------------------------
js medium (control)            8.09 ms/iter   8.81 ms ██                   
                       (6.22 ms … 18.88 ms)  14.26 ms ███                  
                    (  2.83 mb …   4.66 mb)   3.53 mb ███▅▄▂█▄▂▄▂▄▄▁▂▄▁▄▁▂▄

js medium (experiment)         7.27 ms/iter   7.63 ms ▂█                   
                       (6.20 ms … 15.67 ms)  14.11 ms ██                   
                    (  2.05 mb …   4.93 mb)   3.51 mb ██▆▃▅▅▅▂▁▁▁▂▂▁▁▂▁▁▁▁▂

                             ┌                                            ┐
                             ╷┌─────────┬───┐                             ╷
         js medium (control) ├┤         │   ├─────────────────────────────┤
                             ╵└─────────┴───┘                             ╵
                             ╷┌────┬─┐                                   ╷
      js medium (experiment) ├┤    │ ├───────────────────────────────────┤
                             ╵└────┴─┘                                   ╵
                             └                                            ┘
                             6.20 ms           10.23 ms            14.26 ms

summary
  js medium (experiment)
   1.11x faster than js medium (control)

------------------------------------------- -------------------------------
js large (control)             3.16 ms/iter   3.18 ms  █                   
                        (2.26 ms … 9.40 ms)   8.34 ms  █                   
                    (718.98 kb …   2.87 mb)   1.44 mb ▇██▃▃▃▂▂▂▁▂▁▂▁▁▁▁▁▁▁▁

js large (experiment)          2.90 ms/iter   2.75 ms  █                   
                        (2.37 ms … 7.84 ms)   6.19 ms ▂█                   
                    (354.56 kb …   2.58 mb)   1.44 mb ███▃▂▃▂▂▂▁▁▁▂▁▁▁▁▂▁▁▁

                             ┌                                            ┐
                             ╷ ┌────┬                                     ╷
          js large (control) ├─┤    │─────────────────────────────────────┤
                             ╵ └────┴                                     ╵
                              ╷┌──┬                       ╷
       js large (experiment)  ├┤  │───────────────────────┤
                              ╵└──┴                       ╵
                             └                                            ┘
                             2.26 ms            5.30 ms             8.34 ms

summary
  js large (experiment)
   1.09x faster than js large (control)

------------------------------------------- -------------------------------
gjs small (control)            1.39 ms/iter   1.22 ms █                    
                        (1.09 ms … 7.38 ms)   6.16 ms █                    
                    (259.17 kb …   1.66 mb)   1.06 mb █▄▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs small (experiment)         1.23 ms/iter   1.13 ms █                    
                        (1.09 ms … 6.26 ms)   5.15 ms █                    
                    (224.02 kb …   1.73 mb)   1.06 mb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌──┬                                         ╷
         gjs small (control) │  │─────────────────────────────────────────┤
                             └──┴                                         ╵
                             ┌┬                                  ╷
      gjs small (experiment) ││──────────────────────────────────┤
                             └┴                                  ╵
                             └                                            ┘
                             1.09 ms            3.62 ms             6.16 ms

summary
  gjs small (experiment)
   1.14x faster than gjs small (control)

------------------------------------------- -------------------------------
gjs medium (control)         602.44 µs/iter 565.02 µs █                    
                      (534.54 µs … 5.58 ms)   2.97 ms █                    
                    ( 54.64 kb …   1.11 mb) 542.39 kb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      604.01 µs/iter 559.54 µs █                    
                      (534.74 µs … 6.01 ms)   1.54 ms █                    
                    ( 11.69 kb …   1.19 mb) 540.62 kb █▇▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
        gjs medium (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌┬                 ╷
     gjs medium (experiment) ││─────────────────┤
                             └┴                 ╵
                             └                                            ┘
                             534.54 µs           1.75 ms            2.97 ms

summary
  gjs medium (control)
   1x faster than gjs medium (experiment)

------------------------------------------- -------------------------------
gjs large (control)          238.15 µs/iter 225.43 µs  ▆█                  
                      (210.96 µs … 5.02 ms) 283.10 µs  ██▃                 
                    (216.09 kb … 921.80 kb) 217.18 kb ▂███▆█▆▂▂▁▁▁▁▁▁▁▁▁▁▁▁

gjs large (experiment)       240.24 µs/iter 225.73 µs  █                   
                      (212.24 µs … 5.50 ms) 317.47 µs  █                   
                    (183.83 kb …   1.37 mb) 216.75 kb ▆█▆▆▅▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌────────┬                  ╷
         gjs large (control) ├─┤        │──────────────────┤
                             ╵ └────────┴                  ╵
                              ╷┌─────────┬                                ╷
      gjs large (experiment)  ├┤         │────────────────────────────────┤
                              ╵└─────────┴                                ╵
                             └                                            ┘
                             210.96 µs         264.21 µs          317.47 µs

summary
  gjs large (control)
   1.01x faster than gjs large (experiment)

------------------------------------------- -------------------------------
gts small (control)            1.20 ms/iter   1.12 ms █                    
                        (1.08 ms … 7.00 ms)   5.42 ms █                    
                    (617.53 kb …   1.53 mb)   1.06 mb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)         1.19 ms/iter   1.11 ms █                    
                        (1.07 ms … 6.39 ms)   5.41 ms █                    
                    (220.06 kb …   1.91 mb)   1.05 mb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
         gts small (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌┬                                           ╷
      gts small (experiment) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             └                                            ┘
                             1.07 ms            3.25 ms             5.42 ms

summary
  gts small (experiment)
   1.01x faster than gts small (control)

------------------------------------------- -------------------------------
gts medium (control)         596.49 µs/iter 561.09 µs ▇█                   
                      (533.20 µs … 5.43 ms)   1.30 ms ██                   
                    ( 31.95 kb …   1.21 mb) 541.66 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts medium (experiment)      600.48 µs/iter 560.19 µs █▃                   
                      (531.51 µs … 5.81 ms)   1.41 ms ██                   
                    ( 54.26 kb …   1.23 mb) 540.96 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌─┬                                   ╷
        gts medium (control) ├┤ │───────────────────────────────────┤
                             ╵└─┴                                   ╵
                             ╷┌──┬                                        ╷
     gts medium (experiment) ├┤  │────────────────────────────────────────┤
                             ╵└──┴                                        ╵
                             └                                            ┘
                             531.51 µs          969.39 µs           1.41 ms

summary
  gts medium (control)
   1.01x faster than gts medium (experiment)

------------------------------------------- -------------------------------
gts large (control)          241.14 µs/iter 227.07 µs  █                   
                      (213.08 µs … 5.18 ms) 303.93 µs  █▅                  
                    (182.85 kb … 934.19 kb) 217.07 kb ▄██▄▇▄▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts large (experiment)       240.14 µs/iter 225.56 µs  █                   
                      (212.52 µs … 5.07 ms) 296.00 µs  █▇                  
                    (105.95 kb … 776.45 kb) 216.41 kb ▂██▃▇▅▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌───────────┬                              ╷
         gts large (control) ├─┤           │──────────────────────────────┤
                             ╵ └───────────┴                              ╵
                             ╷ ┌───────────┬                          ╷
      gts large (experiment) ├─┤           │──────────────────────────┤
                             ╵ └───────────┴                          ╵
                             └                                            ┘
                             212.52 µs         258.22 µs          303.93 µs

summary
  gts large (experiment)
   1x faster than gts large (control)

johanrd added a commit that referenced this pull request Apr 21, 2026
Adopts the shared utils from PRs #31 and #37. Bit-identical copies so
both PRs merge cleanly regardless of order. Rule behavior unchanged.
johanrd added a commit that referenced this pull request Apr 21, 2026
Migrate template-no-aria-hidden-on-focusable to the shared utility helpers
introduced by PR #31 (isComponentInvocation) and PR #37
(isNativeInteractive):

- isFocusable() now delegates the native-focusable-tag check to
  isNativeInteractive(node, getTextAttrValue). The local
  INHERENTLY_FOCUSABLE_TAGS set and the inline a[href] branch are
  removed.
- hasFocusableDescendant()'s opaque-tag skip (added in G5.1) now uses
  isComponentInvocation(child) in place of the inline isOpaqueTag
  predicate; the local isOpaqueTag helper is removed.

Behavior delta (spec-correct FN fix):

- Previously <video controls> and <audio controls> were absent from the
  local INHERENTLY_FOCUSABLE_TAGS, so
  <div aria-hidden="true"><video controls></video></div> was VALID.
- isNativeInteractive returns true for audio[controls] / video[controls]
  (browsers only render focusable media UI when controls is present).
  Such patterns are now FLAGGED under noAriaHiddenOnAncestorOfFocusable,
  and the element directly (<video controls aria-hidden="true">) is
  FLAGGED under noAriaHiddenOnFocusable.
- Audio/video without controls remain VALID (no native focusable UI).

Tests: new invalid cases for audio/video with controls directly
aria-hidden and as descendants of aria-hidden wrappers, in both gts and
hbs suites. New valid cases for audio/video without controls to pin the
conditional behavior.
johanrd added a commit that referenced this pull request Apr 21, 2026
Replace the rule's inline INHERENTLY_FOCUSABLE_TAGS set and ad-hoc tag
checks with the two shared utils:

- lib/utils/is-component-invocation.js (from PR #31)
- lib/utils/native-interactive-elements.js (from PR #37)

Both files (and their tests) are copied bit-identically from their source
branches so parity is preserved while those PRs remain open.

Behavior deltas introduced by the util swap
-------------------------------------------
The prior inline set was {button, details, embed, iframe, input, select,
summary, textarea}. The shared util covers the same set plus several
additional native-interactive tags that were previously false negatives:

- option, datalist, object, canvas — now recognized as native-interactive
- area[href]                       — now recognized (symmetric with a[href])
- audio[controls], video[controls] — now recognized (per HTML-AAM / browser
  reality; keyboard-operable transport controls)

Net effect: `role="presentation"` / `role="none"` on any of the above is
now flagged where it wasn't before. All of these are spec-correct FN fixes
(WAI-ARIA 1.2 §4.6 conflict resolution applies the same way once the
element is acknowledged as focusable).

Tests added for representative new cases:
- <video controls role="presentation"> — flags (gts + hbs)
- <audio controls role="none">         — flags (gts)
- <area href="/x" role="presentation"> — flags (gts)
- <video role="presentation"> (no controls) — valid (still not focusable)

No deltas for <label>: it was not in the prior INHERENTLY_FOCUSABLE_TAGS
set and it is not in the shared util either, so behavior is unchanged.

Component-invocation handling is now an explicit early-return via
isComponentInvocation(node), which also excludes named-arg (<@slot>),
this-path (<this.widget>), and dot-path (<foo.bar>) invocations that were
previously only excluded incidentally by the tag-lowercase lookup.
@johanrd
Copy link
Copy Markdown
Owner Author

johanrd commented Apr 21, 2026

Moved upstream to ember-cli#2724. See that PR for ongoing review.

@johanrd johanrd closed this Apr 21, 2026
johanrd added a commit that referenced this pull request Apr 26, 2026
Migrate template-no-aria-hidden-on-focusable to the shared utility helpers
introduced by PR #31 (isComponentInvocation) and PR #37
(isNativeInteractive):

- isFocusable() now delegates the native-focusable-tag check to
  isNativeInteractive(node, getTextAttrValue). The local
  INHERENTLY_FOCUSABLE_TAGS set and the inline a[href] branch are
  removed.
- hasFocusableDescendant()'s opaque-tag skip (added in G5.1) now uses
  isComponentInvocation(child) in place of the inline isOpaqueTag
  predicate; the local isOpaqueTag helper is removed.

Behavior delta (spec-correct FN fix):

- Previously <video controls> and <audio controls> were absent from the
  local INHERENTLY_FOCUSABLE_TAGS, so
  <div aria-hidden="true"><video controls></video></div> was VALID.
- isNativeInteractive returns true for audio[controls] / video[controls]
  (browsers only render focusable media UI when controls is present).
  Such patterns are now FLAGGED under noAriaHiddenOnAncestorOfFocusable,
  and the element directly (<video controls aria-hidden="true">) is
  FLAGGED under noAriaHiddenOnFocusable.
- Audio/video without controls remain VALID (no native focusable UI).

Tests: new invalid cases for audio/video with controls directly
aria-hidden and as descendants of aria-hidden wrappers, in both gts and
hbs suites. New valid cases for audio/video without controls to pin the
conditional behavior.
johanrd added a commit that referenced this pull request Apr 26, 2026
Adopts the shared utils from PRs #31 and #37. Bit-identical copies so
both PRs merge cleanly regardless of order. Rule behavior unchanged.
johanrd added a commit that referenced this pull request Apr 26, 2026
Replace the rule's inline INHERENTLY_FOCUSABLE_TAGS set and ad-hoc tag
checks with the two shared utils:

- lib/utils/is-component-invocation.js (from PR #31)
- lib/utils/native-interactive-elements.js (from PR #37)

Both files (and their tests) are copied bit-identically from their source
branches so parity is preserved while those PRs remain open.

Behavior deltas introduced by the util swap
-------------------------------------------
The prior inline set was {button, details, embed, iframe, input, select,
summary, textarea}. The shared util covers the same set plus several
additional native-interactive tags that were previously false negatives:

- option, datalist, object, canvas — now recognized as native-interactive
- area[href]                       — now recognized (symmetric with a[href])
- audio[controls], video[controls] — now recognized (per HTML-AAM / browser
  reality; keyboard-operable transport controls)

Net effect: `role="presentation"` / `role="none"` on any of the above is
now flagged where it wasn't before. All of these are spec-correct FN fixes
(WAI-ARIA 1.2 §4.6 conflict resolution applies the same way once the
element is acknowledged as focusable).

Tests added for representative new cases:
- <video controls role="presentation"> — flags (gts + hbs)
- <audio controls role="none">         — flags (gts)
- <area href="/x" role="presentation"> — flags (gts)
- <video role="presentation"> (no controls) — valid (still not focusable)

No deltas for <label>: it was not in the prior INHERENTLY_FOCUSABLE_TAGS
set and it is not in the shared util either, so behavior is unchanged.

Component-invocation handling is now an explicit early-return via
isComponentInvocation(node), which also excludes named-arg (<@slot>),
this-path (<this.widget>), and dot-path (<foo.bar>) invocations that were
previously only excluded incidentally by the tag-lowercase lookup.
johanrd added a commit that referenced this pull request Apr 27, 2026
Adopts the shared utils from PRs #31 and #37. Bit-identical copies so
both PRs merge cleanly regardless of order. Rule behavior unchanged.
johanrd added a commit that referenced this pull request Apr 27, 2026
Migrate template-no-aria-hidden-on-focusable to the shared utility helpers
introduced by PR #31 (isComponentInvocation) and PR #37
(isNativeInteractive):

- isFocusable() now delegates the native-focusable-tag check to
  isNativeInteractive(node, getTextAttrValue). The local
  INHERENTLY_FOCUSABLE_TAGS set and the inline a[href] branch are
  removed.
- hasFocusableDescendant()'s opaque-tag skip (added in G5.1) now uses
  isComponentInvocation(child) in place of the inline isOpaqueTag
  predicate; the local isOpaqueTag helper is removed.

Behavior delta (spec-correct FN fix):

- Previously <video controls> and <audio controls> were absent from the
  local INHERENTLY_FOCUSABLE_TAGS, so
  <div aria-hidden="true"><video controls></video></div> was VALID.
- isNativeInteractive returns true for audio[controls] / video[controls]
  (browsers only render focusable media UI when controls is present).
  Such patterns are now FLAGGED under noAriaHiddenOnAncestorOfFocusable,
  and the element directly (<video controls aria-hidden="true">) is
  FLAGGED under noAriaHiddenOnFocusable.
- Audio/video without controls remain VALID (no native focusable UI).

Tests: new invalid cases for audio/video with controls directly
aria-hidden and as descendants of aria-hidden wrappers, in both gts and
hbs suites. New valid cases for audio/video without controls to pin the
conditional behavior.
johanrd added a commit that referenced this pull request Apr 27, 2026
Replace the rule's inline INHERENTLY_FOCUSABLE_TAGS set and ad-hoc tag
checks with the two shared utils:

- lib/utils/is-component-invocation.js (from PR #31)
- lib/utils/native-interactive-elements.js (from PR #37)

Both files (and their tests) are copied bit-identically from their source
branches so parity is preserved while those PRs remain open.

Behavior deltas introduced by the util swap
-------------------------------------------
The prior inline set was {button, details, embed, iframe, input, select,
summary, textarea}. The shared util covers the same set plus several
additional native-interactive tags that were previously false negatives:

- option, datalist, object, canvas — now recognized as native-interactive
- area[href]                       — now recognized (symmetric with a[href])
- audio[controls], video[controls] — now recognized (per HTML-AAM / browser
  reality; keyboard-operable transport controls)

Net effect: `role="presentation"` / `role="none"` on any of the above is
now flagged where it wasn't before. All of these are spec-correct FN fixes
(WAI-ARIA 1.2 §4.6 conflict resolution applies the same way once the
element is acknowledged as focusable).

Tests added for representative new cases:
- <video controls role="presentation"> — flags (gts + hbs)
- <audio controls role="none">         — flags (gts)
- <area href="/x" role="presentation"> — flags (gts)
- <video role="presentation"> (no controls) — valid (still not focusable)

No deltas for <label>: it was not in the prior INHERENTLY_FOCUSABLE_TAGS
set and it is not in the shared util either, so behavior is unchanged.

Component-invocation handling is now an explicit early-return via
isComponentInvocation(node), which also excludes named-arg (<@slot>),
this-path (<this.widget>), and dot-path (<foo.bar>) invocations that were
previously only excluded incidentally by the tag-lowercase lookup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant