Skip to content

fix(DqElement): avoid calling constructors with cloneNode - #5013

Merged
WilcoFiers merged 3 commits into
developfrom
get-element-source
Feb 18, 2026
Merged

fix(DqElement): avoid calling constructors with cloneNode#5013
WilcoFiers merged 3 commits into
developfrom
get-element-source

Conversation

@WilcoFiers

Copy link
Copy Markdown
Contributor

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

@WilcoFiers
WilcoFiers requested a review from a team as a code owner February 13, 2026 15:07
Copilot AI review requested due to automatic review settings February 13, 2026 15:07

Copilot AI 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.

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 getElementSource utility function that generates truncated HTML source without calling cloneNode()
  • Refactored DqElement to use the new utility function instead of the old truncateElement implementation
  • 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.

Comment thread lib/core/utils/get-element-source.js Outdated
({ name, value }) => ({ name, value })
);
const attrsLength = nodeAttrs.reduce((acc, { name, value }) => {
return acc + name.length + value.length + 4;

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 guessing what the 4 represents but we should comment the number

Suggested change
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) {

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.

Same here for the 2

Suggested change
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;

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.

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;

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.

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.

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.

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.

Comment thread test/core/utils/get-element-source.js Outdated
'</svg>'
);
const result = getElementSource(vNode.actualNode, { maxLength: 50 });
assert.include(result, 'a');

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.

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.

@WilcoFiers
WilcoFiers merged commit 0281fa1 into develop Feb 18, 2026
23 checks passed
@WilcoFiers
WilcoFiers deleted the get-element-source branch February 18, 2026 11:46
WilcoFiers added a commit that referenced this pull request Mar 30, 2026
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]>
straker pushed a commit that referenced this pull request Mar 30, 2026
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]>
@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.

element.cloneNode can cause problems with custom elements

3 participants