diff --git a/lib/core/utils/dq-element.js b/lib/core/utils/dq-element.js index 47bcc64e..9ce3acf6 100644 --- a/lib/core/utils/dq-element.js +++ b/lib/core/utils/dq-element.js @@ -1,4 +1,3 @@ -import getSelector from './get-selector'; import getAncestry from './get-ancestry'; import getXpath from './get-xpath'; import getNodeFromTree from './get-node-from-tree'; @@ -7,30 +6,115 @@ import cache from '../base/cache'; const CACHE_KEY = 'DqElm.RunOptions'; -function truncate(str, maxLength) { - maxLength = maxLength || 300; +/** + * Escapes a string for use in CSS selectors + * @param {String} str - The string to escape + * @returns {String} The escaped string + */ +function escapeCSSSelector(str) { + // Use the CSS.escape method if available + if (window.CSS && window.CSS.escape) { + return window.CSS.escape(str); + } + // Simple fallback for browsers that don't support CSS.escape + return str + .replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&') + .replace(/^\d/, '\\3$& '); +} + +function getFullPathSelector(elm) { + if (elm.nodeName === 'HTML' || elm.nodeName === 'BODY') { + return elm.nodeName.toLowerCase(); + } - if (str.length > maxLength) { - var index = str.indexOf('>'); - str = str.substring(0, index + 1); + if (cache.get('getFullPathSelector') === undefined) { + cache.set('getFullPathSelector', new WeakMap()); + } + + // Check cache first + const sourceCache = cache.get('getFullPathSelector'); + if (sourceCache.has(elm)) { + return sourceCache.get(elm); + } + + const element = elm; + const names = []; + while (elm.parentElement && elm.nodeName !== 'BODY') { + if (sourceCache.has(elm)) { + names.unshift(sourceCache.get(elm)); + break; + } else if (elm.id) { + // Check if the ID is unique in the document before using it + const escapedId = escapeCSSSelector(elm.getAttribute('id')); + const elementsWithSameId = document.querySelectorAll(`#${escapedId}`); + if (elementsWithSameId.length === 1) { + // ID is unique, safe to use + names.unshift('#' + escapedId); + break; + } else { + // ID is not unique, fallback to position-based selector + let c = 1; + let e = elm; + for (; e.previousElementSibling; e = e.previousElementSibling, c++) { + // Increment counter for each previous sibling + } + names.unshift(`${elm.nodeName.toLowerCase()}:nth-child(${c})`); + } + } else { + let c = 1; + let e = elm; + for (; e.previousElementSibling; e = e.previousElementSibling, c++) { + // Increment counter for each previous sibling + } + names.unshift(`${elm.nodeName.toLowerCase()}:nth-child(${c})`); + } + elm = elm.parentElement; } - return str; + const selector = names.join('>'); + sourceCache.set(element, selector); + return selector; } function getSource(element) { - if (!element?.outerHTML) { + if (!element) { return ''; } - var source = element.outerHTML; - if (!source && typeof window.XMLSerializer === 'function') { - source = new window.XMLSerializer().serializeToString(element); + + // Initialize cache if needed + if (cache.get('getSourceEfficient') === undefined) { + cache.set('getSourceEfficient', new WeakMap()); + } + + // Check cache first + const sourceCache = cache.get('getSourceEfficient'); + if (sourceCache.has(element)) { + return sourceCache.get(element); + } + + // Compute value if not cached + const tagName = element.nodeName?.toLowerCase(); + if (!tagName) { + return ''; } - let htmlString = truncate(source || ''); - // Remove unwanted attributes - const regex = /\s*data-percy-[^=]+="[^"]*"/g; - htmlString = htmlString.replace(regex, ''); - return htmlString; + + let result; + try { + const attributes = Array.from(element.attributes || []) + .filter(attr => !attr.name.startsWith('data-percy-')) + .map(attr => `${attr.name}="${attr.value}"`) + .join(' '); + + result = attributes ? `<${tagName} ${attributes}>` : `<${tagName}>`; + + // Store in cache + sourceCache.set(element, result); + } catch (e) { + // Handle potential errors (like accessing attributes on non-element nodes) + result = `<${tagName || 'unknown'}>`; + } + + return result; } /** @@ -94,7 +178,9 @@ DqElement.prototype = { * @return {String} */ get selector() { - return this.spec.selector || [getSelector(this.element, this._options)]; + return ( + this.spec.selector || [getFullPathSelector(this.element, this._options)] + ); }, /**