fix(DqElement): avoid calling constructors with cloneNode - #5013
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes issue #4996 by refactoring how element HTML source is retrieved, avoiding calls to cloneNode() which can trigger custom element constructors. The solution extracts the element source generation logic from DqElement into a new standalone utility function getElementSource that works independently of the virtual tree and uses string manipulation instead of node cloning.
Changes:
- Created new
getElementSourceutility function that generates truncated HTML source without callingcloneNode() - Refactored DqElement to use the new utility function instead of the old
truncateElementimplementation - Improved attribute truncation logic to check each attribute individually instead of stopping at the first that doesn't fit
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| lib/core/utils/get-element-source.js | New utility function that generates element HTML source using outerHTML/XMLSerializer instead of cloneNode, with support for truncation and various node types |
| lib/core/utils/dq-element.js | Removed old truncateElement implementation and updated to use the new getElementSource utility |
| lib/core/utils/index.js | Added export for the new getElementSource utility |
| axe.d.ts | Added TypeScript definitions for the new getElementSource utility function |
| test/core/utils/get-element-source.js | Comprehensive test suite for the new utility covering elements, non-element nodes, namespaces, and truncation scenarios |
| test/core/utils/dq-element.js | Removed source-related tests that were moved to the new test file, keeping only DqElement-specific behavior tests |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <[email protected]>
| ({ name, value }) => ({ name, value }) | ||
| ); | ||
| const attrsLength = nodeAttrs.reduce((acc, { name, value }) => { | ||
| return acc + name.length + value.length + 4; |
There was a problem hiding this comment.
I'm guessing what the 4 represents but we should comment the number
| return acc + name.length + value.length + 4; | |
| // 4 = whitespace + equal sign + start and end quote | |
| return acc + name.length + value.length + 4; |
| return acc + name.length + value.length + 4; | ||
| }, 0); | ||
|
|
||
| if (2 + nodeName.length + attrsLength > maxLength) { |
There was a problem hiding this comment.
Same here for the 2
| if (2 + nodeName.length + attrsLength > maxLength) { | |
| // 2 = whitespace before attrs + ? | |
| if (2 + nodeName.length + attrsLength > maxLength) { |
| const attrStr = ` ${attr.name}="${attr.value}"`; | ||
| if (source.length + attrStr.length > maxLength - truncateEnd.length) { | ||
| tagEnd = truncateEnd; | ||
| continue; |
There was a problem hiding this comment.
This should probably break since we've reached the max length. Otherwise this will continue to process attrs we don't need to.
Edit: I see the last test tests that the function can add later attrs that do fit when priors don't. This just means we will always loop over every attribute even if none could fit. Not sure if that's a performance problem, but something to keep in mind.
| source += attrStr; | ||
| } | ||
|
|
||
| return source + tagEnd; |
There was a problem hiding this comment.
One thing to note with this approach is the case where the full outerHTML is too long but the outerHTML of just the element without children is not too long. As far as I am aware node.attributes is not guaranteed to be the same order as the element source. E.g.
<a id="link" href="#" style="color: blue" class="thing"></a>
<script>
link.attributes // { 0: style, 1: href, 2: class, 3: id }
</script>Not having the same order would mean that combining them back into a string could change the attribute order from what the DOM is showing. Not sure if that will be a problem or not, but something to keep in mind. Probably worth the performance gain over doing what we thought before.
There was a problem hiding this comment.
Fair, but that's also the case for the current implementation. Even if we detect that it it'll fit we use node.attributes to generate the string.
| '</svg>' | ||
| ); | ||
| const result = getElementSource(vNode.actualNode, { maxLength: 50 }); | ||
| assert.include(result, 'a'); |
There was a problem hiding this comment.
Just looking for the letter a may not be exactly what we want to test as id="target" also includes the letter a. So not sure if this is testing that the a node is in the string or something else.
Closes: #4996 - **Avoid calling cloneNode when retrieving source** - Add a new utils.getElementSource (which works even if the tree is not constructed - Made sure getElementSource works with namespaces and non-node elements - Check each attribute if it fits in the truncated source, instead of stopping at the first that doesn't fit --------- Co-authored-by: Copilot <[email protected]>
Closes: #4996 - **Avoid calling cloneNode when retrieving source** - Add a new utils.getElementSource (which works even if the tree is not constructed - Made sure getElementSource works with namespaces and non-node elements - Check each attribute if it fits in the truncated source, instead of stopping at the first that doesn't fit --------- Co-authored-by: Copilot <[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 🤖 🎉
Closes: #4996