Skip to content

feat(axe.externalAPIs): add public api for setting elementInternal data - #5105

Merged
straker merged 19 commits into
developfrom
elm-internals-external-apis
May 21, 2026
Merged

feat(axe.externalAPIs): add public api for setting elementInternal data#5105
straker merged 19 commits into
developfrom
elm-internals-external-apis

Conversation

@straker

@straker straker commented May 15, 2026

Copy link
Copy Markdown
Contributor

Closes: #5040

@straker
straker marked this pull request as ready for review May 18, 2026 14:45
@straker
straker requested a review from a team as a code owner May 18, 2026 14:45

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

Nice work on landing a clean public surface for this — the audit-time queueing is well-isolated and the test coverage is broad. A few things to flag before merge:

One critical correctness bug in the option validation (typeof x !== undefined always evaluates true — see inline on lib/core/public/external-apis.js:9). With the current code, axe.externalAPIs({ elementInternalsTimeout: 500 }) or any call that omits elementInternals throws an unhelpful TypeError. None of the existing tests catch this because every test passes a value.

One additional correctness gapnull values inside internals crash the destructure (typeof null === 'object', so the string-skip misses them) and reject the whole audit run. Real ElementInternals payloads can legitimately have null ARIA properties.

A handful of doc fixes (the API heading is spelled axe.externalAPIS in two places, a node_moduels typo, references to a gather-internals.js script that isn't in this PR, missing cross-link to the _enableElementInternals flag prerequisite) and three sync-done() tests that don't actually verify the async path.

Missing artifacts before merge

A few things commonly expected for a new public API (per the repo guidelines) don't appear in this PR:

  • axe.d.ts — no TypeScript definition for axe.externalAPIs. TS consumers won't get completion or compile-time checking on release.
  • doc/API.md — the new axe.externalAPIs entry point isn't linked or referenced from the API reference; the new doc/external-apis.md and doc/element-internals.md are good but don't connect into the existing API surface map.
  • CHANGELOG.md — no entry for what is a meaningful new public API + behavior gate (_enableElementInternals).

Happy to be wrong on any of these if they're tracked in a follow-up — just call it out so reviewers know it's intentional.

Comment thread lib/core/public/external-apis.js Outdated
Comment thread lib/core/public/external-apis.js Outdated
Comment thread doc/external-apis.md Outdated
Comment thread doc/external-apis.md Outdated
Comment thread doc/external-apis.md Outdated
Comment thread doc/external-apis.md

## axe.externalAPIS({ elementInternalsTimeout })

Since gathering ElementInternals data is an async operation, you can configure how long axe-core will wait for `elementInternals` promise to resolve. By default the timeout is set to 1 second. If the timeout occurs axe-core will not run and will throw an error.

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.

"will throw an error" — the implementation rejects the Promise rather than throwing synchronously. Consumers reading this would wire try/catch around the axe.run call site, which won't catch a Promise rejection.

Suggested change
Since gathering ElementInternals data is an async operation, you can configure how long axe-core will wait for `elementInternals` promise to resolve. By default the timeout is set to 1 second. If the timeout occurs axe-core will not run and will throw an error.
Since gathering ElementInternals data is an async operation, you can configure how long axe-core will wait for `elementInternals` promise to resolve. By default the timeout is set to 1 second. If the timeout occurs `axe.run` will reject with an error.

@straker straker May 18, 2026

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.

A rejected promise during the audit bubbles up to a throw in axe-core using a callback function, but rejects if using a promise. Not sure which would be the best wording in this case.

Comment thread doc/element-internals.md Outdated
Comment thread test/core/public/external-apis.js Outdated
Comment thread test/core/public/external-apis.js Outdated
Comment thread test/core/public/external-apis.js Outdated
straker and others added 7 commits May 18, 2026 15:52
Comment thread lib/core/public/external-apis.js
Comment thread lib/core/public/external-apis.js Outdated
Comment thread lib/core/public/external-apis.js Outdated
Comment thread lib/core/public/external-apis.js Outdated
Comment thread lib/core/public/external-apis.js Outdated
}

// resolve ancestry strings to nodes
for (const { internals, ancestry } of results) {

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 didn't test that results is a destructable object. This can throw if we get something odd here. Either we should fail silently (like you're doing above) or we should throw with a clear message of what's going on. This function is also a bit of a mix of validation and functionality. Consider creating a separate normalize function here. Maybe even one that converts this to a Map with actual elements like we get from globalThis._elementInternals so that the result of this is in a shape axe can consume.

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.

Im not sure about the mapping to a structure similar to globalThis._elementInternals. The whole reason we're adding the internals directly to the vNode is to ensure that the external data isn't saved globally anywhere and only lives for the current run. Axe-core doesn't interact with any other place to look at internals other than the globalThis. _elementInternals (which we wouldn't want to override as it would live after the run) and the vNode itself.


export const external = {};

export default function externalAPIs({

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.

Different PR, but what would you think about adding nodeSerializer and frameMessenger here, and deprecating how we're doing that now. That way we have one API for connecting axe-core to external resources.

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.

Possibly? That sounds like a breaking change though to remove it, but a feature to allow it to configure those things.

Comment thread lib/core/public/external-apis.js Outdated
Comment thread test/core/base/audit.js
Comment thread lib/core/public/external-apis.js Outdated
Comment thread lib/core/public/external-apis.js Outdated
Comment thread lib/core/public/external-apis.js
Comment thread test/core/base/audit.js Outdated
Comment thread axe.d.ts Outdated
Comment thread lib/core/public/external-apis.js Outdated
}

export const external = {
setElementInternals

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.

Sry should have caught this in my last review. Is set the right verb here? Set suggests I need to pass something in that then gets set. That's not what this does. This is more like "load" or maybe "fetch" or something.

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'd be ok with load, but not fetch as it does more than just get the data. The external function does set the element internals on the vnodes so that's why I used set.

Comment thread test/core/public/external-apis.js
Comment thread test/core/base/audit.js

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

Uh, no the other one. :P Just the typo.

Comment thread axe.d.ts Outdated
@straker
straker merged commit 63bab8f into develop May 21, 2026
23 checks passed
@straker
straker deleted the elm-internals-external-apis branch May 21, 2026 16:11
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.

ElementInternals: Create extension interface to pass internal data from extension

3 participants