Skip to content

fix(aria): prevent getOwnedVirtual from returning duplicate nodes - #4987

Merged
WilcoFiers merged 3 commits into
dequelabs:developfrom
camiha:fix/4840-duplicate-aria-owns
Feb 18, 2026
Merged

fix(aria): prevent getOwnedVirtual from returning duplicate nodes#4987
WilcoFiers merged 3 commits into
dequelabs:developfrom
camiha:fix/4840-duplicate-aria-owns

Conversation

@camiha

@camiha camiha commented Jan 11, 2026

Copy link
Copy Markdown
Contributor

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

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
@camiha
camiha requested a review from a team as a code owner January 11, 2026 07:52
@CLAassistant

CLAassistant commented Jan 11, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@WilcoFiers WilcoFiers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Comment thread lib/commons/aria/get-owned-virtual.js Outdated
const childNodeSet = new Set(children.map(child => child.actualNode));
const uniqueOwns = owns.filter(own => !childNodeSet.has(own.actualNode));

return [...children, ...uniqueOwns];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread lib/commons/aria/get-owned-virtual.js Outdated
Comment on lines +21 to +22
const childNodeSet = new Set(children.map(child => child.actualNode));
const uniqueOwns = owns.filter(own => !childNodeSet.has(own.actualNode));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need actualNode or Set here. This can be done with children.includes(owned)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@camiha
camiha requested a review from WilcoFiers January 18, 2026 11:16
@camiha

camiha commented Jan 18, 2026

Copy link
Copy Markdown
Contributor Author

@WilcoFiers
Thank you for the review. This is my first time submitting a code change to an open source project, so I really appreciate the thorough feedback.

I tested the edge cases you mentioned with Safari (26.2) + VoiceOver.

  • Child also in aria-owns
    • with aria-owns="a" and children a, b, I confirmed that VoiceOver reads them in the order b → a.
  • Duplicate IDs in aria-owns
    • with aria-owns="a b a", element A is only announced once, with the first occurrence taking precedence. (a → b)

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 WilcoFiers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work. Thank you @camiha!

For the books; reviewed for security.

@WilcoFiers
WilcoFiers merged commit 48ca955 into dequelabs:develop Feb 18, 2026
22 of 23 checks passed
WilcoFiers added a commit that referenced this pull request Mar 30, 2026
)

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]>
straker pushed a commit that referenced this pull request Mar 30, 2026
)

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]>
@straker straker mentioned this pull request Mar 30, 2026
WilcoFiers added a commit that referenced this pull request Mar 31, 2026
### [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))
WilcoFiers added a commit that referenced this pull request Jun 1, 2026
##
[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 🤖 🎉
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.

aria-required-children can double-report related nodes through aria-owns

3 participants