feat(axe.externalAPIs): add public api for setting elementInternal data - #5105
Conversation
chutchins25
left a comment
There was a problem hiding this comment.
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 gap — null 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 foraxe.externalAPIs. TS consumers won't get completion or compile-time checking on release.doc/API.md— the newaxe.externalAPIsentry point isn't linked or referenced from the API reference; the newdoc/external-apis.mdanddoc/element-internals.mdare 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.
|
|
||
| ## 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. |
There was a problem hiding this comment.
"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.
| 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. |
There was a problem hiding this comment.
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.
Co-authored-by: Chris Hutchins <[email protected]>
Co-authored-by: Chris Hutchins <[email protected]>
Co-authored-by: Chris Hutchins <[email protected]>
Co-authored-by: Chris Hutchins <[email protected]>
Co-authored-by: Chris Hutchins <[email protected]>
Co-authored-by: Chris Hutchins <[email protected]>
| } | ||
|
|
||
| // resolve ancestry strings to nodes | ||
| for (const { internals, ancestry } of results) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Possibly? That sounds like a breaking change though to remove it, but a feature to allow it to configure those things.
| } | ||
|
|
||
| export const external = { | ||
| setElementInternals |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Co-authored-by: Wilco Fiers <[email protected]>
WilcoFiers
left a comment
There was a problem hiding this comment.
Uh, no the other one. :P Just the typo.
Co-authored-by: Wilco Fiers <[email protected]>
## [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 🤖 🎉
Closes: #5040