fix(aria): prevent getOwnedVirtual from returning duplicate nodes - #4987
Conversation
When a child element is also referenced via aria-owns, getOwnedVirtual returned the same node twice. Filter aria-owns references that already exist in children using Set. Closes: dequelabs#4840
WilcoFiers
left a comment
There was a problem hiding this comment.
@camiha Thank you for this pull request. Really appreciate it. The difficulty with this, as is the case for most axe-core issues is that there are subtle edge cases to consider. Do you have access to screen readers to be able to test for the cases I mentioned? If not I can see if we can do the testing.
| const childNodeSet = new Set(children.map(child => child.actualNode)); | ||
| const uniqueOwns = owns.filter(own => !childNodeSet.has(own.actualNode)); | ||
|
|
||
| return [...children, ...uniqueOwns]; |
There was a problem hiding this comment.
I'm not sure the order is correct this way. This needs to be tested, but I'd wager that if you have child A and B, and you then use aria-owns on A, it'll change the owned elements order to B, A.
There was a problem hiding this comment.
I've verified with Safari + VoiceOver and updated accordingly. I also added a test case for this behavior (with aria-owns="c a" and children a, b, c, the order becomes b → c → a).
| const childNodeSet = new Set(children.map(child => child.actualNode)); | ||
| const uniqueOwns = owns.filter(own => !childNodeSet.has(own.actualNode)); |
There was a problem hiding this comment.
We don't need actualNode or Set here. This can be done with children.includes(owned)
There was a problem hiding this comment.
Simplified to use includes() directly.
| if (virtualNode.hasAttr('aria-owns')) { | ||
| const owns = idrefs(actualNode, 'aria-owns') | ||
| .filter(element => !!element) | ||
| .map(element => axe.utils.getNodeFromTree(element)); |
There was a problem hiding this comment.
I don't think this fully solves the problem of ensuring this function doesn't return the same node twice. You've solved it for returning a child that's also aria-owns, but not for the same node getting picked by aria-owns twice. I think to solve that you need a different solution.
What I don't know is if I do aria-owns="A B A" whether the order I get in the accessibility tree is "A B" or "B A". This should be tested before implementing this, and we'll probably want to leave a comment about it in the code to have a record of that.
There was a problem hiding this comment.
Updated to deduplicate by first occurrence and added a comment documenting this behavior.
- Deduplicate aria-owns references by first occurrence - Move owned children to end to match browser accessibility tree behavior
|
@WilcoFiers I tested the edge cases you mentioned with Safari (26.2) + VoiceOver.
I've updated the implementation accordingly. I don't have access to JAWS or NVDA testing environments, so if the behavior differs, please let me know and I'll make adjustments. |
WilcoFiers
left a comment
There was a problem hiding this comment.
Excellent work. Thank you @camiha!
For the books; reviewed for security.
) When a child element is also referenced via aria-owns, getOwnedVirtual returned the same node twice. Filter aria-owns references that already exist in children using Set. Closes: #4840 --------- Co-authored-by: Wilco Fiers <[email protected]>
) When a child element is also referenced via aria-owns, getOwnedVirtual returned the same node twice. Filter aria-owns references that already exist in children using Set. Closes: #4840 --------- Co-authored-by: Wilco Fiers <[email protected]>
### [4.11.2](v4.11.1...v4.11.2) (2026-03-30) ### Bug Fixes - **aria-valid-attr-value:** handle multiple aria-errormessage IDs ([#4973](#4973)) ([9322148](9322148)) - **aria:** prevent getOwnedVirtual from returning duplicate nodes ([#4987](#4987)) ([99d1e77](99d1e77)), closes [#4840](#4840) - **DqElement:** avoid calling constructors with cloneNode ([#5013](#5013)) ([88bc57f](88bc57f)) - **existing-rule:** aria-busy now shows an error message for a use with unallowed children ([#5017](#5017)) ([dded75a](dded75a)) - **scrollable-region-focusable:** clarify the issue is in safari ([#4995](#4995)) ([2567afd](2567afd)), closes [WebKit#190870](https://github.com/dequelabs/WebKit/issues/190870) [WebKit#277290](https://github.com/dequelabs/WebKit/issues/277290) - **scrollable-region-focusable:** do not fail scroll areas when all content is visible without scrolling ([#4993](#4993)) ([240f8b5](240f8b5)) - **target-size:** determine offset using clientRects if target is display:inline ([#5012](#5012)) ([69d81c1](69d81c1)) - **target-size:** ignore widgets that are inline with other inline elements ([#5000](#5000)) ([cf8a3c0](cf8a3c0))
## [4.12.0](v4.11.4...v4.12.0) (2026-06-01) ### Features - add gather-internals.js external script ([#5099](#5099)) ([c61d58b](c61d58b)), closes [#5080](#5080) - **aria-allowed/prohibited-attr, aria-required-parent/children:** partially support element internals role ([#5080](#5080)) ([417b48a](417b48a)), closes [#5039](#5039) [#4259](#4259) - **axe.externalAPIs:** add public api for setting elementInternal data ([#5105](#5105)) ([63bab8f](63bab8f)) - **core:** expose normalizeRunOptions ([#4998](#4998)) ([b8e6a59](b8e6a59)) - expose axe.resetLocale() to restore the default locale ([#5108](#5108)) ([c2b5292](c2b5292)), closes [#5107](#5107) - **getRules:** include rule enabled state in returned objects ([#5118](#5118)) ([75bf772](75bf772)), closes [#5116](#5116) - **list,listitem:** support element internals role ([#5119](#5119)) ([7d9d696](7d9d696)) - **new-rule:** check that aria-tab have an accessible name ([#5001](#5001)) ([0d4e4e7](0d4e4e7)), closes [#4842](#4842) - **rules:** deprecate landmark-complementary-is-top-level rules ([#4992](#4992)) ([9e09139](9e09139)), closes [#4950](#4950) - **utils:** add `getElementInternals` function ([#5077](#5077)) ([1c15f82](1c15f82)) ### Bug Fixes - **aria-allowed-attr:** restrict br and wbr elements to aria-hidden only ([#4974](#4974)) ([c6245e7](c6245e7)) - **aria-conditional-attr:** add support for radio ([#5100](#5100)) ([8223c98](8223c98)) - **aria-valid-attr-value:** handle multiple aria-errormessage IDs ([#4973](#4973)) ([0489e30](0489e30)) - **aria:** prevent getOwnedVirtual from returning duplicate nodes ([#4987](#4987)) ([48ca955](48ca955)), closes [#4840](#4840) - **commons/text:** exclude natively hidden elements from aria-labelledby accessible name ([#5076](#5076)) ([ea7202c](ea7202c)), closes [#4704](#4704) - **DqElement:** avoid calling constructors with cloneNode ([#5013](#5013)) ([0281fa1](0281fa1)) - **existing-rule:** aria-busy now shows an error message for a use with unallowed children ([#5017](#5017)) ([2067b87](2067b87)) - **helpUrl:** ensure axe.configure always updates the help URLs ([#5114](#5114)) ([c4f60ff](c4f60ff)) - **label-content-name-mismatch:** match visible text with aria-label and exclude invisible text ([#5096](#5096)) ([3a012a1](3a012a1)) - **locale:** ensure all subtags are correctly set ([#5112](#5112)) ([13005ed](13005ed)) - **scrollable-region-focusable:** clarify the issue is in safari ([#4995](#4995)) ([4ec5211](4ec5211)), closes [WebKit#190870](https://github.com/dequelabs/WebKit/issues/190870) [WebKit#277290](https://github.com/dequelabs/WebKit/issues/277290) - **scrollable-region-focusable:** do not fail scroll areas when all content is visible without scrolling ([#4993](#4993)) ([838707a](838707a)) - **target-size:** determine offset using clientRects if target is display:inline ([#5012](#5012)) ([a4b8091](a4b8091)) - **target-size:** ignore position: fixed elements that are offscreen when page is scrolled ([#5066](#5066)) ([1229a6e](1229a6e)), closes [#5065](#5065) - **target-size:** ignore widgets that are inline with other inline elements ([#5000](#5000)) ([a8dd81b](a8dd81b)) - **utils/getAncestry:** escape node name ([#5079](#5079)) ([d1fabaa](d1fabaa)), closes [#5078](#5078) - **utils:** Add null check to parseCrossOriginStylesheet, closes [#5074](#5074) ([#5075](#5075)) ([f12ef32](f12ef32)) - **utils:** update isShadowRoot to use spec-compliant custom element regex ([#5059](#5059)) ([edc6ce2](edc6ce2)), closes [#5030](#5030) This PR was opened by a robot 🤖 🎉
When a child element is also referenced via aria-owns, getOwnedVirtual returned the same node twice. Filter aria-owns references that already exist in children using Set.
Closes: #4840