diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 374494c5194..6139ae9116b 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,4 +1,12 @@ +## 15.7.1 + +_Released 12/2/2025 (PENDING)_ + +**Performance:** + +- Improved performance when viewing command snapshots in the Command Log. Element highlighting is now significantly faster, especially when highlighting multiple elements or complex pages. This is achieved by reducing redundant style calculations and batching DOM operations to minimize browser reflows. Addressed in [#32951](https://github.com/cypress-io/cypress/pull/32951). + ## 15.7.0 _Released 11/19/2025_ @@ -6,6 +14,7 @@ _Released 11/19/2025_ **Performance:** - Limits the number of matched elements that are tested for visibility when added to a command log entry. Fixes a crash scenario related to rapid successive DOM additions in conjunction with a large number of elements returned from a query. Addressed in [#32937](https://github.com/cypress-io/cypress/pull/32937). +- Improved performance when viewing command snapshots in the Command Log. Element highlighting is now significantly faster, especially when highlighting multiple elements or complex pages. This is achieved by reducing redundant style calculations and batching DOM operations to minimize browser reflows. Addressed in [#32951](https://github.com/cypress-io/cypress/pull/32951). **Features:** diff --git a/packages/app/src/runner/aut-iframe.cy.tsx b/packages/app/src/runner/aut-iframe.cy.tsx new file mode 100644 index 00000000000..782362c9f59 --- /dev/null +++ b/packages/app/src/runner/aut-iframe.cy.tsx @@ -0,0 +1,146 @@ +import { AutIframe } from './aut-iframe' +import { createEventManager } from '../../cypress/component/support/ctSupport' +import { getElementDimensions } from './dimensions' + +describe('AutIframe._addElementBoxModelLayers', () => { + let autIframe: AutIframe + let mockGetComputedStyle: typeof getComputedStyle + let getComputedStyleCallCount: number + let mockJQuery: any + + beforeEach(() => { + getComputedStyleCallCount = 0 + + mockGetComputedStyle = window.getComputedStyle + window.getComputedStyle = (element: Element, pseudoElement?: string | null) => { + getComputedStyleCallCount++ + + return mockGetComputedStyle.call(window, element, pseudoElement) + } + + mockJQuery = (selector: any) => { + if (typeof selector === 'string') { + return { + get: (index?: number) => { + if (selector === 'body') { + return index === 0 ? document.body : [document.body] + } + + return null + }, + } + } + + if (selector && (selector.nodeType || selector instanceof HTMLElement || selector instanceof Element)) { + return { + get: (index?: number) => { + return index === 0 ? selector : [selector] + }, + } + } + + return { + get: () => null, + } + } + + const eventManager = createEventManager() + + autIframe = new AutIframe('Test Project', eventManager, mockJQuery) + }) + + afterEach(() => { + window.getComputedStyle = mockGetComputedStyle + }) + + it('should not call getComputedStyle when dimensions are provided', () => { + const testElement = document.createElement('div') + + testElement.style.width = '100px' + testElement.style.height = '50px' + testElement.style.padding = '10px' + testElement.style.border = '5px solid black' + testElement.style.margin = '15px' + testElement.style.position = 'absolute' + testElement.style.top = '20px' + testElement.style.left = '30px' + testElement.style.display = 'block' + testElement.style.transform = 'translateX(10px)' + testElement.style.zIndex = '100' + document.body.appendChild(testElement) + + // Get dimensions first (this will call getComputedStyle once) + const dimensions = getElementDimensions(testElement) + + // Verify dimensions include transform and zIndex + expect(dimensions.transform).to.exist + expect(dimensions.zIndex).to.exist + + // Reset the counter since getElementDimensions also calls getComputedStyle + getComputedStyleCallCount = 0 + + const $el = mockJQuery(testElement) + const $body = mockJQuery('body') + + // When dimensions are provided, _addElementBoxModelLayers should NOT call getComputedStyle + const container = (autIframe as any)._addElementBoxModelLayers($el, $body, dimensions) + + // Verify getComputedStyle was NOT called in _addElementBoxModelLayers + // (it should use transform and zIndex from the provided dimensions) + expect(getComputedStyleCallCount).to.equal(0, 'getComputedStyle should not be called when dimensions are provided') + + expect(container).to.not.be.undefined + expect(container).to.not.be.null + expect(container).to.be.instanceof(HTMLElement) + expect(container.classList.contains('__cypress-highlight')).to.be.true + expect(container.children.length).to.be.greaterThan(0, 'Should create at least one layer') + + const layers = Array.from(container.children) as HTMLElement[] + + layers.forEach((layer) => { + expect(layer.style.position).to.equal('absolute') + // Verify positions are stored in data attributes (not style properties) + expect(layer.getAttribute('data-top')).to.exist + expect(layer.getAttribute('data-left')).to.exist + expect(parseFloat(layer.getAttribute('data-top')!)).to.be.a('number') + expect(parseFloat(layer.getAttribute('data-left')!)).to.be.a('number') + expect(layer.getAttribute('data-layer')).to.exist + // Verify transform and zIndex were applied from dimensions + // Note: getComputedStyle returns computed transform as a matrix, not the original CSS value + // So we check that transform is set (not 'none') and matches the computed value from dimensions + expect(layer.style.transform).to.equal(dimensions.transform) + expect(layer.style.zIndex).to.equal('100') + }) + + document.body.removeChild(testElement) + }) + + it('should call getComputedStyle only once when dimensions are not provided', () => { + const testElement = document.createElement('div') + + testElement.style.width = '100px' + testElement.style.height = '50px' + testElement.style.display = 'block' + document.body.appendChild(testElement) + + getComputedStyleCallCount = 0 + + const $el = mockJQuery(testElement) + const $body = mockJQuery('body') + + // Call without providing dimensions (will call getElementDimensions internally) + const container = (autIframe as any)._addElementBoxModelLayers($el, $body) + + // getElementDimensions will call getComputedStyle once and return transform/zIndex, + // so _addElementBoxModelLayers won't need to call it again + // We expect only 1 call total (from getElementDimensions) + expect(getComputedStyleCallCount).to.equal(1, 'Should call getComputedStyle only once in getElementDimensions') + + expect(container).to.not.be.undefined + expect(container).to.not.be.null + expect(container).to.be.instanceof(HTMLElement) + expect(container.children.length).to.be.greaterThan(0) + + document.body.removeChild(testElement) + }) +}) diff --git a/packages/app/src/runner/aut-iframe.ts b/packages/app/src/runner/aut-iframe.ts index af8f24b3d9a..3ee5b5333cb 100644 --- a/packages/app/src/runner/aut-iframe.ts +++ b/packages/app/src/runner/aut-iframe.ts @@ -6,7 +6,7 @@ import _ from 'lodash' import type { DebouncedFunc } from 'lodash' import { useStudioStore } from '../store/studio-store' import { getElementDimensions, setOffset } from './dimensions' -import { getOrCreateHelperDom, getSelectorHighlightStyles, getZIndex, INT32_MAX } from './dom' +import { getOrCreateHelperDom, getSelectorHighlightStyles, INT32_MAX } from './dom' import highlightMounter from './selector-playground/highlight-mounter' import Highlight from './selector-playground/Highlight.ce.vue' @@ -20,6 +20,7 @@ export class AutIframe { debouncedToggleSelectorPlayground: DebouncedFunc<(isEnabled: any) => void> $iframe?: JQuery _highlightedEl?: Element + private _currentHighlightingId: number = 0 constructor ( private projectName: string, @@ -167,18 +168,24 @@ export class AutIframe { const Cypress = this.eventManager.getCypress() const { headStyles = undefined, bodyStyles = undefined } = Cypress ? Cypress.cy.getStyles(snapshot) : {} const { body, htmlAttrs } = snapshot - const contents = this._contents() - const $html = contents?.find('html') as any as JQuery + const $contents = this._contents() + + if (!$contents) return + + // Cache DOM queries to avoid redundant _contents() calls + const $html = $contents.find('html') as any as JQuery + const $head = $contents.find('head') as any as JQuery + const $body = $contents.find('body') as unknown as JQuery if ($html) { this._replaceHtmlAttrs($html, htmlAttrs) } - this._replaceHeadStyles(headStyles) + // Pass $head to avoid _replaceHeadStyles calling _contents() again + this._replaceHeadStyles(headStyles, $head) // remove the old body and replace with restored one - - this._body()?.remove() + $body?.remove() this._insertBodyStyles(body.get(), bodyStyles) $html?.append(body.get()) @@ -209,8 +216,12 @@ export class AutIframe { }) } - _replaceHeadStyles (styles: Record = {}) { - const $head = this._contents()?.find('head') + _replaceHeadStyles (styles: Record = {}, $head?: JQuery) { + // Use provided $head if available, otherwise query for it + if (!$head) { + $head = this._contents()?.find('head') as any as JQuery + } + const existingStyles = $head?.find('link[rel="stylesheet"],style') _.each(styles, (style, index) => { @@ -276,8 +287,14 @@ export class AutIframe { } highlightEl = ({ body }, { $el, coords, highlightAttr, scrollBy }) => { + // Cancel any ongoing highlighting operation by incrementing the operation ID + // This ensures any async work from previous calls will see a different ID and stop processing + this._currentHighlightingId++ this.removeHighlights() + // Capture the current operation ID for this highlighting operation + const highlightingId = this._currentHighlightingId + if (body) { $el = body.get().find(`[${highlightAttr}]`) } else { @@ -301,34 +318,135 @@ export class AutIframe { } } + // Collect all containers first, then append in a single batch to minimize reflows + const containers: HTMLElement[] = [] + const elementsToProcess: Array<{ $el: any, dimensions: ReturnType }> = [] + + // collect all valid elements and their dimensions $el.each((__, element) => { const $_el = this.$(element) // bail if our el no longer exists in the parent body - if (!this.$.contains(body, element)) return + if (!this.$.contains(body, element)) { + return + } - // switch to using offsetWidth + offsetHeight - // because we want to highlight our element even - // if it only has margin and zero content height / width - const dimensions = this._getOffsetSize($_el.get(0)) + // Get all dimensions and computed styles in one call to avoid multiple getComputedStyle calls + const dimensions = getElementDimensions($_el.get(0)) // dont show anything if our element displaces nothing - if (dimensions.width === 0 || dimensions.height === 0 || $_el.css('display') === 'none') { + // Use offsetWidth/offsetHeight to check because we want to highlight our element even + // if it only has margin and zero content height / width + if (dimensions.offsetWidth === 0 || dimensions.offsetHeight === 0 || dimensions.display === 'none') { return } - this._addElementBoxModelLayers($_el, $body).setAttribute('data-highlight-el', `true`) + elementsToProcess.push({ $el: $_el, dimensions }) }) + // create all containers (off-DOM) + elementsToProcess.forEach(({ $el: $_el, dimensions }) => { + const container = this._addElementBoxModelLayers($_el, $body, dimensions) + + container.setAttribute('data-highlight-el', `true`) + containers.push(container) + }) + + // batch append all containers at once to minimize reflows + if (containers.length > 0) { + // Use DocumentFragment for even better performance when appending many elements + const fragment = document.createDocumentFragment() + + containers.forEach((container) => { + fragment.appendChild(container) + }) + + body.appendChild(fragment) + + // Now that containers are in DOM, set offsets using setOffset (which uses getBoundingClientRect) + // Batch setOffset calls to avoid layout thrashing - process in chunks using requestAnimationFrame + const OFFSET_BATCH_SIZE = 100 + let offsetIndex = 0 + + const processOffsetBatch = () => { + // Check if this highlighting operation was cancelled (e.g., user switched to different snapshot) + // by comparing the captured operation ID with the current one + if (this._currentHighlightingId !== highlightingId) { + return + } + + const endIndex = Math.min(offsetIndex + OFFSET_BATCH_SIZE, containers.length) + + for (let j = offsetIndex; j < endIndex; j++) { + const container = containers[j] + + // Check if container is still in the DOM (might have been removed by removeHighlights) + if (!container.isConnected) { + continue + } + + for (let i = 0; i < container.children.length; i++) { + const childEl = container.children[i] as HTMLElement + + // Check if element is still in the DOM before positioning + if (!childEl.isConnected) { + continue + } + + const top = parseFloat(childEl.getAttribute('data-top')!) + const left = parseFloat(childEl.getAttribute('data-left')!) + + setOffset(childEl, { top, left }) + } + } + + offsetIndex = endIndex + + if (offsetIndex < containers.length) { + // Process next batch on next frame to avoid blocking + requestAnimationFrame(processOffsetBatch) + } else { + // All highlights have been positioned + } + } + + // Start processing offsets in batches + processOffsetBatch() + } + if (coords) { requestAnimationFrame(() => { - this._addHitBoxLayer(coords, $body.get(0)).setAttribute('data-highlight-hitbox', 'true') + // Check if this highlighting operation was cancelled before adding hitbox + if (this._currentHighlightingId !== highlightingId) { + return + } + + const bodyElement = $body.get(0) + + if (!bodyElement) { + return + } + + this._addHitBoxLayer(coords, bodyElement).setAttribute('data-highlight-hitbox', 'true') }) } } removeHighlights = () => { - this._contents() && this._contents()?.find('.__cypress-highlight').remove() + const $contents = this._contents() + + if (!$contents) return + + const contentsElement = $contents[0] as Document | Element + + if (!contentsElement || typeof contentsElement.querySelectorAll !== 'function') { + return + } + + const highlights = contentsElement.querySelectorAll('.__cypress-highlight') + + // Batch remove using native DOM API + highlights.forEach((el) => el.remove()) } toggleSelectorPlayground = (isEnabled) => { @@ -615,28 +733,25 @@ export class AutIframe { return box } - private _getOffsetSize (el: HTMLElement) { - return { - width: el.offsetWidth, - height: el.offsetHeight, - } - } - - private _addElementBoxModelLayers ($el, $body) { - $body = $body || $('body') + private _addElementBoxModelLayers ($el, $body, dimensions?: ReturnType) { + $body = $body || this.$('body') const el = $el.get(0) - const body = $body.get(0) - const dimensions = getElementDimensions(el) + // Use existing dimensions if provided to avoid redundant getComputedStyle calls + const elementDimensions: ReturnType = dimensions || getElementDimensions(el) + + // Ensure transform and zIndex are valid (should always be set by getElementDimensions) + const transform = elementDimensions.transform || 'none' + const zIndex = elementDimensions.zIndex ?? 2147483647 const container = document.createElement('div') container.classList.add('__cypress-highlight') - container.style.opacity = `0.7` + container.style.opacity = '0.7' container.style.position = 'absolute' - container.style.zIndex = `${INT32_MAX}` + container.style.zIndex = INT32_MAX.toString() const layers = { Content: '#9FC4E7', @@ -654,19 +769,19 @@ export class AutIframe { // rearrange the contents offset so // its inside of our border + padding obj = { - width: dimensions.width, - height: dimensions.height, - top: dimensions.offset.top + dimensions.borderTop + dimensions.paddingTop, - left: dimensions.offset.left + dimensions.borderLeft + dimensions.paddingLeft, + width: elementDimensions.width, + height: elementDimensions.height, + top: elementDimensions.offset.top + elementDimensions.borderTop + elementDimensions.paddingTop, + left: elementDimensions.offset.left + elementDimensions.borderLeft + elementDimensions.paddingLeft, } break default: obj = { - width: this._getDimensionsFor(dimensions, attr, 'width'), - height: this._getDimensionsFor(dimensions, attr, 'height'), - top: dimensions.offset.top, - left: dimensions.offset.left, + width: this._getDimensionsFor(elementDimensions, attr, 'width'), + height: this._getDimensionsFor(elementDimensions, attr, 'height'), + top: elementDimensions.offset.top, + left: elementDimensions.offset.left, } } @@ -674,47 +789,43 @@ export class AutIframe { // subtract what the actual marginTop + marginLeft // values are, since offset disregards margin completely if (attr === 'Margin') { - obj.top -= dimensions.marginTop - obj.left -= dimensions.marginLeft + obj.top -= elementDimensions.marginTop + obj.left -= elementDimensions.marginLeft } if (attr === 'Padding') { - obj.top += dimensions.borderTop - obj.left += dimensions.borderLeft + obj.top += elementDimensions.borderTop + obj.left += elementDimensions.borderLeft } // bail if the dimensions of this layer match the previous one // so we dont create unnecessary layers - if (this._dimensionsMatchPreviousLayer(obj, container)) return + if (this._dimensionsMatchPreviousLayer(obj, container)) { + return + } - this._createLayer($el.get(0), attr, color, container, obj) + this._createLayer(attr, color, container, obj, transform, zIndex) }) - body.appendChild(container) - - for (let i = 0; i < container.children.length; i++) { - const el = container.children[i] as HTMLElement - const top = parseFloat(el.getAttribute('data-top')!) - const left = parseFloat(el.getAttribute('data-left')!) - - setOffset(el, { top, left }) - } - + // Note: setOffset will be called after container is appended to DOM + // The offsets are stored in data-top/data-left attributes for now return container } - private _createLayer (el, attr, color, container, dimensions) { + private _createLayer (attr, color, container, dimensions, transform: string, zIndex: number) { const div = document.createElement('div') - div.style.transform = getComputedStyle(el, null).transform + // Set transform directly (original code always set it, even if 'none') + div.style.transform = transform + div.style.width = `${dimensions.width}px` div.style.height = `${dimensions.height}px` div.style.position = 'absolute' - div.style.zIndex = `${getZIndex(el)}` + div.style.zIndex = `${zIndex}` div.style.backgroundColor = color - div.setAttribute('data-top', dimensions.top) - div.setAttribute('data-left', dimensions.left) + div.setAttribute('data-top', dimensions.top.toString()) + div.setAttribute('data-left', dimensions.left.toString()) div.setAttribute('data-layer', attr) container.prepend(div) diff --git a/packages/app/src/runner/dimensions.cy.tsx b/packages/app/src/runner/dimensions.cy.tsx new file mode 100644 index 00000000000..d98b9955574 --- /dev/null +++ b/packages/app/src/runner/dimensions.cy.tsx @@ -0,0 +1,58 @@ +import { getElementDimensions } from './dimensions' + +describe('dimensions utilities', () => { + describe('getElementDimensions', () => { + let mockGetComputedStyle: typeof getComputedStyle + let getComputedStyleCallCount: number + + beforeEach(() => { + getComputedStyleCallCount = 0 + + mockGetComputedStyle = window.getComputedStyle + window.getComputedStyle = (element: Element, pseudoElement?: string | null) => { + getComputedStyleCallCount++ + + return mockGetComputedStyle.call(window, element, pseudoElement) + } + }) + + afterEach(() => { + window.getComputedStyle = mockGetComputedStyle + }) + + it('should call getComputedStyle only once per element', () => { + const el = document.createElement('div') + + el.style.padding = '10px' + el.style.margin = '20px' + el.style.border = '5px solid black' + el.style.width = '100px' + el.style.height = '50px' + el.style.display = 'block' + + document.body.appendChild(el) + + const dimensions = getElementDimensions(el) + + // Verify getComputedStyle was called exactly once + // to ensure this remains performant + expect(getComputedStyleCallCount).to.equal(1) + + expect(dimensions.paddingTop).to.equal(10) + expect(dimensions.paddingRight).to.equal(10) + expect(dimensions.paddingBottom).to.equal(10) + expect(dimensions.paddingLeft).to.equal(10) + expect(dimensions.marginTop).to.equal(20) + expect(dimensions.marginRight).to.equal(20) + expect(dimensions.marginBottom).to.equal(20) + expect(dimensions.marginLeft).to.equal(20) + expect(dimensions.borderTop).to.equal(5) + expect(dimensions.borderRight).to.equal(5) + expect(dimensions.borderBottom).to.equal(5) + expect(dimensions.borderLeft).to.equal(5) + expect(dimensions.display).to.equal('block') + + document.body.removeChild(el) + }) + }) +}) diff --git a/packages/app/src/runner/dimensions.ts b/packages/app/src/runner/dimensions.ts index 3bfba4a8955..adbc2977c43 100644 --- a/packages/app/src/runner/dimensions.ts +++ b/packages/app/src/runner/dimensions.ts @@ -1,18 +1,22 @@ export const getElementDimensions = (el: HTMLElement) => { const { offsetHeight, offsetWidth } = el - const paddingTop = getStylePropertyNumber(el, 'padding-top') - const paddingRight = getStylePropertyNumber(el, 'padding-right') - const paddingBottom = getStylePropertyNumber(el, 'padding-bottom') - const paddingLeft = getStylePropertyNumber(el, 'padding-left') - const borderTop = getStylePropertyNumber(el, 'border-top-width') - const borderRight = getStylePropertyNumber(el, 'border-right-width') - const borderBottom = getStylePropertyNumber(el, 'border-bottom-width') - const borderLeft = getStylePropertyNumber(el, 'border-left-width') - const marginTop = getStylePropertyNumber(el, 'margin-top') - const marginRight = getStylePropertyNumber(el, 'margin-right') - const marginBottom = getStylePropertyNumber(el, 'margin-bottom') - const marginLeft = getStylePropertyNumber(el, 'margin-left') + // Call getComputedStyle once and cache the result to avoid + // multiple layout/reflow operations. + const computedStyle: CSSStyleDeclaration = getComputedStyle(el, null) + + const paddingTop = getStylePropertyNumberFromStyle(computedStyle, 'padding-top') + const paddingRight = getStylePropertyNumberFromStyle(computedStyle, 'padding-right') + const paddingBottom = getStylePropertyNumberFromStyle(computedStyle, 'padding-bottom') + const paddingLeft = getStylePropertyNumberFromStyle(computedStyle, 'padding-left') + const borderTop = getStylePropertyNumberFromStyle(computedStyle, 'border-top-width') + const borderRight = getStylePropertyNumberFromStyle(computedStyle, 'border-right-width') + const borderBottom = getStylePropertyNumberFromStyle(computedStyle, 'border-bottom-width') + const borderLeft = getStylePropertyNumberFromStyle(computedStyle, 'border-left-width') + const marginTop = getStylePropertyNumberFromStyle(computedStyle, 'margin-top') + const marginRight = getStylePropertyNumberFromStyle(computedStyle, 'margin-right') + const marginBottom = getStylePropertyNumberFromStyle(computedStyle, 'margin-bottom') + const marginLeft = getStylePropertyNumberFromStyle(computedStyle, 'margin-left') // NOTE: offsetWidth/height always give us content + padding + border, so subtract them // to get the true "clientHeight" and "clientWidth". @@ -35,10 +39,24 @@ export const getElementDimensions = (el: HTMLElement) => { const widthWithBorder = widthWithPadding + borderLeft + borderRight const widthWithMargin = widthWithBorder + marginLeft + marginRight + // Extract transform and z-index from computed style to avoid additional getComputedStyle calls + // Use .transform property directly to match original behavior (getComputedStyle(el, null).transform) + // Ensure it's always a string (fallback to 'none' if undefined/null) + const transform = computedStyle.transform || 'none' + const zIndexValue = computedStyle.getPropertyValue('z-index') + // Use INT32_MAX for auto/0 z-index values (matching getZIndex behavior) + const INT32_MAX = 2147483647 + const parsedZIndex = parseFloat(zIndexValue) + const zIndex = /^(auto|0)$/.test(zIndexValue) || isNaN(parsedZIndex) ? INT32_MAX : parsedZIndex + return { // offset disregards margin but takes into account border + padding offset: getOffset(el), + // Include original offsetWidth/offsetHeight for direct access (equivalent to widthWithBorder/heightWithBorder) + offsetWidth, + offsetHeight, + paddingTop, paddingRight, paddingBottom, @@ -61,6 +79,11 @@ export const getElementDimensions = (el: HTMLElement) => { widthWithPadding, widthWithBorder, widthWithMargin, + + // Include display, transform, and zIndex from computed style to avoid additional getComputedStyle calls + display: computedStyle.display, + transform, + zIndex, } } @@ -68,8 +91,10 @@ export const getElementDimensions = (el: HTMLElement) => { export const setOffset = (el: HTMLElement, offset: { top: number, left: number }) => { const curOffset = getOffset(el) - const curTop = parseFloat(getComputedStyle(el, null).top) - const curLeft = parseFloat(getComputedStyle(el, null).left) + // Cache getComputedStyle result to avoid multiple layout operations + const computedStyle: CSSStyleDeclaration = getComputedStyle(el, null) + const curTop = parseFloat(computedStyle.top) + const curLeft = parseFloat(computedStyle.left) el.style.top = `${offset.top - curOffset.top + curTop}px` el.style.left = `${offset.left - curOffset.left + curLeft}px` @@ -81,14 +106,22 @@ export const getOffset = (el: HTMLElement) => { const rect = el.getBoundingClientRect() const win = el.ownerDocument.defaultView + // Handle test environments where defaultView might be null + if (!win) { + return { + top: rect.top, + left: rect.left, + } + } + return { - top: rect.top + win!.scrollY, - left: rect.left + win!.scrollX, + top: rect.top + win.scrollY, + left: rect.left + win.scrollX, } } -const getStylePropertyNumber = (el: HTMLElement, property: string) => { - const value = parseFloat(getComputedStyle(el, null).getPropertyValue(property)) +const getStylePropertyNumberFromStyle = (computedStyle: CSSStyleDeclaration, property: string): number => { + const value = parseFloat(computedStyle.getPropertyValue(property)) if (isNaN(value)) { throw new Error('Element attr did not return a valid number') diff --git a/packages/app/src/runner/iframe-model.ts b/packages/app/src/runner/iframe-model.ts index 7eabb3c44cc..99888b12e65 100644 --- a/packages/app/src/runner/iframe-model.ts +++ b/packages/app/src/runner/iframe-model.ts @@ -153,7 +153,6 @@ export class IframeModel { this._showSnapshotVue(snapshots[0], snapshotProps) } - /// todo(lachlan): UNIFY-1318 - figure out shape of these two args _showSnapshotVue = (snapshot: any, snapshotProps: AutSnapshot) => { const snapshotStore = useSnapshotStore() diff --git a/packages/driver/src/dom/blackout.ts b/packages/driver/src/dom/blackout.ts index 4fa75bb90c6..26691fe03f6 100644 --- a/packages/driver/src/dom/blackout.ts +++ b/packages/driver/src/dom/blackout.ts @@ -1,16 +1,6 @@ import $ from 'jquery' import $dimensions from './dimensions' -const resetStyles = ` - border: none !important; - margin: 0 !important; - padding: 0 !important; -` - -const styles = (styleString) => { - return styleString.replace(/\s*\n\s*/g, '') -} - function addBlackoutForElement ($body: JQuery, $el: JQuery) { const dimensions = $dimensions.getElementDimensions($el) const width = dimensions.widthWithBorder @@ -18,18 +8,13 @@ function addBlackoutForElement ($body: JQuery, $el: JQuery`).appendTo($body) + const style = `border: none !important; margin: 0 !important; padding: 0 !important; position: absolute; top: ${top}px; left: ${left}px; width: ${width}px; height: ${height}px; background-color: black; z-index: 2147483647;` + + const div = document.createElement('div') + + div.className = '__cypress-blackout' + div.style.cssText = style + $body.append(div) } function addBlackouts ($body: JQuery, $container: JQuery, selector: string) { diff --git a/packages/driver/src/dom/dimensions.ts b/packages/driver/src/dom/dimensions.ts index 76928fbba6f..586b7b185e7 100644 --- a/packages/driver/src/dom/dimensions.ts +++ b/packages/driver/src/dom/dimensions.ts @@ -33,22 +33,27 @@ const getElementDimensions = ($el: JQuery) => { const { offsetHeight, offsetWidth } = el + // Call getComputedStyle once and cache the result to avoid + // multiple layout/reflow operations. + const computedStyle: CSSStyleDeclaration = getComputedStyle(el, null) + const box: Box = { // offset disregards margin but takes into account border + padding offset: $el.offset(), // dont use jquery here for width/height because it uses getBoundingClientRect() which returns scaled values. - paddingTop: getPadding($el, 'top'), - paddingRight: getPadding($el, 'right'), - paddingBottom: getPadding($el, 'bottom'), - paddingLeft: getPadding($el, 'left'), - borderTop: getBorder($el, 'top'), - borderRight: getBorder($el, 'right'), - borderBottom: getBorder($el, 'bottom'), - borderLeft: getBorder($el, 'left'), - marginTop: getMargin($el, 'top'), - marginRight: getMargin($el, 'right'), - marginBottom: getMargin($el, 'bottom'), - marginLeft: getMargin($el, 'left'), + // Use cached computedStyle instead of calling $el.css() multiple times + paddingTop: getPaddingFromStyle(computedStyle, 'top'), + paddingRight: getPaddingFromStyle(computedStyle, 'right'), + paddingBottom: getPaddingFromStyle(computedStyle, 'bottom'), + paddingLeft: getPaddingFromStyle(computedStyle, 'left'), + borderTop: getBorderFromStyle(computedStyle, 'top'), + borderRight: getBorderFromStyle(computedStyle, 'right'), + borderBottom: getBorderFromStyle(computedStyle, 'bottom'), + borderLeft: getBorderFromStyle(computedStyle, 'left'), + marginTop: getMarginFromStyle(computedStyle, 'top'), + marginRight: getMarginFromStyle(computedStyle, 'right'), + marginBottom: getMarginFromStyle(computedStyle, 'bottom'), + marginLeft: getMarginFromStyle(computedStyle, 'left'), } // NOTE: offsetWidth/height always give us content + padding + border, so subtract them @@ -81,11 +86,13 @@ const getElementDimensions = ($el: JQuery) => { } type dir = 'top' | 'right' | 'bottom' | 'left' -type attr = `padding-${dir}` | `border-${dir}-width` | `margin-${dir}` -const getNumAttrValue = ($el: JQuery, attr: attr) => { +// Helper to extract numeric value from computed style property +// Replicates the behavior of $el.css(attr).replace(/[^0-9\.-]+/, '') +const getStylePropertyNumber = (computedStyle: CSSStyleDeclaration, property: string): number => { + const value = computedStyle.getPropertyValue(property) // nuke anything thats not a number or a negative symbol - const num = _.toNumber($el.css(attr).replace(/[^0-9\.-]+/, '')) + const num = _.toNumber(value.replace(/[^0-9\.-]+/, '')) if (!_.isFinite(num)) { throw new Error('Element attr did not return a valid number') @@ -94,16 +101,17 @@ const getNumAttrValue = ($el: JQuery, attr: attr) => { return num } -const getPadding = ($el: JQuery, dir: dir) => { - return getNumAttrValue($el, `padding-${dir}`) +// Optimized versions that read from cached computedStyle +const getPaddingFromStyle = (computedStyle: CSSStyleDeclaration, dir: dir): number => { + return getStylePropertyNumber(computedStyle, `padding-${dir}`) } -const getBorder = ($el: JQuery, dir: dir) => { - return getNumAttrValue($el, `border-${dir}-width`) +const getBorderFromStyle = (computedStyle: CSSStyleDeclaration, dir: dir): number => { + return getStylePropertyNumber(computedStyle, `border-${dir}-width`) } -const getMargin = ($el: JQuery, dir: dir) => { - return getNumAttrValue($el, `margin-${dir}`) +const getMarginFromStyle = (computedStyle: CSSStyleDeclaration, dir: dir): number => { + return getStylePropertyNumber(computedStyle, `margin-${dir}`) } export default {